You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2355 lines
79 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<string, SpritePoolEntry>();
private _objectSpritePool = new Map<string, SpritePoolEntry>();
private _tileSpriteFreeList: Sprite[] = [];
private _objectSpriteFreeList: Sprite[] = [];
private _usedTileSprites = new Set<string>();
private _usedObjectSprites = new Set<string>();
private _townObjectSpritePool = new Map<string, SpritePoolEntry>();
private _townObjectSpriteFreeList: Sprite[] = [];
private _usedTownObjectSprites = new Set<string>();
private _emptySpriteSet = new Set<string>();
private _buildingSpritePool = new Map<string, SpritePoolEntry>();
private _characterSpritePool = new Map<string, SpritePoolEntry>();
private _npcSpritePool = new Map<string, SpritePoolEntry>();
private _usedBuildingSprites = new Set<string>();
private _usedNpcSprites = new Set<string>();
private _groundDirty = true;
private _lastGroundBounds:
| {
startX: number;
endX: number;
startY: number;
endY: number;
}
| null = null;
private _lastSpritesReady = false;
// Reusable Graphics 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<string, SpritePoolEntry>();
private _usedNearbyHeroSprites = new Set<string>();
private _lastEntitySortMs = 0;
private _entitySortIntervalMs = 120;
private _townDrawDirty = true;
private _lastTownDrawMs = 0;
private _townDrawIntervalMs = 120;
private _lastTownCameraX = Number.NaN;
private _lastTownCameraY = Number.NaN;
private _lastTownsRef: TownData[] | null = null;
private _lastTownCount = -1;
private _drawBush(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.25) * 3.5;
gfx.ellipse(x - 4 * s, y - 7 * s, 7 * s, 4.5 * s);
gfx.fill({ color: 0x356e2b, alpha: 0.95 });
gfx.ellipse(x + 3 * s, y - 8 * s, 6.5 * s, 4 * s);
gfx.fill({ color: 0x2f6526, alpha: 0.95 });
gfx.ellipse(x, y - 10 * s, 8 * s, 4.5 * s);
gfx.fill({ color: 0x3f7d32, alpha: 0.95 });
gfx.ellipse(x, y - 5 * s, 9 * s, 2.5 * s);
gfx.fill({ color: 0x22481b, alpha: 0.35 });
}
private _drawTree(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.95 + variant * 0.35) * 3.5;
gfx.rect(x - 2 * s, y - 24 * s, 4 * s, 12 * s);
gfx.fill({ color: 0x5a3d24, alpha: 0.98 });
gfx.ellipse(x, y - 28 * s, 11 * s, 7.5 * s);
gfx.fill({ color: 0x2f6c29, alpha: 0.98 });
gfx.ellipse(x - 6 * s, y - 24 * s, 8 * s, 6 * s);
gfx.fill({ color: 0x3a7e31, alpha: 0.98 });
gfx.ellipse(x + 6 * s, y - 24 * s, 8 * s, 6 * s);
gfx.fill({ color: 0x3a7e31, alpha: 0.98 });
gfx.ellipse(x, y - 19 * s, 10 * s, 6 * s);
gfx.fill({ color: 0x2a5f23, alpha: 0.92 });
}
private _drawRock(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.8 + variant * 0.4) * 3.5;
gfx.ellipse(x, y - 4 * s, 7 * s, 4 * s);
gfx.fill({ color: 0x6b6b6b, alpha: 0.95 });
gfx.ellipse(x - 2 * s, y - 6 * s, 5 * s, 3.5 * s);
gfx.fill({ color: 0x7e7e7e, alpha: 0.95 });
gfx.ellipse(x, y - 2 * s, 8 * s, 2 * s);
gfx.fill({ color: 0x3a3a3a, alpha: 0.25 });
}
private _drawStump(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.85 + variant * 0.2) * 3.2;
gfx.roundRect(x - 5 * s, y - 8 * s, 10 * s, 9 * s, 2 * s);
gfx.fill({ color: 0x4a3020, alpha: 0.98 });
gfx.ellipse(x, y - 10 * s, 6 * s, 3 * s);
gfx.fill({ color: 0x3d2818, alpha: 0.9 });
}
private _drawBrokenCart(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.15) * 3;
gfx.rect(x - 14 * s, y - 3 * s, 22 * s, 6 * s);
gfx.fill({ color: 0x5c4030, alpha: 0.95 });
gfx.rect(x + 8 * s, y - 2 * s, 3 * s, 8 * s);
gfx.fill({ color: 0x3a3a3a, alpha: 0.9 });
gfx.circle(x - 10 * s, y + 4 * s, 3 * s);
gfx.fill({ color: 0x2a2a2a, alpha: 0.85 });
}
private _drawBones(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.8 + variant * 0.3) * 2.8;
gfx.roundRect(x - 8 * s, y - 2 * s, 16 * s, 3 * s, s);
gfx.fill({ color: 0xddd5c8, alpha: 0.9 });
gfx.circle(x - 5 * s, y - s, 2 * s);
gfx.fill({ color: 0xc9c2b6, alpha: 0.9 });
gfx.circle(x + 6 * s, y, 1.5 * s);
gfx.fill({ color: 0xb0a898, alpha: 0.85 });
}
private _drawMushroom(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.85 + variant * 0.25) * 3;
gfx.rect(x - 1.5 * s, y - 4 * s, 3 * s, 6 * s);
gfx.fill({ color: 0xe8e0d5, alpha: 0.95 });
gfx.ellipse(x, y - 8 * s, 5 * s, 3.5 * s);
gfx.fill({ color: variant > 0.5 ? 0xc44a4a : 0xd9c04a, alpha: 0.95 });
gfx.ellipse(x, y - 8 * s, 2 * s, 1.2 * s);
gfx.fill({ color: 0xffffff, alpha: 0.35 });
}
private _drawRuin(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.2) * 3.2;
gfx.rect(x - 4 * s, y - 14 * s, 4 * s, 14 * s);
gfx.fill({ color: 0x6a6a72, alpha: 0.95 });
gfx.rect(x + 2 * s, y - 10 * s, 5 * s, 10 * s);
gfx.fill({ color: 0x5a5a62, alpha: 0.92 });
gfx.rect(x - 1 * s, y - 4 * s, 8 * s, 3 * s);
gfx.fill({ color: 0x4a4a52, alpha: 0.88 });
}
private _drawMarketStall(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.95 + variant * 0.1) * 3.5;
gfx.rect(x - 12 * s, y - 2 * s, 24 * s, 5 * s);
gfx.fill({ color: 0x6b4a32, alpha: 0.96 });
gfx.poly([x, y - 16 * s, x + 14 * s, y - 4 * s, x - 14 * s, y - 4 * s]);
gfx.fill({ color: 0x8b3a3a, alpha: 0.94 });
gfx.rect(x - 3 * s, y - 8 * s, 6 * s, 5 * s);
gfx.fill({ color: 0x2a5080, alpha: 0.85 });
}
private _drawWell(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.15) * 3.2;
gfx.ellipse(x, y - 2 * s, 9 * s, 5 * s);
gfx.fill({ color: 0x5a5a62, alpha: 0.95 });
gfx.ellipse(x, y - 4 * s, 5 * s, 3 * s);
gfx.fill({ color: 0x1a4a6e, alpha: 0.85 });
gfx.rect(x - 2 * s, y - 12 * s, 4 * s, 8 * s);
gfx.fill({ color: 0x7a7a82, alpha: 0.9 });
}
private _drawBanner(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.85 + variant * 0.2) * 3;
gfx.rect(x - 1.2 * s, y - 18 * s, 2.4 * s, 18 * s);
gfx.fill({ color: 0x4a3a28, alpha: 0.95 });
gfx.poly([x + 1.2 * s, y - 18 * s, x + 12 * s, y - 14 * s, x + 1.2 * s, y - 10 * s]);
gfx.fill({ color: 0x6a3a8e, alpha: 0.92 });
}
private _drawBarrel(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.9 + variant * 0.15) * 2.8;
gfx.ellipse(x, y, 5 * s, 3 * s);
gfx.fill({ color: 0x5a4228, alpha: 0.9 });
gfx.rect(x - 5 * s, y - 7 * s, 10 * s, 7 * s);
gfx.fill({ color: 0x6b4a30, alpha: 0.92 });
gfx.ellipse(x, y - 7 * s, 5 * s, 3 * s);
gfx.fill({ color: 0x7a5a3a, alpha: 0.9 });
gfx.rect(x - 5 * s, y - 5 * s, 10 * s, s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
gfx.rect(x - 5 * s, y - 2 * s, 10 * s, s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
}
private _drawLeafPile(gfx: Graphics, x: number, y: number, variant: number): void {
const s = (0.85 + variant * 0.25) * 2.5;
const colors = [0x6a8a2a, 0x8a7a22, 0x5a7a28, 0x9a8a30];
for (let i = 0; i < 4; i++) {
const ox = (((variant * 100 + i * 37) | 0) % 7 - 3) * s;
const oy = (((variant * 100 + i * 53) | 0) % 5 - 2) * s * 0.5;
gfx.ellipse(x + ox, y + oy, 4 * s, 2.5 * s);
gfx.fill({ color: colors[i % colors.length]!, alpha: 0.7 });
}
}
private _terrainColors(terrain: string, dark: boolean): number {
if (terrain === 'plaza') return dark ? 0x5a5a62 : 0x6c6c75;
if (terrain === 'road') return dark ? 0x7b6545 : 0x8e7550;
if (terrain === 'dirt') return dark ? 0x6b5338 : 0x7c6242;
if (terrain === 'stone') return dark ? 0x5c5f67 : 0x6c7078;
if (terrain === 'forest_floor') return dark ? 0x234a1c : 0x2d5a24;
if (terrain === 'ruins_floor') return dark ? 0x4a4a48 : 0x5a5a58;
if (terrain === 'canyon_floor') return dark ? 0x7a6248 : 0x8b7355;
if (terrain === 'swamp_floor') return dark ? 0x324c36 : 0x3d5c42;
if (terrain === 'volcanic_floor') return dark ? 0x4a2e28 : 0x5c3830;
if (terrain === 'astral_floor') return dark ? 0x3a3568 : 0x4a4580;
return dark ? 0x2d5a1e : 0x3a7a28; // grass
}
private _terrainStrokeColor(terrain: string): number {
if (terrain === 'road') return 0x4e3f2d;
if (terrain === 'plaza' || terrain === 'stone') return 0x333340;
if (terrain === 'dirt') return 0x4a3828;
if (terrain === 'ruins_floor') return 0x2a2a30;
if (terrain === 'canyon_floor') return 0x5a4835;
if (terrain === 'swamp_floor') return 0x1a3a28;
if (terrain === 'volcanic_floor') return 0x4a2020;
if (terrain === 'astral_floor') return 0x302840;
return 0x1a4a12;
}
private _ensureSprite(
pool: Map<string, SpritePoolEntry>,
poolKey: string,
textureKey: string,
texture: SpritePoolEntry['sprite']['texture'],
layer: Container,
worldX?: number,
worldY?: number,
freeList?: Sprite[],
): SpritePoolEntry {
let entry = pool.get(poolKey);
if (!entry) {
const sprite = freeList && freeList.length > 0
? (freeList.pop() as Sprite)
: new Sprite(texture);
sprite.texture = texture;
sprite.anchor.set(0.5, 1);
sprite.roundPixels = true;
if (!sprite.parent) layer.addChild(sprite);
entry = { sprite, textureKey, worldX, worldY };
pool.set(poolKey, entry);
return entry;
}
if (entry.textureKey !== textureKey) {
entry.sprite.texture = texture;
entry.textureKey = textureKey;
}
if (typeof worldX === 'number' && typeof worldY === 'number') {
entry.worldX = worldX;
entry.worldY = worldY;
}
return entry;
}
private _hideUnusedSprites(pool: Map<string, SpritePoolEntry>, used: Set<string>): void {
for (const [key, entry] of pool) {
entry.sprite.visible = used.has(key);
}
}
private _evictSpritesOutsideTileBounds(
pool: Map<string, SpritePoolEntry>,
minX: number,
maxX: number,
minY: number,
maxY: number,
freeList: Sprite[],
maxFreeListSize: number,
): void {
for (const [key, entry] of pool) {
const wx = entry.worldX ?? Number.NaN;
const wy = entry.worldY ?? Number.NaN;
if (!Number.isFinite(wx) || !Number.isFinite(wy)) continue;
if (wx >= minX && wx <= maxX && wy >= minY && wy <= maxY) continue;
pool.delete(key);
const sprite = entry.sprite;
sprite.visible = false;
if (freeList.length < maxFreeListSize) {
freeList.push(sprite);
} else {
sprite.destroy();
}
}
}
/** Called from GameEngine when towns or route waypoints change. */
setWorldTerrainContext(ctx: WorldTerrainContext | null): void {
this._worldTerrainContext = ctx;
this._groundDirty = true;
}
constructor() {
this.app = new Application();
this.worldContainer = new Container();
this.groundLayer = new Container();
this.entityLayer = new Container();
this.effectLayer = new Container();
this.uiContainer = new Container();
this._groundSpriteLayer = new Container();
this._objectSpriteLayer = new Container();
this._buildingSpriteLayer = new Container();
this._townObjectSpriteLayer = new Container();
}
get initialized(): boolean {
return this._initialized;
}
/** Initialize the PixiJS application and attach to canvas container */
async init(canvasContainer: HTMLElement): Promise<void> {
if (this._initialized) return;
const viewport = getViewport();
await this.app.init({
width: viewport.width,
height: viewport.height,
backgroundColor: 0x1a1a2e,
resolution: Math.min(window.devicePixelRatio, 2),
autoDensity: true,
antialias: false,
powerPreference: 'high-performance',
});
canvasContainer.appendChild(this.app.canvas as HTMLCanvasElement);
try {
await this._spriteRegistry.loadAll();
this._spritesReady = this._spriteRegistry.ready;
} catch (error) {
this._spritesReady = false;
console.warn('[Renderer] Sprite preload failed, using fallback graphics.', error);
}
// Build scene graph
this.worldContainer.addChild(this.groundLayer);
this.worldContainer.addChild(this.entityLayer);
this.worldContainer.addChild(this.effectLayer);
this.app.stage.addChild(this.worldContainer);
this.app.stage.addChild(this.uiContainer);
// Center the world container and apply zoom
this.worldContainer.x = viewport.width / 2;
this.worldContainer.y = viewport.height / 2;
this.worldContainer.scale.set(MAP_ZOOM);
// Create reusable graphics objects
this.groundLayer.sortableChildren = true;
this.groundLayer.addChild(this._groundSpriteLayer);
this._groundSpriteLayer.zIndex = 0;
this._groundSpriteLayer.sortableChildren = true;
this._groundGfx = new Graphics();
this._groundGfx.zIndex = 1;
this.groundLayer.addChild(this._groundGfx);
this.groundLayer.addChild(this._objectSpriteLayer);
this._objectSpriteLayer.zIndex = 2;
this._objectSpriteLayer.sortableChildren = true;
this.groundLayer.addChild(this._townObjectSpriteLayer);
this._townObjectSpriteLayer.zIndex = 2;
this._townObjectSpriteLayer.sortableChildren = true;
this.groundLayer.addChild(this._buildingSpriteLayer);
this._buildingSpriteLayer.zIndex = 3;
this._buildingSpriteLayer.sortableChildren = true;
this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx);
this._meetPartnerGfx = new Graphics();
this.entityLayer.addChild(this._meetPartnerGfx);
this._restCampGfx = new Graphics();
this.entityLayer.addChild(this._restCampGfx);
this._enemyGfx = new Graphics();
this.entityLayer.addChild(this._enemyGfx);
this._thoughtGfx = new Graphics();
this.entityLayer.addChild(this._thoughtGfx);
this._thoughtText = new Text({
text: '',
style: new TextStyle({
fontSize: 11,
fontFamily: 'system-ui, sans-serif',
fill: 0x333333,
wordWrap: true,
wordWrapWidth: 210,
align: 'center',
}),
});
this._thoughtText.anchor.set(0.5, 0.5);
this._thoughtText.visible = false;
this.entityLayer.addChild(this._thoughtText);
for (let i = 0; i < 2; i++) {
const gfx = new Graphics();
this.entityLayer.addChild(gfx);
const txt = new Text({
text: '',
style: new TextStyle({
fontSize: 10,
fontFamily: 'system-ui, sans-serif',
fill: 0x333333,
wordWrap: true,
wordWrapWidth: 200,
align: 'center',
}),
});
txt.anchor.set(0.5, 0.5);
txt.visible = false;
this.entityLayer.addChild(txt);
this._meetBubbleSlots.push({ gfx, txt });
}
this._heroNameText = new Text({
text: '',
style: new TextStyle({
fontSize: 11,
fontFamily: 'system-ui, sans-serif',
fill: 0xffffff,
stroke: { color: 0x000000, width: 3 },
align: 'center',
}),
});
this._heroNameText.anchor.set(0.5, 0.5);
this._heroNameText.visible = false;
this.entityLayer.addChild(this._heroNameText);
this._meetPartnerLabel = new Text({
text: '',
style: new TextStyle({
fontSize: 11,
fontFamily: 'system-ui, sans-serif',
fill: 0xffffff,
stroke: { color: 0x000000, width: 3 },
align: 'center',
}),
});
this._meetPartnerLabel.anchor.set(0.5, 0.5);
this._meetPartnerLabel.visible = false;
this.entityLayer.addChild(this._meetPartnerLabel);
// Town graphics (drawn between ground and entity layers)
this._townGfx = new Graphics();
this._townGfx.zIndex = 4;
this.groundLayer.addChild(this._townGfx);
this._townIconGfx = new Graphics();
this._townIconGfx.zIndex = 5;
this.groundLayer.addChild(this._townIconGfx);
// NPC graphics (drawn in entity layer for depth sorting)
this._npcGfx = new Graphics();
this.entityLayer.addChild(this._npcGfx);
// Nearby hero graphics
this._nearbyHeroGfx = new Graphics();
this.entityLayer.addChild(this._nearbyHeroGfx);
this._initialized = true;
console.info(`[Renderer] Initialized ${viewport.width}x${viewport.height} @${this.app.renderer.resolution}x`);
}
/** Handle window/viewport resize */
resize(): void {
if (!this._initialized) return;
const viewport = getViewport();
this.app.renderer.resize(viewport.width, viewport.height);
this.worldContainer.x = viewport.width / 2;
this.worldContainer.y = viewport.height / 2;
this.worldContainer.scale.set(MAP_ZOOM);
this._townDrawDirty = true;
}
/**
* Draw visible ground tiles and decorative objects.
* Terrain is procedurally generated — the world is endless.
*/
drawGround(camera: Camera, screenWidth: number, screenHeight: number): void {
const gfx = this._groundGfx;
if (!gfx) return;
const cx = camera.finalX;
const cy = camera.finalY;
const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 2;
const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 2;
const worldCorners = [
screenToWorld(cx - halfW, cy - halfH),
screenToWorld(cx + halfW, cy - halfH),
screenToWorld(cx - halfW, cy + halfH),
screenToWorld(cx + halfW, cy + halfH),
];
const renderPaddingTiles = 4;
let minWX = Number.POSITIVE_INFINITY;
let maxWX = Number.NEGATIVE_INFINITY;
let minWY = Number.POSITIVE_INFINITY;
let maxWY = Number.NEGATIVE_INFINITY;
for (const corner of worldCorners) {
if (corner.x < minWX) minWX = corner.x;
if (corner.x > maxWX) maxWX = corner.x;
if (corner.y < minWY) minWY = corner.y;
if (corner.y > maxWY) maxWY = corner.y;
}
minWX -= renderPaddingTiles;
maxWX += renderPaddingTiles;
minWY -= renderPaddingTiles;
maxWY += renderPaddingTiles;
const startX = Math.floor(minWX);
const endX = Math.ceil(maxWX);
const startY = Math.floor(minWY);
const endY = Math.ceil(maxWY);
const spritesReady = this._spritesReady;
const last = this._lastGroundBounds;
const sameBounds =
last &&
last.startX === startX &&
last.endX === endX &&
last.startY === startY &&
last.endY === endY;
if (!this._groundDirty && sameBounds && this._lastSpritesReady === spritesReady) {
return;
}
this._groundDirty = false;
this._lastGroundBounds = { startX, endX, startY, endY };
this._lastSpritesReady = spritesReady;
gfx.clear();
const hw = TILE_WIDTH / 2;
const hh = TILE_HEIGHT / 2;
const terrainCtx = this._worldTerrainContext;
const usedTileSprites = this._usedTileSprites;
const usedObjectSprites = this._usedObjectSprites;
usedTileSprites.clear();
usedObjectSprites.clear();
const tileMinX = cx - (halfW + hw);
const tileMaxX = cx + (halfW + hw);
const tileMinY = cy - (halfH + hh);
const tileMaxY = cy + (halfH + hh);
// Pass 2 needs slightly expanded bounds for oversized props.
const objectPaddingTiles = 4;
const objectStartX = startX - objectPaddingTiles;
const objectEndX = endX + objectPaddingTiles;
const objectStartY = startY - objectPaddingTiles;
const objectEndY = endY + objectPaddingTiles;
const objectGridH = objectEndY - objectStartY + 1;
const terrainGrid = new Array<string | undefined>(
(objectEndX - objectStartX + 1) * objectGridH,
);
const terrainIndex = (wx: number, wy: number): number =>
(wx - objectStartX) * objectGridH + (wy - objectStartY);
const terrainAt = (wx: number, wy: number): string => {
const idx = terrainIndex(wx, wy);
const cached = terrainGrid[idx];
if (cached !== undefined) return cached;
const terrain = proceduralTerrain(wx, wy, terrainCtx);
terrainGrid[idx] = terrain;
return terrain;
};
// Pass 1: tiles
for (let wx = startX; wx <= endX; wx++) {
let isoX = (wx - startY) * hw;
let isoY = (wx + startY) * hh;
for (let wy = startY; wy <= endY; wy++) {
if (isoX < tileMinX || isoX > tileMaxX || isoY < tileMinY || isoY > tileMaxY) {
isoX -= hw;
isoY += hh;
continue;
}
const terrain = terrainAt(wx, wy);
const dark = (wx + wy) % 2 === 0;
const textureKey = spritesReady ? terrainToTextureKey(terrain) : null;
const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
if (textureKey && texture) {
const poolKey = `${wx},${wy}`;
usedTileSprites.add(poolKey);
const entry = this._ensureSprite(
this._tileSpritePool,
poolKey,
textureKey,
texture,
this._groundSpriteLayer,
wx,
wy,
this._tileSpriteFreeList,
);
entry.sprite.x = isoX;
entry.sprite.y = isoY + hh;
entry.sprite.zIndex = isoY + hh;
const texW = entry.sprite.texture.orig.width || entry.sprite.texture.width || TILE_WIDTH;
const scale = (TILE_WIDTH / texW) * TERRAIN_SEAM_BLEED_SCALE;
entry.sprite.scale.set(scale);
entry.sprite.visible = true;
} else {
const color = this._terrainColors(terrain, dark);
gfx.poly([
isoX, isoY - hh,
isoX + hw, isoY,
isoX, isoY + hh,
isoX - hw, isoY,
]);
gfx.fill({ color, alpha: 1 });
gfx.poly([
isoX, isoY - hh,
isoX + hw, isoY,
isoX, isoY + hh,
isoX - hw, isoY,
]);
gfx.stroke({
color: this._terrainStrokeColor(terrain),
width: 1,
alpha: 0.25,
});
}
isoX -= hw;
isoY += hh;
}
}
// Pass 2: objects (drawn after tiles so they layer on top)
// Slightly expanded object bounds prevent enlarged props from edge clipping.
const objectMinX = cx - (halfW + TILE_WIDTH * 1.5);
const objectMaxX = cx + (halfW + TILE_WIDTH * 1.5);
const objectMinY = cy - (halfH + TILE_HEIGHT * 2);
const objectMaxY = cy + (halfH + TILE_HEIGHT * 2);
for (let wx = objectStartX; wx <= objectEndX; wx++) {
let isoX = (wx - objectStartY) * hw;
let isoY = (wx + objectStartY) * hh;
for (let wy = objectStartY; wy <= objectEndY; wy++) {
if (isoX < objectMinX || isoX > objectMaxX || isoY < objectMinY || isoY > objectMaxY) {
isoX -= hw;
isoY += hh;
continue;
}
const terrainHere = terrainAt(wx, wy);
const obj = proceduralObject(wx, wy, terrainHere, terrainCtx);
if (!obj) {
isoX -= hw;
isoY += hh;
continue;
}
const variant = tileHash(wx, wy, 999);
const objTextureKey = spritesReady ? objectToTextureKey(obj, variant) : null;
const objTexture = objTextureKey ? this._spriteRegistry.getTexture(objTextureKey) : null;
if (objTextureKey && objTexture) {
const poolKey = `${wx},${wy}`;
usedObjectSprites.add(poolKey);
const entry = this._ensureSprite(
this._objectSpritePool,
poolKey,
objTextureKey,
objTexture,
this._objectSpriteLayer,
wx,
wy,
this._objectSpriteFreeList,
);
entry.sprite.x = isoX;
entry.sprite.y = isoY;
entry.sprite.zIndex = isoY;
entry.sprite.visible = true;
} else {
if (obj === 'tree') this._drawTree(gfx, isoX, isoY, variant);
else if (obj === 'bush') this._drawBush(gfx, isoX, isoY, variant);
else if (obj === 'rock') this._drawRock(gfx, isoX, isoY, variant);
else if (obj === 'stump') this._drawStump(gfx, isoX, isoY, variant);
else if (obj === 'cart') this._drawBrokenCart(gfx, isoX, isoY, variant);
else if (obj === 'bones') this._drawBones(gfx, isoX, isoY, variant);
else if (obj === 'mushroom') this._drawMushroom(gfx, isoX, isoY, variant);
else if (obj === 'ruin') this._drawRuin(gfx, isoX, isoY, variant);
else if (obj === 'stall') this._drawMarketStall(gfx, isoX, isoY, variant);
else if (obj === 'well') this._drawWell(gfx, isoX, isoY, variant);
else if (obj === 'banner') this._drawBanner(gfx, isoX, isoY, variant);
else if (obj === 'barrel') this._drawBarrel(gfx, isoX, isoY, variant);
else if (obj === 'leaves') this._drawLeafPile(gfx, isoX, isoY, variant);
}
isoX -= hw;
isoY += hh;
}
}
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<string>,
): boolean {
if (!this._spritesReady) return false;
const spriteTexture = this._spriteRegistry.getTexture(textureKey);
if (!spriteTexture) return false;
usedBuildingSprites.add(poolKey);
const entry = this._ensureSprite(
this._buildingSpritePool,
poolKey,
textureKey,
spriteTexture,
this._buildingSpriteLayer,
);
entry.sprite.x = bx;
entry.sprite.y = by;
const texW = entry.sprite.texture.width || targetWidth;
const scaleFactor = texW > 0 ? targetWidth / texW : 1;
entry.sprite.scale.set(scaleFactor);
entry.sprite.zIndex = by;
entry.sprite.visible = true;
return true;
}
/** Draw a market stall structure for town variety. */
private _drawTownStall(gfx: Graphics, cx: number, cy: number, s: number): void {
// Counter / table
gfx.rect(cx - 18 * s, cy - 6 * s, 36 * s, 8 * s);
gfx.fill({ color: 0x6b4a32, alpha: 0.95 });
// Cloth awning
gfx.poly([
cx - 22 * s, cy - 6 * s,
cx + 22 * s, cy - 6 * s,
cx + 20 * s, cy - 20 * s,
cx - 20 * s, cy - 20 * s,
]);
gfx.fill({ color: 0x8b3a3a, alpha: 0.85 });
// Supports
gfx.rect(cx - 18 * s, cy - 20 * s, 2 * s, 14 * s);
gfx.fill({ color: 0x4a3a28, alpha: 0.9 });
gfx.rect(cx + 16 * s, cy - 20 * s, 2 * s, 14 * s);
gfx.fill({ color: 0x4a3a28, alpha: 0.9 });
// Goods on counter
gfx.circle(cx - 6 * s, cy - 8 * s, 3 * s);
gfx.fill({ color: 0xddaa44, alpha: 0.7 });
gfx.circle(cx + 4 * s, cy - 8 * s, 2.5 * s);
gfx.fill({ color: 0x44aa88, alpha: 0.7 });
}
/**
* Draw server-defined buildings for a town. Each building type gets a distinct
* visual style so players can identify NPC houses at a glance.
*/
private _drawServerBuildings(
gfx: Graphics,
iconGfx: Graphics,
usedBuildingSprites: Set<string>,
buildings: BuildingData[],
_townScreenX: number,
_townScreenY: number,
scale: number,
): void {
for (let i = 0; i < buildings.length; i++) {
const b = buildings[i]!;
const bScreen = worldToScreen(b.worldX, b.worldY);
const bx = bScreen.x;
const by = bScreen.y;
const bt = b.buildingType;
const isNpcHouse = bt.startsWith('house.');
const footprintW = Math.max(1, b.footprintW);
const footprintH = Math.max(1, b.footprintH);
const baseW = 60 * scale * (footprintW / 2.5);
const baseH = 48 * scale * (footprintH / 2.0);
const houseSizeBoost = 1.95;
const w = isNpcHouse ? Math.max(baseW * houseSizeBoost, 54 * scale) : baseW;
const h = isNpcHouse ? Math.max(baseH * houseSizeBoost, 42 * scale) : baseH;
const rh = isNpcHouse ? 32 * scale * 1.65 : 32 * scale;
const spriteDrawW =
bt === 'decoration.well'
? Math.max(w, 56 * scale)
: bt === 'decoration.stall'
? Math.max(w, 44 * scale)
: w;
const spriteKey = buildingTypeToTextureKey(bt);
const spriteTexture =
this._spritesReady && spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null;
const hasUsableSprite = spriteKey !== null && spriteTexture !== null;
if (spriteKey !== null && spriteTexture !== null) {
const poolKey = `building:${b.id}`;
usedBuildingSprites.add(poolKey);
const entry = this._ensureSprite(
this._buildingSpritePool,
poolKey,
spriteKey,
spriteTexture,
this._buildingSpriteLayer,
);
entry.sprite.x = bx;
entry.sprite.y = by;
const texW = entry.sprite.texture.width || spriteDrawW;
const scaleFactor = texW > 0 ? spriteDrawW / texW : 1;
entry.sprite.scale.set(scaleFactor);
entry.sprite.zIndex = by;
entry.sprite.visible = true;
}
const hasSprite = hasUsableSprite;
if (!hasSprite) {
if (bt === 'house.quest_giver') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xb89040, 0x6a3a22, 0);
this._drawFence(gfx, bx, by, w, 'left');
} else if (bt === 'house.merchant') {
this._drawHouse(gfx, bx, by, w * 1.1, h, rh * 0.8, 0x44aa55, 0x2a5a30, 1);
this._drawTownStall(gfx, bx + w * 0.7, by + 4, scale * 0.6);
} else if (bt === 'house.armorer') {
this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x5a6e8a, 0x2a3548, 1);
} else if (bt === 'house.weapon_smith') {
this._drawHouse(gfx, bx, by, w * 1.05, h, rh * 0.85, 0x8a5a3a, 0x4a3020, 1);
} else if (bt === 'house.jeweler') {
this._drawHouse(gfx, bx, by, w, h * 0.95, rh * 0.9, 0x7a4a9a, 0x3a2050, 2);
} else if (bt === 'house.bounty_hunter') {
this._drawHouse(gfx, bx, by, w, h, rh, 0x906040, 0x4a2818, 0);
this._drawFence(gfx, bx, by, w, 'right');
} else if (bt === 'house.elder') {
this._drawHouse(gfx, bx, by, w * 0.98, h, rh * 1.05, 0x9a8860, 0x5a4830, 0);
} else if (bt === 'house.healer') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2);
} else if (bt === 'decoration.well') {
this._drawTownWell(gfx, bx, by, scale);
} else if (bt === 'decoration.stall') {
this._drawTownStall(gfx, bx, by, scale * 0.9);
} else if (bt === 'decoration.signpost') {
this._drawSignpost(gfx, bx, by, scale);
}
}
const twIcon = spriteTexture?.width ?? 0;
const thIcon = spriteTexture?.height ?? 0;
const iconYBase =
hasUsableSprite && twIcon > 0
? by - ((thIcon * spriteDrawW) / twIcon) * 0.9
: by - h - rh * 0.45;
if (bt === 'house.quest_giver') {
this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, '!', 0xffd700, scale);
} else if (bt === 'house.merchant') {
this._drawBuildingIcon(iconGfx, bx, iconYBase + rh * 0.12, '$', 0x88dd88, scale);
} else if (bt === 'house.armorer') {
this._drawBuildingIcon(iconGfx, bx, iconYBase, 'A', 0xaaccff, scale);
} else if (bt === 'house.weapon_smith') {
this._drawBuildingIcon(iconGfx, bx, iconYBase + rh * 0.04, 'W', 0xffaa66, scale);
} else if (bt === 'house.jeweler') {
this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.06, 'J', 0xdd88ff, scale);
} else if (bt === 'house.bounty_hunter') {
this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, 'B', 0xffcc44, scale);
} else if (bt === 'house.elder') {
this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, 'E', 0xeeddaa, scale);
} else if (bt === 'house.healer') {
this._drawBuildingIcon(iconGfx, bx, iconYBase - rh * 0.05, '+', 0xff6666, scale);
}
}
}
/** Draw a small icon circle above a building to indicate its purpose. */
private _drawBuildingIcon(
gfx: Graphics, cx: number, cy: number, _icon: string, color: number, scale: number,
): void {
const r = 6 * scale;
gfx.circle(cx, cy, r);
gfx.fill({ color, alpha: 0.6 });
gfx.stroke({ color: 0x000000, width: 1.2, alpha: 0.4 });
}
/** Draw a town well decoration (server-driven building). */
private _drawTownWell(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.ellipse(cx, cy, 10 * s, 5 * s);
gfx.fill({ color: 0x6a6a7a, alpha: 0.8 });
gfx.stroke({ color: 0x4a4a5a, width: 1.5, alpha: 0.6 });
gfx.rect(cx - s, cy - 12 * s, 2 * s, 12 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
gfx.rect(cx - 6 * s, cy - 13 * s, 12 * s, 2 * s);
gfx.fill({ color: 0x5a4a3a, alpha: 0.9 });
}
/** Small plaza fountain (procedural towns; server towns use decoration.well at center). */
private _drawTownFountain(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.ellipse(cx, cy + 2 * s, 14 * s, 7 * s);
gfx.fill({ color: 0x4a5a6a, alpha: 0.85 });
gfx.stroke({ color: 0x3a4550, width: 1.2, alpha: 0.65 });
gfx.ellipse(cx, cy + 2 * s, 10 * s, 5 * s);
gfx.fill({ color: 0x5a8aaa, alpha: 0.45 });
gfx.rect(cx - 3 * s, cy - 14 * s, 6 * s, 16 * s);
gfx.fill({ color: 0x7a7a88, alpha: 0.9 });
gfx.rect(cx - 5 * s, cy - 16 * s, 10 * s, 3 * s);
gfx.fill({ color: 0x6a6a78, alpha: 0.88 });
gfx.circle(cx, cy - 10 * s, 2.2 * s);
gfx.fill({ color: 0xaaddff, alpha: 0.35 });
gfx.arc(cx, cy - 10 * s, 3 * s, -Math.PI * 0.85, -Math.PI * 0.15);
gfx.stroke({ color: 0x88ccff, width: 1.2, alpha: 0.4 });
}
/**
* Paved town square at the settlement center (under well / fountain and civic building).
*/
private _drawTownPlaza(
gfx: Graphics,
tx: number,
ty: number,
groundW: number,
groundH: number,
): void {
const pw = groundW * 0.42;
const ph = groundH * 0.42;
gfx.ellipse(tx, ty, pw, ph);
gfx.fill({ color: 0x7a7878, alpha: 0.55 });
gfx.stroke({ color: 0x5a5858, width: 1.2, alpha: 0.45 });
const step = Math.max(10, pw * 0.14);
for (let dx = -pw + step * 0.3; dx < pw; dx += step) {
for (let dy = -ph + step * 0.25; dy < ph; dy += step * 0.85) {
if ((dx * dx) / (pw * pw) + (dy * dy) / (ph * ph) > 0.82) continue;
const h = ((Math.floor(dx / step) * 31) ^ (Math.floor(dy / step) * 17)) & 1;
gfx.rect(tx + dx - step * 0.08, ty + dy - step * 0.08, step * 0.45, step * 0.38);
gfx.fill({ color: h ? 0x6a686e : 0x757278, alpha: 0.35 });
}
}
}
/** Single civic building (hall / notice board) facing the plaza — not an NPC home. */
private _drawCivicBuilding(gfx: Graphics, cx: number, cy: number, s: number): void {
const w = 52 * s * 3;
const h = 38 * s * 3;
const rh = 26 * s * 3;
gfx.rect(cx - w / 2, cy - h, w, h);
gfx.fill({ color: 0x8a9098, alpha: 0.95 });
gfx.stroke({ color: 0x4a5058, width: 1.2, alpha: 0.55 });
gfx.poly([
cx - w / 2 - 4 * s, cy - h,
cx + w / 2 + 4 * s, cy - h,
cx + w / 2, cy - h - rh,
cx - w / 2, cy - h - rh,
]);
gfx.fill({ color: 0x4a5560, alpha: 0.92 });
gfx.rect(cx - 8 * s, cy - h * 0.65, 16 * s, 22 * s);
gfx.fill({ color: 0x2a3540, alpha: 0.75 });
gfx.stroke({ color: 0x1a2530, width: 0.8, alpha: 0.5 });
gfx.rect(cx - w / 2 + 6 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55);
gfx.fill({ color: 0x6a7580, alpha: 0.85 });
gfx.rect(cx + w / 2 - 10 * s, cy - h - rh * 0.35, 4 * s, rh * 0.55);
gfx.fill({ color: 0x6a7580, alpha: 0.85 });
}
/** Draw a signpost decoration. */
private _drawSignpost(gfx: Graphics, cx: number, cy: number, s: number): void {
gfx.rect(cx - s, cy - 16 * s, 2 * s, 16 * s);
gfx.fill({ color: 0x6a5a3a, alpha: 0.9 });
gfx.poly([
cx + 2 * s, cy - 14 * s,
cx + 12 * s, cy - 13 * s,
cx + 12 * s, cy - 10 * s,
cx + 2 * s, cy - 9 * s,
]);
gfx.fill({ color: 0x8a7a5a, alpha: 0.85 });
}
/**
* Fallback procedural building placement when server buildings are unavailable.
*/
private _drawProceduralBuildings(
gfx: Graphics, tx: number, ty: number, s: number,
spread: number, size: string, townSeed: number,
usedBuildingSprites: Set<string>, townId: number,
): void {
const houseCount = size === 'XS' ? 5 : size === 'S' ? 7 : size === 'M' ? 10 : 14;
const wallColors = [0x9a7e5a, 0x8b7252, 0xa08860, 0x7e6844, 0x907656, 0x9e8862, 0x887050];
const roofColors = [0x6a3a22, 0x5a3020, 0x7a4028, 0x5e3422, 0x6e3a24, 0x724030, 0x603828];
const baseW = 60;
const baseH = 48;
const baseRH = 32;
for (let i = 0; i < houseCount; i++) {
const hash = ((townSeed * 31 + i * 17) ^ (i * 0x45d9f3b)) >>> 0;
const r1 = (hash & 0xffff) / 0xffff;
const r2 = ((hash >> 16) & 0xffff) / 0xffff;
const r3 = ((hash * 7 + i * 13) & 0xff) / 0xff;
const angle = (i / houseCount) * Math.PI * 2 + r1 * 0.4;
// Keep town center clear for plaza + fountain/well + civic building
const dist = spread * (0.36 + r2 * 0.52);
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist * 0.5;
const sizeVar = 0.7 + r3 * 0.5;
const w = baseW * s * sizeVar;
const h = baseH * s * sizeVar;
const rh = baseRH * s * sizeVar;
const roofStyle = i % 5 === 0 ? 1 : i % 3 === 0 ? 2 : 0;
const hx = tx + dx;
const hy = ty + dy;
const facadeKey =
PROCEDURAL_TOWN_HOUSE_FACADE_KEYS[hash % PROCEDURAL_TOWN_HOUSE_FACADE_KEYS.length]!;
const poolKey = `town:${townId}:proc_house:${i}`;
const placedSprite = this._placeBuildingLayerSprite(
poolKey,
facadeKey,
hx,
hy,
w,
usedBuildingSprites,
);
if (!placedSprite) {
this._drawHouse(
gfx, hx, hy, w, h, rh,
wallColors[i % wallColors.length]!,
roofColors[i % roofColors.length]!,
roofStyle,
);
}
if (i % 4 === 1) {
this._drawFence(gfx, hx, hy, w, i % 2 === 0 ? 'left' : 'right');
}
}
const stallCount = houseCount >= 10 ? 2 : 1;
for (let si = 0; si < stallCount; si++) {
const stallAngle = (si + 0.5) * Math.PI + (townSeed & 0xf) * 0.1;
const stallDist = spread * 0.42;
const sx = tx + Math.cos(stallAngle) * stallDist;
const sy = ty + Math.sin(stallAngle) * stallDist * 0.5;
const stallW = 44 * s * 0.9;
const poolKey = `town:${townId}:proc_stall:${si}`;
if (
!this._placeBuildingLayerSprite(
poolKey,
MARKET_STALL_TEXTURE_KEY,
sx,
sy,
stallW,
usedBuildingSprites,
)
) {
this._drawTownStall(gfx, sx, sy, s * 0.9);
}
}
}
/**
* Draw towns visible in the current viewport.
* Each town renders a ground plane, a paved central plaza, a fountain or well at the
* plaza center (procedural fallback) or from server buildings, one civic hall offset
* from center, NPC homes and stalls, a name label, and a dashed border.
*/
drawTowns(towns: TownData[], camera: Camera, screenWidth: number, screenHeight: number): void {
const gfx = this._townGfx;
const iconGfx = this._townIconGfx;
if (!gfx || !iconGfx) return;
const cx = camera.finalX;
const cy = camera.finalY;
if (this._lastTownsRef !== towns || this._lastTownCount !== towns.length) {
this._townDrawDirty = true;
this._lastTownsRef = towns;
this._lastTownCount = towns.length;
}
const now = performance.now();
const dx = Math.abs(cx - this._lastTownCameraX);
const dy = Math.abs(cy - this._lastTownCameraY);
if (
!this._townDrawDirty &&
now - this._lastTownDrawMs < this._townDrawIntervalMs &&
dx < TILE_WIDTH &&
dy < TILE_HEIGHT
) {
return;
}
this._townDrawDirty = false;
this._lastTownDrawMs = now;
this._lastTownCameraX = cx;
this._lastTownCameraY = cy;
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;
}
}