@ -1,9 +1,20 @@
import { Application , Container , Graphics , Text, TextStyl e } from 'pixi.js' ;
import { Application , Container , Graphics , Sprite, Text, TextStyl e, Textur e } from 'pixi.js' ;
import { TILE_WIDTH , TILE_HEIGHT , MAP_ZOOM } from '../shared/constants' ;
import { TILE_WIDTH , TILE_HEIGHT , MAP_ZOOM } from '../shared/constants' ;
import { getViewport } from '../shared/telegram' ;
import { getViewport } from '../shared/telegram' ;
import type { Camera } from './camera' ;
import type { Camera } from './camera' ;
import type { TownData , NPCData , BuildingData } from './types' ;
import type { TownData , NPCData , BuildingData } from './types' ;
import { drawEnemyBySlug } from './enemyVisuals' ;
import { drawEnemyBySlug , drawEnemyHpBarOnly } from './enemyVisuals' ;
import { GameSpriteRegistry } from './assets/gameSpriteRegistry' ;
import {
buildingTypeToTextureKey ,
heroTextureKey ,
meetPartnerTextureKey ,
npcTypeToTextureKey ,
objectToTextureKey ,
restCampTextureKeys ,
enemySouthTextureKey ,
terrainToTextureKey ,
} from './assets/spriteMapping' ;
/ * *
/ * *
* Isometric coordinate conversion utilities .
* Isometric coordinate conversion utilities .
@ -23,6 +34,13 @@ export interface WorldPoint {
y : number ;
y : number ;
}
}
type SpritePoolEntry = {
sprite : Sprite ;
textureKey : string ;
} ;
const TERRAIN_SEAM_BLEED_SCALE = 1.002 ;
/** Convert world (tile) coordinates to screen (pixel) coordinates */
/** Convert world (tile) coordinates to screen (pixel) coordinates */
export function worldToScreen ( wx : number , wy : number ) : ScreenPoint {
export function worldToScreen ( wx : number , wy : number ) : ScreenPoint {
return {
return {
@ -73,6 +91,30 @@ export class GameRenderer {
/** Town ring + active route for procedural ground (null until towns loaded). */
/** Town ring + active route for procedural ground (null until towns loaded). */
private _worldTerrainContext : WorldTerrainContext | null = null ;
private _worldTerrainContext : WorldTerrainContext | null = null ;
// Sprite rendering
private _spriteRegistry = new GameSpriteRegistry ( ) ;
private _spritesReady = false ;
private _groundSpriteLayer : Container ;
private _objectSpriteLayer : Container ;
private _buildingSpriteLayer : Container ;
private _tileSpritePool = new Map < string , SpritePoolEntry > ( ) ;
private _objectSpritePool = new Map < string , SpritePoolEntry > ( ) ;
private _tileSpriteFreeList : Sprite [ ] = [ ] ;
private _objectSpriteFreeList : Sprite [ ] = [ ] ;
private _buildingSpritePool = new Map < string , SpritePoolEntry > ( ) ;
private _characterSpritePool = new Map < string , SpritePoolEntry > ( ) ;
private _npcSpritePool = new Map < string , SpritePoolEntry > ( ) ;
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)
// Reusable Graphics objects (avoid GC in hot path)
private _groundGfx : Graphics | null = null ;
private _groundGfx : Graphics | null = null ;
private _heroGfx : Graphics | null = null ;
private _heroGfx : Graphics | null = null ;
@ -93,6 +135,7 @@ export class GameRenderer {
// Town rendering
// Town rendering
private _townGfx : Graphics | null = null ;
private _townGfx : Graphics | null = null ;
private _townIconGfx : Graphics | null = null ;
private _townLabels : Text [ ] = [ ] ;
private _townLabels : Text [ ] = [ ] ;
private _townLabelPool : Text [ ] = [ ] ;
private _townLabelPool : Text [ ] = [ ] ;
@ -106,6 +149,9 @@ export class GameRenderer {
private _nearbyHeroLabels : Text [ ] = [ ] ;
private _nearbyHeroLabels : Text [ ] = [ ] ;
private _nearbyHeroLabelPool : Text [ ] = [ ] ;
private _nearbyHeroLabelPool : Text [ ] = [ ] ;
private _lastEntitySortMs = 0 ;
private _entitySortIntervalMs = 120 ;
private _drawBush ( gfx : Graphics , x : number , y : number , variant : number ) : void {
private _drawBush ( gfx : Graphics , x : number , y : number , variant : number ) : void {
const s = ( 0.9 + variant * 0.25 ) * 3.5 ;
const s = ( 0.9 + variant * 0.25 ) * 3.5 ;
gfx . ellipse ( x - 4 * s , y - 7 * s , 7 * s , 4.5 * s ) ;
gfx . ellipse ( x - 4 * s , y - 7 * s , 7 * s , 4.5 * s ) ;
@ -162,9 +208,9 @@ export class GameRenderer {
private _drawBones ( gfx : Graphics , x : number , y : number , variant : number ) : void {
private _drawBones ( gfx : Graphics , x : number , y : number , variant : number ) : void {
const s = ( 0.8 + variant * 0.3 ) * 2.8 ;
const s = ( 0.8 + variant * 0.3 ) * 2.8 ;
gfx . roundRect ( x - 8 * s , y - 2 * s , 16 * s , 3 * s , 1 * s ) ;
gfx . roundRect ( x - 8 * s , y - 2 * s , 16 * s , 3 * s , s ) ;
gfx . fill ( { color : 0xddd5c8 , alpha : 0.9 } ) ;
gfx . fill ( { color : 0xddd5c8 , alpha : 0.9 } ) ;
gfx . circle ( x - 5 * s , y - 1 * s , 2 * s ) ;
gfx . circle ( x - 5 * s , y - s , 2 * s ) ;
gfx . fill ( { color : 0xc9c2b6 , alpha : 0.9 } ) ;
gfx . fill ( { color : 0xc9c2b6 , alpha : 0.9 } ) ;
gfx . circle ( x + 6 * s , y , 1.5 * s ) ;
gfx . circle ( x + 6 * s , y , 1.5 * s ) ;
gfx . fill ( { color : 0xb0a898 , alpha : 0.85 } ) ;
gfx . fill ( { color : 0xb0a898 , alpha : 0.85 } ) ;
@ -226,9 +272,9 @@ export class GameRenderer {
gfx . fill ( { color : 0x6b4a30 , alpha : 0.92 } ) ;
gfx . fill ( { color : 0x6b4a30 , alpha : 0.92 } ) ;
gfx . ellipse ( x , y - 7 * s , 5 * s , 3 * s ) ;
gfx . ellipse ( x , y - 7 * s , 5 * s , 3 * s ) ;
gfx . fill ( { color : 0x7a5a3a , alpha : 0.9 } ) ;
gfx . fill ( { color : 0x7a5a3a , alpha : 0.9 } ) ;
gfx . rect ( x - 5 * s , y - 5 * s , 10 * s , 1 * s ) ;
gfx . rect ( x - 5 * s , y - 5 * s , 10 * s , s ) ;
gfx . fill ( { color : 0x4a3218 , alpha : 0.6 } ) ;
gfx . fill ( { color : 0x4a3218 , alpha : 0.6 } ) ;
gfx . rect ( x - 5 * s , y - 2 * s , 10 * s , 1 * s ) ;
gfx . rect ( x - 5 * s , y - 2 * s , 10 * s , s ) ;
gfx . fill ( { color : 0x4a3218 , alpha : 0.6 } ) ;
gfx . fill ( { color : 0x4a3218 , alpha : 0.6 } ) ;
}
}
@ -269,9 +315,71 @@ export class GameRenderer {
return 0x1a4a12 ;
return 0x1a4a12 ;
}
}
private _ensureSprite (
pool : Map < string , SpritePoolEntry > ,
poolKey : string ,
textureKey : string ,
texture : SpritePoolEntry [ 'sprite' ] [ 'texture' ] ,
layer : Container ,
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 } ;
pool . set ( poolKey , entry ) ;
return entry ;
}
if ( entry . textureKey !== textureKey ) {
entry . sprite . texture = texture ;
entry . textureKey = textureKey ;
}
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 sep = key . indexOf ( ',' ) ;
if ( sep < 0 ) continue ;
const wx = Number ( key . slice ( 0 , sep ) ) ;
const wy = Number ( key . slice ( sep + 1 ) ) ;
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. */
/** Called from GameEngine when towns or route waypoints change. */
setWorldTerrainContext ( ctx : WorldTerrainContext | null ) : void {
setWorldTerrainContext ( ctx : WorldTerrainContext | null ) : void {
this . _worldTerrainContext = ctx ;
this . _worldTerrainContext = ctx ;
this . _groundDirty = true ;
}
}
constructor ( ) {
constructor ( ) {
@ -281,6 +389,9 @@ export class GameRenderer {
this . entityLayer = new Container ( ) ;
this . entityLayer = new Container ( ) ;
this . effectLayer = new Container ( ) ;
this . effectLayer = new Container ( ) ;
this . uiContainer = new Container ( ) ;
this . uiContainer = new Container ( ) ;
this . _groundSpriteLayer = new Container ( ) ;
this . _objectSpriteLayer = new Container ( ) ;
this . _buildingSpriteLayer = new Container ( ) ;
}
}
get initialized ( ) : boolean {
get initialized ( ) : boolean {
@ -305,6 +416,14 @@ export class GameRenderer {
canvasContainer . appendChild ( this . app . canvas as HTMLCanvasElement ) ;
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
// Build scene graph
this . worldContainer . addChild ( this . groundLayer ) ;
this . worldContainer . addChild ( this . groundLayer ) ;
this . worldContainer . addChild ( this . entityLayer ) ;
this . worldContainer . addChild ( this . entityLayer ) ;
@ -318,9 +437,20 @@ export class GameRenderer {
this . worldContainer . scale . set ( MAP_ZOOM ) ;
this . worldContainer . scale . set ( MAP_ZOOM ) ;
// Create reusable graphics objects
// Create reusable graphics objects
this . groundLayer . sortableChildren = true ;
this . groundLayer . addChild ( this . _groundSpriteLayer ) ;
this . _groundSpriteLayer . zIndex = 0 ;
this . _groundGfx = new Graphics ( ) ;
this . _groundGfx = new Graphics ( ) ;
this . _groundGfx . zIndex = 1 ;
this . groundLayer . addChild ( this . _groundGfx ) ;
this . groundLayer . addChild ( this . _groundGfx ) ;
this . groundLayer . addChild ( this . _objectSpriteLayer ) ;
this . _objectSpriteLayer . zIndex = 2 ;
this . groundLayer . addChild ( this . _buildingSpriteLayer ) ;
this . _buildingSpriteLayer . zIndex = 3 ;
this . _heroGfx = new Graphics ( ) ;
this . _heroGfx = new Graphics ( ) ;
this . entityLayer . addChild ( this . _heroGfx ) ;
this . entityLayer . addChild ( this . _heroGfx ) ;
@ -401,8 +531,13 @@ export class GameRenderer {
// Town graphics (drawn between ground and entity layers)
// Town graphics (drawn between ground and entity layers)
this . _townGfx = new Graphics ( ) ;
this . _townGfx = new Graphics ( ) ;
this . _townGfx . zIndex = 4 ;
this . groundLayer . addChild ( this . _townGfx ) ;
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)
// NPC graphics (drawn in entity layer for depth sorting)
this . _npcGfx = new Graphics ( ) ;
this . _npcGfx = new Graphics ( ) ;
this . entityLayer . addChild ( this . _npcGfx ) ;
this . entityLayer . addChild ( this . _npcGfx ) ;
@ -432,7 +567,6 @@ export class GameRenderer {
drawGround ( camera : Camera , screenWidth : number , screenHeight : number ) : void {
drawGround ( camera : Camera , screenWidth : number , screenHeight : number ) : void {
const gfx = this . _groundGfx ;
const gfx = this . _groundGfx ;
if ( ! gfx ) return ;
if ( ! gfx ) return ;
gfx . clear ( ) ;
const cx = camera . finalX ;
const cx = camera . finalX ;
const cy = camera . finalY ;
const cy = camera . finalY ;
@ -469,16 +603,63 @@ export class GameRenderer {
const startY = Math . floor ( minWY ) ;
const startY = Math . floor ( minWY ) ;
const endY = Math . ceil ( maxWY ) ;
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 hw = TILE_WIDTH / 2 ;
const hh = TILE_HEIGHT / 2 ;
const hh = TILE_HEIGHT / 2 ;
const terrainCtx = this . _worldTerrainContext ;
const terrainCtx = this . _worldTerrainContext ;
const usedTileSprites = new Set < string > ( ) ;
const usedObjectSprites = new Set < string > ( ) ;
// Pass 1: tiles
// Pass 1: tiles
for ( let wx = startX ; wx <= endX ; wx ++ ) {
for ( let wx = startX ; wx <= endX ; wx ++ ) {
for ( let wy = startY ; wy <= endY ; wy ++ ) {
for ( let wy = startY ; wy <= endY ; wy ++ ) {
const terrain = proceduralTerrain ( wx , wy , terrainCtx ) ;
const iso = worldToScreen ( wx , wy ) ;
const iso = worldToScreen ( wx , wy ) ;
if (
Math . abs ( iso . x - cx ) > halfW + hw ||
Math . abs ( iso . y - cy ) > halfH + hh
) {
continue ;
}
const terrain = proceduralTerrain ( wx , wy , terrainCtx ) ;
const dark = ( wx + wy ) % 2 === 0 ;
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 ,
this . _tileSpriteFreeList ,
) ;
entry . sprite . x = iso . x ;
entry . sprite . y = iso . y + 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 ) ;
const color = this . _terrainColors ( terrain , dark ) ;
gfx . poly ( [
gfx . poly ( [
@ -502,6 +683,7 @@ export class GameRenderer {
} ) ;
} ) ;
}
}
}
}
}
// Pass 2: objects (drawn after tiles so they layer on top)
// Pass 2: objects (drawn after tiles so they layer on top)
// Slightly expanded object bounds prevent enlarged props from edge clipping.
// Slightly expanded object bounds prevent enlarged props from edge clipping.
@ -512,11 +694,34 @@ export class GameRenderer {
const objectEndY = endY + objectPaddingTiles ;
const objectEndY = endY + objectPaddingTiles ;
for ( let wx = objectStartX ; wx <= objectEndX ; wx ++ ) {
for ( let wx = objectStartX ; wx <= objectEndX ; wx ++ ) {
for ( let wy = objectStartY ; wy <= objectEndY ; wy ++ ) {
for ( let wy = objectStartY ; wy <= objectEndY ; wy ++ ) {
const iso = worldToScreen ( wx , wy ) ;
if (
Math . abs ( iso . x - cx ) > halfW + TILE_WIDTH * 1.5 ||
Math . abs ( iso . y - cy ) > halfH + TILE_HEIGHT * 2
) {
continue ;
}
const terrainHere = proceduralTerrain ( wx , wy , terrainCtx ) ;
const terrainHere = proceduralTerrain ( wx , wy , terrainCtx ) ;
const obj = proceduralObject ( wx , wy , terrainHere , terrainCtx ) ;
const obj = proceduralObject ( wx , wy , terrainHere , terrainCtx ) ;
if ( ! obj ) continue ;
if ( ! obj ) continue ;
const iso = worldToScreen ( wx , wy ) ;
const variant = tileHash ( wx , wy , 999 ) ;
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 ,
this . _objectSpriteFreeList ,
) ;
entry . sprite . x = iso . x ;
entry . sprite . y = iso . y ;
entry . sprite . visible = true ;
} else {
if ( obj === 'tree' ) this . _drawTree ( gfx , iso . x , iso . y , variant ) ;
if ( obj === 'tree' ) this . _drawTree ( gfx , iso . x , iso . y , variant ) ;
else if ( obj === 'bush' ) this . _drawBush ( gfx , iso . x , iso . y , variant ) ;
else if ( obj === 'bush' ) this . _drawBush ( gfx , iso . x , iso . y , variant ) ;
else if ( obj === 'rock' ) this . _drawRock ( gfx , iso . x , iso . y , variant ) ;
else if ( obj === 'rock' ) this . _drawRock ( gfx , iso . x , iso . y , variant ) ;
@ -534,6 +739,34 @@ export class GameRenderer {
}
}
}
}
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 , new Set ( ) ) ;
this . _hideUnusedSprites ( this . _objectSpritePool , new Set ( ) ) ;
}
}
/ * *
/ * *
* Shared adventurer figure for local hero and meet opponent ( tint differs for readability ) .
* Shared adventurer figure for local hero and meet opponent ( tint differs for readability ) .
* /
* /
@ -602,13 +835,38 @@ export class GameRenderer {
drawHero ( wx : number , wy : number , phase : 'walk' | 'fight' | 'idle' , now : number ) : void {
drawHero ( wx : number , wy : number , phase : 'walk' | 'fight' | 'idle' , now : number ) : void {
const gfx = this . _heroGfx ;
const gfx = this . _heroGfx ;
if ( ! gfx ) return ;
if ( ! gfx ) return ;
const { cy , iso } = this . paintHeroSilhouette ( gfx , wx , wy , phase , now , 'self' ) ;
const iso = worldToScreen ( wx , wy ) ;
const textureKey = this . _spritesReady ? heroTextureKey ( ) : 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 ;
const nameTxt = this . _heroNameText ;
if ( nameTxt && this . _heroName ) {
if ( nameTxt && this . _heroName ) {
nameTxt . text = this . _heroName ;
nameTxt . text = this . _heroName ;
nameTxt . x = iso . x ;
nameTxt . x = iso . x ;
nameTxt . y = iso . y - 42 ;
nameTxt . y = iso . y - 9 2;
nameTxt . visible = true ;
nameTxt . visible = true ;
nameTxt . zIndex = cy + 199 ;
nameTxt . zIndex = cy + 199 ;
}
}
@ -621,7 +879,32 @@ export class GameRenderer {
const gfx = this . _meetPartnerGfx ;
const gfx = this . _meetPartnerGfx ;
const lbl = this . _meetPartnerLabel ;
const lbl = this . _meetPartnerLabel ;
if ( ! gfx || ! lbl ) return ;
if ( ! gfx || ! lbl ) return ;
const { cy , iso } = this . paintHeroSilhouette ( gfx , wx , wy , 'idle' , now , 'meet_partner' ) ;
const iso = worldToScreen ( wx , wy ) ;
const textureKey = this . _spritesReady ? meetPartnerTextureKey ( ) : 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 . text = ` ${ name } Lv. ${ level } ` ;
lbl . x = iso . x ;
lbl . x = iso . x ;
lbl . y = iso . y - 42 ;
lbl . y = iso . y - 42 ;
@ -631,52 +914,100 @@ export class GameRenderer {
clearMeetPartner ( ) : void {
clearMeetPartner ( ) : void {
this . _meetPartnerGfx ? . clear ( ) ;
this . _meetPartnerGfx ? . clear ( ) ;
const entry = this . _characterSpritePool . get ( 'meet_partner' ) ;
if ( entry ) entry . sprite . visible = false ;
if ( this . _meetPartnerLabel ) {
if ( this . _meetPartnerLabel ) {
this . _meetPartnerLabel . visible = false ;
this . _meetPartnerLabel . visible = false ;
}
}
}
}
/ * *
/ * *
* Draw a small camp ( A - frame tent + campfire ) near the hero during wilderness rest ( wild phase ) .
* Wilderness rest : tent + fire + bag as separate transparent sprites around the hero ( wild phase ) .
* Placed slightly behind the hero in screen space for a “ bivouac ” read .
* /
* /
drawRestCamp ( wx : number , wy : number , now : number ) : void {
drawRestCamp ( wx : number , wy : number , now : number ) : void {
const gfx = this . _restCampGfx ;
const gfx = this . _restCampGfx ;
if ( ! gfx ) return ;
if ( ! gfx ) return ;
gfx . clear ( ) ;
const iso = worldToScreen ( wx , wy ) ;
const iso = worldToScreen ( wx , wy ) ;
const bob = Math . sin ( now * 0.004 ) * 1.0 ;
const bob = Math . sin ( now * 0.004 ) * 1.0 ;
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 b = bob * 0.35 ;
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 + b ;
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 ;
}
}
// --- Tent (screen-left of hero, reads “behind” in iso) ---
hideRestCampSprites ( ) ;
const tx = iso . x - 26 ;
gfx . clear ( ) ;
const ty = iso . y - 4 + bob * 0.4 ;
// --- Tent (screen-left of hero, reads “behind” in iso); ~2× size vs old fallback ---
const tx = iso . x - 54 ;
const ty = iso . y - 6 + bob * 0.4 ;
// Ground shadow under tent
// Ground shadow under tent
gfx . ellipse ( tx , ty + 14 , 22 , 7 ) ;
gfx . ellipse ( tx , ty + 28, 44 , 14 ) ;
gfx . fill ( { color : 0x000000 , alpha : 0.18 } ) ;
gfx . fill ( { color : 0x000000 , alpha : 0.18 } ) ;
// Tent body (trapezoid wall + triangle roof)
// Tent body (trapezoid wall + triangle roof)
gfx . poly ( [ tx - 18 , ty + 12 , tx + 18 , ty + 12 , tx + 14 , ty - 8 , tx - 14 , ty - 8 ] ) ;
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 . fill ( { color : 0x8b6914 , alpha : 0.92 } ) ;
gfx . poly ( [ tx - 14 , ty - 8 , tx , ty - 22 , tx + 14 , ty - 8 ] ) ;
gfx . poly ( [ tx - 28, ty - 16 , tx , ty - 44 , tx + 28 , ty - 16 ] ) ;
gfx . fill ( { color : 0xc4a574 , alpha : 0.96 } ) ;
gfx . fill ( { color : 0xc4a574 , alpha : 0.96 } ) ;
gfx . poly ( [ tx - 14 , ty - 8 , tx , ty - 22 , tx + 14 , ty - 8 ] ) ;
gfx . poly ( [ tx - 28, ty - 16 , tx , ty - 44 , tx + 28 , ty - 16 ] ) ;
gfx . stroke ( { color : 0x5c4030 , width : 1.2 , alpha : 0.85 } ) ;
gfx . stroke ( { color : 0x5c4030 , width : 1.2 , alpha : 0.85 } ) ;
gfx . rect ( tx - 5 , ty + 2 , 10 , 10 ) ;
gfx . rect ( tx - 10, ty + 4 , 20 , 2 0) ;
gfx . fill ( { color : 0x1a1510 , alpha : 0.55 } ) ;
gfx . fill ( { color : 0x1a1510 , alpha : 0.55 } ) ;
// Guy lines / pegs (tiny)
// Guy lines / pegs (tiny)
gfx . moveTo ( tx - 18 , ty + 12 ) ;
gfx . moveTo ( tx - 36, ty + 24 ) ;
gfx . lineTo ( tx - 26 , ty + 16 ) ;
gfx . lineTo ( tx - 52, ty + 32 ) ;
gfx . stroke ( { color : 0x4a3a2a , width : 1 , alpha : 0.5 } ) ;
gfx . stroke ( { color : 0x4a3a2a , width : 1 , alpha : 0.5 } ) ;
gfx . moveTo ( tx + 18 , ty + 12 ) ;
gfx . moveTo ( tx + 36, ty + 24 ) ;
gfx . lineTo ( tx + 26 , ty + 16 ) ;
gfx . lineTo ( tx + 52, ty + 32 ) ;
gfx . stroke ( { color : 0x4a3a2a , width : 1 , alpha : 0.5 } ) ;
gfx . stroke ( { color : 0x4a3a2a , width : 1 , alpha : 0.5 } ) ;
// --- Campfire (near tent / hero) ---
// --- Campfire ( pushed right; center clear for hero) ---
const cx = iso . x + 16 ;
const cx = iso . x + 42 ;
const cy = iso . y + 10 + bob ;
const cy = iso . y + 8 + bob ;
gfx . ellipse ( cx , cy + 6 , 12 , 4 ) ;
gfx . ellipse ( cx , cy + 6 , 12 , 4 ) ;
gfx . fill ( { color : 0x000000 , alpha : 0.22 } ) ;
gfx . fill ( { color : 0x000000 , alpha : 0.22 } ) ;
@ -701,6 +1032,10 @@ export class GameRenderer {
clearRestCamp ( ) : void {
clearRestCamp ( ) : void {
if ( this . _restCampGfx ) this . _restCampGfx . clear ( ) ;
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 ;
}
}
}
/ * *
/ * *
@ -717,9 +1052,45 @@ export class GameRenderer {
) : void {
) : void {
const gfx = this . _enemyGfx ;
const gfx = this . _enemyGfx ;
if ( ! gfx ) return ;
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 = enemySouthTextureKey ( enemySlug ) ;
const tex = this . _spritesReady ? this . _spriteRegistry . getTexture ( southKey ) : null ;
if ( tex ) {
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 ;
const th = tex . height || 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 ) ;
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
* Draw a white rounded - rect thought bubble above the hero with a small
* downward - pointing triangle . Fades in over 300 ms and fades out when the
* downward - pointing triangle . Fades in over 300 ms and fades out when the
@ -733,8 +1104,7 @@ export class GameRenderer {
const elapsed = now - startMs ;
const elapsed = now - startMs ;
// Fade in over 300ms
// Fade in over 300ms
const fadeIn = Math . min ( 1 , elapsed / 300 ) ;
const alpha = Math . min ( 1 , elapsed / 300 ) ;
const alpha = fadeIn ;
if ( alpha <= 0 ) {
if ( alpha <= 0 ) {
txt . visible = false ;
txt . visible = false ;
return ;
return ;
@ -920,6 +1290,8 @@ export class GameRenderer {
* /
* /
private _drawServerBuildings (
private _drawServerBuildings (
gfx : Graphics ,
gfx : Graphics ,
iconGfx : Graphics ,
usedBuildingSprites : Set < string > ,
buildings : BuildingData [ ] ,
buildings : BuildingData [ ] ,
_townScreenX : number ,
_townScreenX : number ,
_townScreenY : number ,
_townScreenY : number ,
@ -936,34 +1308,48 @@ export class GameRenderer {
const rh = 32 * scale ;
const rh = 32 * scale ;
const bt = b . buildingType ;
const bt = b . buildingType ;
const spriteKey = this . _spritesReady ? buildingTypeToTextureKey ( bt ) : null ;
const spriteTexture = spriteKey ? this . _spriteRegistry . getTexture ( spriteKey ) : null ;
if ( spriteKey && spriteTexture ) {
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 || w ;
const scaleFactor = texW > 0 ? w / texW : 1 ;
entry . sprite . scale . set ( scaleFactor ) ;
entry . sprite . zIndex = by ;
entry . sprite . visible = true ;
}
const hasSprite = Boolean ( spriteKey && spriteTexture ) ;
if ( ! hasSprite ) {
if ( bt === 'house.quest_giver' ) {
if ( bt === 'house.quest_giver' ) {
this . _drawHouse ( gfx , bx , by , w , h , rh , 0xb89040 , 0x6a3a22 , 0 ) ;
this . _drawHouse ( gfx , bx , by , w , h , rh , 0xb89040 , 0x6a3a22 , 0 ) ;
this . _drawFence ( gfx , bx , by , w , 'left' ) ;
this . _drawFence ( gfx , bx , by , w , 'left' ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.5 , '!' , 0xffd700 , scale ) ;
} else if ( bt === 'house.merchant' ) {
} else if ( bt === 'house.merchant' ) {
this . _drawHouse ( gfx , bx , by , w * 1.1 , h , rh * 0.8 , 0x44aa55 , 0x2a5a30 , 1 ) ;
this . _drawHouse ( gfx , bx , by , w * 1.1 , h , rh * 0.8 , 0x44aa55 , 0x2a5a30 , 1 ) ;
this . _drawTownStall ( gfx , bx + w * 0.7 , by + 4 , scale * 0.6 ) ;
this . _drawTownStall ( gfx , bx + w * 0.7 , by + 4 , scale * 0.6 ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.3 , '$' , 0x88dd88 , scale ) ;
} else if ( bt === 'house.armorer' ) {
} else if ( bt === 'house.armorer' ) {
this . _drawHouse ( gfx , bx , by , w * 1.05 , h , rh * 0.85 , 0x5a6e8a , 0x2a3548 , 1 ) ;
this . _drawHouse ( gfx , bx , by , w * 1.05 , h , rh * 0.85 , 0x5a6e8a , 0x2a3548 , 1 ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.4 , 'A' , 0xaaccff , scale ) ;
} else if ( bt === 'house.weapon_smith' ) {
} else if ( bt === 'house.weapon_smith' ) {
this . _drawHouse ( gfx , bx , by , w * 1.05 , h , rh * 0.85 , 0x8a5a3a , 0x4a3020 , 1 ) ;
this . _drawHouse ( gfx , bx , by , w * 1.05 , h , rh * 0.85 , 0x8a5a3a , 0x4a3020 , 1 ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.35 , 'W' , 0xffaa66 , scale ) ;
} else if ( bt === 'house.jeweler' ) {
} else if ( bt === 'house.jeweler' ) {
this . _drawHouse ( gfx , bx , by , w , h * 0.95 , rh * 0.9 , 0x7a4a9a , 0x3a2050 , 2 ) ;
this . _drawHouse ( gfx , bx , by , w , h * 0.95 , rh * 0.9 , 0x7a4a9a , 0x3a2050 , 2 ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.45 , 'J' , 0xdd88ff , scale ) ;
} else if ( bt === 'house.bounty_hunter' ) {
} else if ( bt === 'house.bounty_hunter' ) {
this . _drawHouse ( gfx , bx , by , w , h , rh , 0x906040 , 0x4a2818 , 0 ) ;
this . _drawHouse ( gfx , bx , by , w , h , rh , 0x906040 , 0x4a2818 , 0 ) ;
this . _drawFence ( gfx , bx , by , w , 'right' ) ;
this . _drawFence ( gfx , bx , by , w , 'right' ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.5 , 'B' , 0xffcc44 , scale ) ;
} else if ( bt === 'house.elder' ) {
} else if ( bt === 'house.elder' ) {
this . _drawHouse ( gfx , bx , by , w * 0.98 , h , rh * 1.05 , 0x9a8860 , 0x5a4830 , 0 ) ;
this . _drawHouse ( gfx , bx , by , w * 0.98 , h , rh * 1.05 , 0x9a8860 , 0x5a4830 , 0 ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.5 , 'E' , 0xeeddaa , scale ) ;
} else if ( bt === 'house.healer' ) {
} else if ( bt === 'house.healer' ) {
this . _drawHouse ( gfx , bx , by , w , h , rh , 0xccccdd , 0x5555aa , 2 ) ;
this . _drawHouse ( gfx , bx , by , w , h , rh , 0xccccdd , 0x5555aa , 2 ) ;
this . _drawBuildingIcon ( gfx , bx , by - h - rh * 0.5 , '+' , 0xff6666 , scale ) ;
} else if ( bt === 'decoration.well' ) {
} else if ( bt === 'decoration.well' ) {
this . _drawTownWell ( gfx , bx , by , scale ) ;
this . _drawTownWell ( gfx , bx , by , scale ) ;
} else if ( bt === 'decoration.stall' ) {
} else if ( bt === 'decoration.stall' ) {
@ -972,6 +1358,25 @@ export class GameRenderer {
this . _drawSignpost ( gfx , bx , by , scale ) ;
this . _drawSignpost ( gfx , bx , by , scale ) ;
}
}
}
}
if ( bt === 'house.quest_giver' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.5 , '!' , 0xffd700 , scale ) ;
} else if ( bt === 'house.merchant' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.3 , '$' , 0x88dd88 , scale ) ;
} else if ( bt === 'house.armorer' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.4 , 'A' , 0xaaccff , scale ) ;
} else if ( bt === 'house.weapon_smith' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.35 , 'W' , 0xffaa66 , scale ) ;
} else if ( bt === 'house.jeweler' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.45 , 'J' , 0xdd88ff , scale ) ;
} else if ( bt === 'house.bounty_hunter' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.5 , 'B' , 0xffcc44 , scale ) ;
} else if ( bt === 'house.elder' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.5 , 'E' , 0xeeddaa , scale ) ;
} else if ( bt === 'house.healer' ) {
this . _drawBuildingIcon ( iconGfx , bx , by - h - rh * 0.5 , '+' , 0xff6666 , scale ) ;
}
}
}
}
/** Draw a small icon circle above a building to indicate its purpose. */
/** Draw a small icon circle above a building to indicate its purpose. */
@ -989,7 +1394,7 @@ export class GameRenderer {
gfx . ellipse ( cx , cy , 10 * s , 5 * s ) ;
gfx . ellipse ( cx , cy , 10 * s , 5 * s ) ;
gfx . fill ( { color : 0x6a6a7a , alpha : 0.8 } ) ;
gfx . fill ( { color : 0x6a6a7a , alpha : 0.8 } ) ;
gfx . stroke ( { color : 0x4a4a5a , width : 1.5 , alpha : 0.6 } ) ;
gfx . stroke ( { color : 0x4a4a5a , width : 1.5 , alpha : 0.6 } ) ;
gfx . rect ( cx - 1 * s , cy - 12 * s , 2 * s , 12 * s ) ;
gfx . rect ( cx - s , cy - 12 * s , 2 * s , 12 * s ) ;
gfx . fill ( { color : 0x5a4a3a , alpha : 0.9 } ) ;
gfx . fill ( { color : 0x5a4a3a , alpha : 0.9 } ) ;
gfx . rect ( cx - 6 * s , cy - 13 * s , 12 * s , 2 * s ) ;
gfx . rect ( cx - 6 * s , cy - 13 * s , 12 * s , 2 * s ) ;
gfx . fill ( { color : 0x5a4a3a , alpha : 0.9 } ) ;
gfx . fill ( { color : 0x5a4a3a , alpha : 0.9 } ) ;
@ -1064,7 +1469,7 @@ export class GameRenderer {
/** Draw a signpost decoration. */
/** Draw a signpost decoration. */
private _drawSignpost ( gfx : Graphics , cx : number , cy : number , s : number ) : void {
private _drawSignpost ( gfx : Graphics , cx : number , cy : number , s : number ) : void {
gfx . rect ( cx - 1 * s , cy - 16 * s , 2 * s , 16 * s ) ;
gfx . rect ( cx - s , cy - 16 * s , 2 * s , 16 * s ) ;
gfx . fill ( { color : 0x6a5a3a , alpha : 0.9 } ) ;
gfx . fill ( { color : 0x6a5a3a , alpha : 0.9 } ) ;
gfx . poly ( [
gfx . poly ( [
cx + 2 * s , cy - 14 * s ,
cx + 2 * s , cy - 14 * s ,
@ -1140,8 +1545,10 @@ export class GameRenderer {
* /
* /
drawTowns ( towns : TownData [ ] , camera : Camera , screenWidth : number , screenHeight : number ) : void {
drawTowns ( towns : TownData [ ] , camera : Camera , screenWidth : number , screenHeight : number ) : void {
const gfx = this . _townGfx ;
const gfx = this . _townGfx ;
if ( ! gfx ) return ;
const iconGfx = this . _townIconGfx ;
if ( ! gfx || ! iconGfx ) return ;
gfx . clear ( ) ;
gfx . clear ( ) ;
iconGfx . clear ( ) ;
// Hide all existing labels first; we'll show visible ones below
// Hide all existing labels first; we'll show visible ones below
for ( const lbl of this . _townLabels ) {
for ( const lbl of this . _townLabels ) {
@ -1155,6 +1562,7 @@ export class GameRenderer {
let labelIdx = 0 ;
let labelIdx = 0 ;
const usedBuildingSprites = new Set < string > ( ) ;
for ( const town of towns ) {
for ( const town of towns ) {
// Convert town world position to screen space
// Convert town world position to screen space
const townScreen = worldToScreen ( town . centerX , town . centerY ) ;
const townScreen = worldToScreen ( town . centerX , town . centerY ) ;
@ -1208,7 +1616,7 @@ export class GameRenderer {
// --- Buildings: server-driven if available, fallback procedural ---
// --- Buildings: server-driven if available, fallback procedural ---
if ( town . buildings && town . buildings . length > 0 ) {
if ( town . buildings && town . buildings . length > 0 ) {
this . _drawServerBuildings ( gfx , town. buildings , tx , ty , s ) ;
this . _drawServerBuildings ( gfx , iconGfx, usedBuildingSprites , town. buildings , tx , ty , s ) ;
this . _drawCivicBuilding ( gfx , civicScreen . x , civicScreen . y , s ) ;
this . _drawCivicBuilding ( gfx , civicScreen . x , civicScreen . y , s ) ;
} else {
} else {
this . _drawProceduralBuildings ( gfx , tx , ty , s , spread , town . size , townSeed ) ;
this . _drawProceduralBuildings ( gfx , tx , ty , s , spread , town . size , townSeed ) ;
@ -1252,6 +1660,8 @@ export class GameRenderer {
label . zIndex = ty - 500 ;
label . zIndex = ty - 500 ;
labelIdx ++ ;
labelIdx ++ ;
}
}
this . _hideUnusedSprites ( this . _buildingSpritePool , usedBuildingSprites ) ;
}
}
/ * *
/ * *
@ -1279,6 +1689,7 @@ export class GameRenderer {
const halfH = screenHeight / ( 2 * MAP_ZOOM ) + TILE_HEIGHT * 3 ;
const halfH = screenHeight / ( 2 * MAP_ZOOM ) + TILE_HEIGHT * 3 ;
let labelIdx = 0 ;
let labelIdx = 0 ;
const usedNpcSprites = new Set < string > ( ) ;
for ( const npc of npcs ) {
for ( const npc of npcs ) {
const iso = worldToScreen ( npc . worldX , npc . worldY ) ;
const iso = worldToScreen ( npc . worldX , npc . worldY ) ;
@ -1298,6 +1709,26 @@ export class GameRenderer {
gfx . ellipse ( cx , cy + 8 , 10 , 3.5 ) ;
gfx . ellipse ( cx , cy + 8 , 10 , 3.5 ) ;
gfx . fill ( { color : 0x000000 , alpha : 0.22 } ) ;
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)
// NPC body diamond (type-specific color)
let bodyColor : number ;
let bodyColor : number ;
let bodyStroke : number ;
let bodyStroke : number ;
@ -1446,6 +1877,42 @@ export class GameRenderer {
gfx . zIndex = cy + 100 ;
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 ) ;
}
}
/** Clear NPC visuals when there are none to render */
/** Clear NPC visuals when there are none to render */
@ -1454,6 +1921,7 @@ export class GameRenderer {
for ( const lbl of this . _npcLabels ) {
for ( const lbl of this . _npcLabels ) {
lbl . visible = false ;
lbl . visible = false ;
}
}
this . _hideUnusedSprites ( this . _npcSpritePool , new Set ( ) ) ;
}
}
/ * *
/ * *
@ -1563,8 +2031,7 @@ export class GameRenderer {
continue ;
continue ;
}
}
const elapsed = now - item . startMs ;
const elapsed = now - item . startMs ;
const fadeIn = Math . min ( 1 , elapsed / 200 ) ;
const alpha = Math . min ( 1 , elapsed / 200 ) ; ;
const alpha = fadeIn ;
if ( alpha <= 0 ) {
if ( alpha <= 0 ) {
txt . visible = false ;
txt . visible = false ;
gfx . clear ( ) ;
gfx . clear ( ) ;
@ -1615,6 +2082,9 @@ export class GameRenderer {
/** Sort entity layer by y-position for correct isometric depth */
/** Sort entity layer by y-position for correct isometric depth */
sortEntities ( ) : void {
sortEntities ( ) : void {
const now = performance . now ( ) ;
if ( now - this . _lastEntitySortMs < this . _entitySortIntervalMs ) return ;
this . _lastEntitySortMs = now ;
this . entityLayer . sortableChildren = true ;
this . entityLayer . sortableChildren = true ;
this . entityLayer . children . sort ( ( a , b ) = > ( a . zIndex ? ? a . y ) - ( b . zIndex ? ? b . y ) ) ;
this . entityLayer . children . sort ( ( a , b ) = > ( a . zIndex ? ? a . y ) - ( b . zIndex ? ? b . y ) ) ;
}
}