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