diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index 7e29427..f351de0 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -3,7 +3,7 @@ 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 { drawEnemyHpBarOnly } from './enemyVisuals'; import { GameSpriteRegistry } from './assets/gameSpriteRegistry'; import { buildingTypeToTextureKey, @@ -108,7 +108,6 @@ export class GameRenderer { // Sprite rendering private _spriteRegistry = new GameSpriteRegistry(); - private _spritesReady = false; private _groundSpriteLayer: Container; private _objectSpriteLayer: Container; private _buildingSpriteLayer: Container; @@ -137,10 +136,8 @@ export class GameRenderer { endY: number; } | null = null; - private _lastSpritesReady = false; - // Reusable Graphics (avoid GC). Animated / full-screen paths are not texture-cached — see drawTowns / drawGround. - private _groundGfx: Graphics | null = null; + // Reusable Graphics (avoid GC). UI/combat overlays; world tiles are sprite-only. private _heroGfx: Graphics | null = null; /** Second adventurer silhouette during hero_meet (opponent at server stand position). */ private _meetPartnerGfx: Graphics | null = null; @@ -185,169 +182,6 @@ export class GameRenderer { private _lastTownsRef: TownData[] | null = null; private _lastTownCount = -1; - private _drawBush(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.9 + variant * 0.25) * 3.5; - gfx.ellipse(x - 4 * s, y - 7 * s, 7 * s, 4.5 * s); - gfx.fill({ color: 0x356e2b, alpha: 0.95 }); - gfx.ellipse(x + 3 * s, y - 8 * s, 6.5 * s, 4 * s); - gfx.fill({ color: 0x2f6526, alpha: 0.95 }); - gfx.ellipse(x, y - 10 * s, 8 * s, 4.5 * s); - gfx.fill({ color: 0x3f7d32, alpha: 0.95 }); - gfx.ellipse(x, y - 5 * s, 9 * s, 2.5 * s); - gfx.fill({ color: 0x22481b, alpha: 0.35 }); - } - - private _drawTree(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.95 + variant * 0.35) * 3.5; - gfx.rect(x - 2 * s, y - 24 * s, 4 * s, 12 * s); - gfx.fill({ color: 0x5a3d24, alpha: 0.98 }); - gfx.ellipse(x, y - 28 * s, 11 * s, 7.5 * s); - gfx.fill({ color: 0x2f6c29, alpha: 0.98 }); - gfx.ellipse(x - 6 * s, y - 24 * s, 8 * s, 6 * s); - gfx.fill({ color: 0x3a7e31, alpha: 0.98 }); - gfx.ellipse(x + 6 * s, y - 24 * s, 8 * s, 6 * s); - gfx.fill({ color: 0x3a7e31, alpha: 0.98 }); - gfx.ellipse(x, y - 19 * s, 10 * s, 6 * s); - gfx.fill({ color: 0x2a5f23, alpha: 0.92 }); - } - - private _drawRock(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.8 + variant * 0.4) * 3.5; - gfx.ellipse(x, y - 4 * s, 7 * s, 4 * s); - gfx.fill({ color: 0x6b6b6b, alpha: 0.95 }); - gfx.ellipse(x - 2 * s, y - 6 * s, 5 * s, 3.5 * s); - gfx.fill({ color: 0x7e7e7e, alpha: 0.95 }); - gfx.ellipse(x, y - 2 * s, 8 * s, 2 * s); - gfx.fill({ color: 0x3a3a3a, alpha: 0.25 }); - } - - private _drawStump(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.85 + variant * 0.2) * 3.2; - gfx.roundRect(x - 5 * s, y - 8 * s, 10 * s, 9 * s, 2 * s); - gfx.fill({ color: 0x4a3020, alpha: 0.98 }); - gfx.ellipse(x, y - 10 * s, 6 * s, 3 * s); - gfx.fill({ color: 0x3d2818, alpha: 0.9 }); - } - - private _drawBrokenCart(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.9 + variant * 0.15) * 3; - gfx.rect(x - 14 * s, y - 3 * s, 22 * s, 6 * s); - gfx.fill({ color: 0x5c4030, alpha: 0.95 }); - gfx.rect(x + 8 * s, y - 2 * s, 3 * s, 8 * s); - gfx.fill({ color: 0x3a3a3a, alpha: 0.9 }); - gfx.circle(x - 10 * s, y + 4 * s, 3 * s); - gfx.fill({ color: 0x2a2a2a, alpha: 0.85 }); - } - - private _drawBones(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.8 + variant * 0.3) * 2.8; - gfx.roundRect(x - 8 * s, y - 2 * s, 16 * s, 3 * s, s); - gfx.fill({ color: 0xddd5c8, alpha: 0.9 }); - gfx.circle(x - 5 * s, y - s, 2 * s); - gfx.fill({ color: 0xc9c2b6, alpha: 0.9 }); - gfx.circle(x + 6 * s, y, 1.5 * s); - gfx.fill({ color: 0xb0a898, alpha: 0.85 }); - } - - private _drawMushroom(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.85 + variant * 0.25) * 3; - gfx.rect(x - 1.5 * s, y - 4 * s, 3 * s, 6 * s); - gfx.fill({ color: 0xe8e0d5, alpha: 0.95 }); - gfx.ellipse(x, y - 8 * s, 5 * s, 3.5 * s); - gfx.fill({ color: variant > 0.5 ? 0xc44a4a : 0xd9c04a, alpha: 0.95 }); - gfx.ellipse(x, y - 8 * s, 2 * s, 1.2 * s); - gfx.fill({ color: 0xffffff, alpha: 0.35 }); - } - - private _drawRuin(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.9 + variant * 0.2) * 3.2; - gfx.rect(x - 4 * s, y - 14 * s, 4 * s, 14 * s); - gfx.fill({ color: 0x6a6a72, alpha: 0.95 }); - gfx.rect(x + 2 * s, y - 10 * s, 5 * s, 10 * s); - gfx.fill({ color: 0x5a5a62, alpha: 0.92 }); - gfx.rect(x - 1 * s, y - 4 * s, 8 * s, 3 * s); - gfx.fill({ color: 0x4a4a52, alpha: 0.88 }); - } - - private _drawMarketStall(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.95 + variant * 0.1) * 3.5; - gfx.rect(x - 12 * s, y - 2 * s, 24 * s, 5 * s); - gfx.fill({ color: 0x6b4a32, alpha: 0.96 }); - gfx.poly([x, y - 16 * s, x + 14 * s, y - 4 * s, x - 14 * s, y - 4 * s]); - gfx.fill({ color: 0x8b3a3a, alpha: 0.94 }); - gfx.rect(x - 3 * s, y - 8 * s, 6 * s, 5 * s); - gfx.fill({ color: 0x2a5080, alpha: 0.85 }); - } - - private _drawWell(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.9 + variant * 0.15) * 3.2; - gfx.ellipse(x, y - 2 * s, 9 * s, 5 * s); - gfx.fill({ color: 0x5a5a62, alpha: 0.95 }); - gfx.ellipse(x, y - 4 * s, 5 * s, 3 * s); - gfx.fill({ color: 0x1a4a6e, alpha: 0.85 }); - gfx.rect(x - 2 * s, y - 12 * s, 4 * s, 8 * s); - gfx.fill({ color: 0x7a7a82, alpha: 0.9 }); - } - - private _drawBanner(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.85 + variant * 0.2) * 3; - gfx.rect(x - 1.2 * s, y - 18 * s, 2.4 * s, 18 * s); - gfx.fill({ color: 0x4a3a28, alpha: 0.95 }); - gfx.poly([x + 1.2 * s, y - 18 * s, x + 12 * s, y - 14 * s, x + 1.2 * s, y - 10 * s]); - gfx.fill({ color: 0x6a3a8e, alpha: 0.92 }); - } - - private _drawBarrel(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.9 + variant * 0.15) * 2.8; - gfx.ellipse(x, y, 5 * s, 3 * s); - gfx.fill({ color: 0x5a4228, alpha: 0.9 }); - gfx.rect(x - 5 * s, y - 7 * s, 10 * s, 7 * s); - gfx.fill({ color: 0x6b4a30, alpha: 0.92 }); - gfx.ellipse(x, y - 7 * s, 5 * s, 3 * s); - gfx.fill({ color: 0x7a5a3a, alpha: 0.9 }); - gfx.rect(x - 5 * s, y - 5 * s, 10 * s, s); - gfx.fill({ color: 0x4a3218, alpha: 0.6 }); - gfx.rect(x - 5 * s, y - 2 * s, 10 * s, s); - gfx.fill({ color: 0x4a3218, alpha: 0.6 }); - } - - private _drawLeafPile(gfx: Graphics, x: number, y: number, variant: number): void { - const s = (0.85 + variant * 0.25) * 2.5; - const colors = [0x6a8a2a, 0x8a7a22, 0x5a7a28, 0x9a8a30]; - for (let i = 0; i < 4; i++) { - const ox = (((variant * 100 + i * 37) | 0) % 7 - 3) * s; - const oy = (((variant * 100 + i * 53) | 0) % 5 - 2) * s * 0.5; - gfx.ellipse(x + ox, y + oy, 4 * s, 2.5 * s); - gfx.fill({ color: colors[i % colors.length]!, alpha: 0.7 }); - } - } - - private _terrainColors(terrain: string, dark: boolean): number { - if (terrain === 'plaza') return dark ? 0x5a5a62 : 0x6c6c75; - if (terrain === 'road') return dark ? 0x7b6545 : 0x8e7550; - if (terrain === 'dirt') return dark ? 0x6b5338 : 0x7c6242; - if (terrain === 'stone') return dark ? 0x5c5f67 : 0x6c7078; - if (terrain === 'forest_floor') return dark ? 0x234a1c : 0x2d5a24; - if (terrain === 'ruins_floor') return dark ? 0x4a4a48 : 0x5a5a58; - if (terrain === 'canyon_floor') return dark ? 0x7a6248 : 0x8b7355; - if (terrain === 'swamp_floor') return dark ? 0x324c36 : 0x3d5c42; - if (terrain === 'volcanic_floor') return dark ? 0x4a2e28 : 0x5c3830; - if (terrain === 'astral_floor') return dark ? 0x3a3568 : 0x4a4580; - return dark ? 0x2d5a1e : 0x3a7a28; // grass - } - - private _terrainStrokeColor(terrain: string): number { - if (terrain === 'road') return 0x4e3f2d; - if (terrain === 'plaza' || terrain === 'stone') return 0x333340; - if (terrain === 'dirt') return 0x4a3828; - if (terrain === 'ruins_floor') return 0x2a2a30; - if (terrain === 'canyon_floor') return 0x5a4835; - if (terrain === 'swamp_floor') return 0x1a3a28; - if (terrain === 'volcanic_floor') return 0x4a2020; - if (terrain === 'astral_floor') return 0x302840; - return 0x1a4a12; - } - private _ensureSprite( pool: Map, poolKey: string, @@ -517,10 +351,8 @@ export class GameRenderer { 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); + console.warn('[Renderer] Sprite preload failed; world rendering is sprite-only.', error); } // Build scene graph @@ -541,10 +373,6 @@ export class GameRenderer { 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; @@ -668,16 +496,10 @@ export class GameRenderer { } /** - * Draw visible ground tiles and decorative objects. + * Draw visible ground tiles and decorative objects (sprites only — no vector fallback). * Terrain is procedurally generated — the world is endless. - * - * Vector fallback spans the full visible tile grid; we do not `cacheAsTexture` on `_groundGfx` - * (bounds would create a very large RenderTexture). Prefer terrain/object sprites when loaded. */ drawGround(camera: Camera, screenWidth: number, screenHeight: number): void { - const gfx = this._groundGfx; - if (!gfx) return; - const cx = camera.finalX; const cy = camera.finalY; const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 2; @@ -713,7 +535,6 @@ export class GameRenderer { const startY = Math.floor(minWY); const endY = Math.ceil(maxWY); - const spritesReady = this._spritesReady; const last = this._lastGroundBounds; const sameBounds = last && @@ -721,15 +542,12 @@ export class GameRenderer { last.endX === endX && last.startY === startY && last.endY === endY; - if (!this._groundDirty && sameBounds && this._lastSpritesReady === spritesReady) { + if (!this._groundDirty && sameBounds) { 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; @@ -775,11 +593,9 @@ export class GameRenderer { 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 textureKey = terrainToTextureKey(terrain); + const texture = this._spriteRegistry.getTexture(textureKey); + if (texture) { const poolKey = `${wx},${wy}`; usedTileSprites.add(poolKey); const entry = this._ensureSprite( @@ -799,28 +615,6 @@ export class GameRenderer { 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; @@ -850,7 +644,7 @@ export class GameRenderer { continue; } const variant = tileHash(wx, wy, 999); - const objTextureKey = spritesReady ? objectToTextureKey(obj, variant) : null; + const objTextureKey = objectToTextureKey(obj, variant); const objTexture = objTextureKey ? this._spriteRegistry.getTexture(objTextureKey) : null; if (objTextureKey && objTexture) { const poolKey = `${wx},${wy}`; @@ -869,20 +663,6 @@ export class GameRenderer { 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; @@ -890,132 +670,53 @@ export class GameRenderer { } const hideSpritesNow = performance.now(); - if (spritesReady) { - this._hideUnusedSprites( - this._tileSpritePool, - usedTileSprites, - hideSpritesNow, - this._tileSpriteFreeList, - 2800, - ); - this._hideUnusedSprites( - this._objectSpritePool, - usedObjectSprites, - hideSpritesNow, - this._objectSpriteFreeList, - 1800, - ); - const evictPaddingTiles = 8; - this._evictSpritesOutsideTileBounds( - this._tileSpritePool, - startX - evictPaddingTiles, - endX + evictPaddingTiles, - startY - evictPaddingTiles, - endY + evictPaddingTiles, - this._tileSpriteFreeList, - 2800, - ); - this._evictSpritesOutsideTileBounds( - this._objectSpritePool, - objectStartX - evictPaddingTiles, - objectEndX + evictPaddingTiles, - objectStartY - evictPaddingTiles, - objectEndY + evictPaddingTiles, - this._objectSpriteFreeList, - 1800, - ); - } else { - this._hideUnusedSprites( - this._tileSpritePool, - this._emptySpriteSet, - hideSpritesNow, - this._tileSpriteFreeList, - 2800, - ); - this._hideUnusedSprites( - this._objectSpritePool, - this._emptySpriteSet, - hideSpritesNow, - this._objectSpriteFreeList, - 1800, - ); - } - } - - /** - * Shared adventurer figure for local hero and meet opponent (tint differs for readability). - */ - private paintHeroSilhouette( - gfx: Graphics, - wx: number, - wy: number, - phase: 'walk' | 'fight' | 'idle', - now: number, - variant: 'self' | 'meet_partner', - ): { cx: number; cy: number; iso: ScreenPoint } { - gfx.clear(); - const iso = worldToScreen(wx, wy); - let yOffset = 0; - if (phase === 'walk') { - yOffset = Math.sin(now * 0.006) * 3; - } else if (phase === 'fight') { - yOffset = Math.sin(now * 0.012) * 2; - } - const cx = iso.x; - const cy = iso.y + yOffset; - - const cape = variant === 'meet_partner' ? 0x523068 : 0x6a1a2e; - const tunic = variant === 'meet_partner' ? 0x2e5070 : 0x3a5a8a; - const tunicStroke = variant === 'meet_partner' ? 0x1a2840 : 0x1a3050; - const helm = variant === 'meet_partner' ? 0x7a8fa0 : 0x8899aa; - const helmStroke = variant === 'meet_partner' ? 0x4a5a68 : 0x556070; - - gfx.ellipse(cx, cy + 10, 16, 5); - gfx.fill({ color: 0x000000, alpha: 0.28 }); - - gfx.poly([cx - 14, cy - 4, cx - 18, cy + 10, cx + 2, cy + 6, cx + 4, cy - 8]); - gfx.fill({ color: cape, alpha: 0.92 }); - - gfx.roundRect(cx - 9, cy - 18, 18, 22, 4); - gfx.fill({ color: tunic, alpha: 0.98 }); - gfx.stroke({ color: tunicStroke, width: 1.2 }); - - gfx.rect(cx - 9, cy + 2, 18, 4); - gfx.fill({ color: 0x3a2818, alpha: 0.95 }); - - gfx.roundRect(cx - 7, cy - 30, 14, 13, 5); - gfx.fill({ color: helm, alpha: 0.98 }); - gfx.stroke({ color: helmStroke, width: 1 }); - - gfx.rect(cx + 8, cy - 22, 3, 20); - gfx.fill({ color: 0xc8d8e8, alpha: 0.95 }); - gfx.rect(cx + 8, cy - 4, 6, 3); - gfx.fill({ color: 0x4a3018, alpha: 0.95 }); - - if (phase === 'fight') { - const flash = (Math.sin(now * 0.01) + 1) * 0.5; - if (flash > 0.75) { - gfx.circle(cx + 14, cy - 10, 5); - gfx.fill({ color: 0xffffff, alpha: flash * 0.55 }); - } - } - - gfx.zIndex = cy + 100; - return { cx, cy, iso }; + this._hideUnusedSprites( + this._tileSpritePool, + usedTileSprites, + hideSpritesNow, + this._tileSpriteFreeList, + 2800, + ); + this._hideUnusedSprites( + this._objectSpritePool, + usedObjectSprites, + hideSpritesNow, + this._objectSpriteFreeList, + 1800, + ); + const evictPaddingTiles = 8; + this._evictSpritesOutsideTileBounds( + this._tileSpritePool, + startX - evictPaddingTiles, + endX + evictPaddingTiles, + startY - evictPaddingTiles, + endY + evictPaddingTiles, + this._tileSpriteFreeList, + 2800, + ); + this._evictSpritesOutsideTileBounds( + this._objectSpritePool, + objectStartX - evictPaddingTiles, + objectEndX + evictPaddingTiles, + objectStartY - evictPaddingTiles, + objectEndY + evictPaddingTiles, + this._objectSpriteFreeList, + 1800, + ); } /** - * Draw the hero as a compact adventurer (silhouette + cape + blade) with bob / combat flash. + * Draw the hero (sprite only). */ - drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number, modelVariant: number): void { + 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; + const textureKey = heroTextureKey(modelVariant, 'south'); + const texture = this._spriteRegistry.getTexture(textureKey); let cy = iso.y; - if (textureKey && texture) { + if (texture) { gfx.clear(); const entry = this._ensureSprite( this._characterSpritePool, @@ -1031,8 +732,7 @@ export class GameRenderer { entry.sprite.visible = true; cy = iso.y; } else { - const painted = this.paintHeroSilhouette(gfx, wx, wy, phase, now, 'self'); - cy = painted.cy; + gfx.clear(); const entry = this._characterSpritePool.get('hero'); if (entry) entry.sprite.visible = false; } @@ -1050,36 +750,37 @@ export class GameRenderer { /** * 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 { + 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; + const textureKey = heroTextureKey(modelVariant, 'north'); + const texture = this._spriteRegistry.getTexture(textureKey); - if (textureKey && texture) { + if (!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.visible = false; + return; } + + 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; + const cy = iso.y; + lbl.text = `${name} Lv.${level}`; lbl.x = iso.x; lbl.y = iso.y + 10; @@ -1099,7 +800,7 @@ export class GameRenderer { /** * Wilderness rest: tent + fire + bag as separate transparent sprites around the hero (wild phase). */ - drawRestCamp(wx: number, wy: number, now: number): void { + drawRestCamp(wx: number, wy: number, _now: number): void { const gfx = this._restCampGfx; if (!gfx) return; @@ -1113,94 +814,42 @@ export class GameRenderer { } }; - 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; - } + 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)) { + hideRestCampSprites(); + gfx.clear(); + 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; + 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); } clearRestCamp(): void { @@ -1231,9 +880,7 @@ export class GameRenderer { const cx = iso.x; const cy = iso.y + sway; - const southKey = this._spritesReady - ? resolveEnemySouthTextureKey(enemySlug, (k) => this._spriteRegistry.getTexture(k)) - : null; + const southKey = resolveEnemySouthTextureKey(enemySlug, (k) => this._spriteRegistry.getTexture(k)); const tex = southKey ? this._spriteRegistry.getTexture(southKey) : null; if (tex && southKey) { const entry = this._ensureSprite( @@ -1256,9 +903,9 @@ export class GameRenderer { return; } + gfx.clear(); 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 { @@ -1350,92 +997,6 @@ export class GameRenderer { } } - /** - * 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. */ @@ -1447,7 +1008,6 @@ export class GameRenderer { targetWidth: number, usedBuildingSprites: Set, ): boolean { - if (!this._spritesReady) return false; const spriteTexture = this._spriteRegistry.getTexture(textureKey); if (!spriteTexture) return false; usedBuildingSprites.add(poolKey); @@ -1468,42 +1028,13 @@ export class GameRenderer { 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. + * Draw server-defined buildings (sprites only). Purpose icons only when the building sprite exists. */ private _drawServerBuildings( - gfx: Graphics, iconGfx: Graphics, usedBuildingSprites: Set, buildings: BuildingData[], - _townScreenX: number, - _townScreenY: number, scale: number, ): void { for (let i = 0; i < buildings.length; i++) { @@ -1529,8 +1060,7 @@ export class GameRenderer { ? Math.max(w, 44 * scale) : w; const spriteKey = buildingTypeToTextureKey(bt); - const spriteTexture = - this._spritesReady && spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null; + const spriteTexture = spriteKey ? this._spriteRegistry.getTexture(spriteKey) : null; const hasUsableSprite = spriteKey !== null && spriteTexture !== null; if (spriteKey !== null && spriteTexture !== null) { const poolKey = `building:${b.id}`; @@ -1551,40 +1081,12 @@ export class GameRenderer { 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); - } - } + if (!hasUsableSprite) continue; - const twIcon = spriteTexture?.width ?? 0; - const thIcon = spriteTexture?.height ?? 0; + const twIcon = spriteTexture!.width ?? 0; + const thIcon = spriteTexture!.height ?? 0; const iconYBase = - hasUsableSprite && twIcon > 0 + twIcon > 0 ? by - ((thIcon * spriteDrawW) / twIcon) * 0.9 : by - h - rh * 0.45; @@ -1618,34 +1120,6 @@ export class GameRenderer { 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). */ @@ -1672,58 +1146,21 @@ export class GameRenderer { } } - /** 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. + * Procedural building placement when server buildings are unavailable (sprites only). */ private _drawProceduralBuildings( - gfx: Graphics, tx: number, ty: number, s: number, - spread: number, size: string, townSeed: number, - usedBuildingSprites: Set, townId: number, + tx: number, + ty: number, + s: number, + spread: number, + size: string, + townSeed: number, + usedBuildingSprites: Set, + townId: number, ): void { const houseCount = size === 'XS' ? 5 : size === 'S' ? 7 : size === 'M' ? 10 : 14; - - const wallColors = [0x9a7e5a, 0x8b7252, 0xa08860, 0x7e6844, 0x907656, 0x9e8862, 0x887050]; - const roofColors = [0x6a3a22, 0x5a3020, 0x7a4028, 0x5e3422, 0x6e3a24, 0x724030, 0x603828]; const baseW = 60; - const baseH = 48; - const baseRH = 32; for (let i = 0; i < houseCount; i++) { const hash = ((townSeed * 31 + i * 17) ^ (i * 0x45d9f3b)) >>> 0; @@ -1732,41 +1169,19 @@ export class GameRenderer { 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'); - } + this._placeBuildingLayerSprite(poolKey, facadeKey, hx, hy, w, usedBuildingSprites); } const stallCount = houseCount >= 10 ? 2 : 1; @@ -1777,26 +1192,19 @@ export class GameRenderer { 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); - } + this._placeBuildingLayerSprite( + poolKey, + MARKET_STALL_TEXTURE_KEY, + sx, + sy, + stallW, + usedBuildingSprites, + ); } } /** - * 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. + * Draw towns visible in the current viewport (vector base + labels; buildings are sprites only). */ drawTowns(towns: TownData[], camera: Camera, screenWidth: number, screenHeight: number): void { const gfx = this._townGfx; @@ -1887,43 +1295,31 @@ export class GameRenderer { // --- Buildings: server-driven if available, fallback procedural --- if (town.buildings && town.buildings.length > 0) { - this._drawServerBuildings(gfx, iconGfx, usedBuildingSprites, town.buildings, tx, ty, s); + this._drawServerBuildings(iconGfx, usedBuildingSprites, town.buildings, s); } else { this._drawProceduralBuildings( - gfx, tx, ty, s, spread, town.size, townSeed, usedBuildingSprites, town.id, + 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); - } - } + this._placeBuildingLayerSprite( + plazaKey, + PLAZA_FOUNTAIN_TEXTURE_KEY, + tx, + ty, + 56 * s, + usedBuildingSprites, + ); } 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); - } + this._placeBuildingLayerSprite( + civicKey, + TOWN_HALL_TEXTURE_KEY, + civicScreen.x, + civicScreen.y, + 52 * s, + usedBuildingSprites, + ); // --- Town name label (larger font, positioned higher) --- let label: Text; @@ -1963,10 +1359,7 @@ export class GameRenderer { this._cacheStaticGraphicsIfBounded(iconGfx); } - /** - * Draw NPCs within the viewport. Each NPC type has a distinct shape and icon. - * NPCs have a gentle idle sway animation. - */ + /** Draw NPCs within the viewport (sprites + name labels; shadow is a small vector ellipse). */ drawNPCs( npcs: NPCData[], camera: Camera, @@ -1976,7 +1369,6 @@ export class GameRenderer { ): void { const gfx = this._npcGfx; if (!gfx) return; - // Per-frame idle sway + culling: keep vector path, no cacheAsTexture (would stale or rebuild every frame). gfx.clear(); for (const lbl of this._npcLabels) { @@ -1995,7 +1387,6 @@ export class GameRenderer { 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 @@ -2004,211 +1395,58 @@ export class GameRenderer { const cx = iso.x; const cy = iso.y; - // Shadow + const npcTextureKey = npcTypeToTextureKey(npc.type); + const npcTexture = npcTextureKey ? this._spriteRegistry.getTexture(npcTextureKey) : null; + if (!npcTextureKey || !npcTexture) continue; + gfx.ellipse(cx, cy + 8, 10, 3.5); gfx.fill({ color: 0x000000, alpha: 0.22 }); + gfx.zIndex = cy + 100; - const npcTextureKey = this._spritesReady ? npcTypeToTextureKey(npc.type) : null; - const npcTexture = npcTextureKey ? this._spriteRegistry.getTexture(npcTextureKey) : null; - const hasSprite = Boolean(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; - 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; + let nameLabel: Text; + if (labelIdx < this._npcLabels.length) { + nameLabel = this._npcLabels[labelIdx]!; } 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]!; + if (this._npcLabelPool.length > 0) { + nameLabel = this._npcLabelPool.pop()!; } 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); + 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); } - - 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; + this.entityLayer.addChild(nameLabel); + this._npcLabels.push(nameLabel); } - // 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++; - } + nameLabel.text = npc.name; + nameLabel.x = cx; + nameLabel.y = cy + 6; + nameLabel.visible = true; + nameLabel.zIndex = cy + 101; + labelIdx++; } this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites, now); @@ -2236,9 +1474,7 @@ export class GameRenderer { 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 objTextureKey = objectToTextureKey(obj.objectType, variant); const objTexture = objTextureKey ? this._spriteRegistry.getTexture(objTextureKey) : null; if (objTextureKey && objTexture) { const poolKey = `town-object:${obj.id}`; @@ -2278,10 +1514,7 @@ export class GameRenderer { this._hideUnusedSprites(this._npcSpritePool, this._emptySpriteSet, performance.now()); } - /** - * Draw nearby heroes using player sprites + name/level labels. - * Each hero gets a subtle idle sway animation. - */ + /** Draw nearby heroes (sprites + labels; shadow is a small vector ellipse). */ drawNearbyHeroes( heroes: ReadonlyArray<{ id: number; @@ -2311,28 +1544,28 @@ export class GameRenderer { const cx = iso.x; const cy = iso.y; - // Shadow + const textureKey = heroTextureKey(hero.modelVariant ?? 0, 'south'); + const texture = this._spriteRegistry.getTexture(textureKey); + if (!texture) continue; + gfx.ellipse(cx, cy + 8, 10, 3); gfx.fill({ color: 0x000000, alpha: 0.2 }); + gfx.zIndex = cy + 100; - 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; - } + 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;