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

1588 lines
53 KiB
TypeScript

import { Application, Container, Graphics, Text, TextStyle } 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 } from './types';
import { drawEnemyBySlug } from './enemyVisuals';
/**
* 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;
}
/** 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;
// 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 _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 _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, 1 * s);
gfx.fill({ color: 0xddd5c8, alpha: 0.9 });
gfx.circle(x - 5 * s, y - 1 * 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, 1 * s);
gfx.fill({ color: 0x4a3218, alpha: 0.6 });
gfx.rect(x - 5 * s, y - 2 * s, 10 * s, 1 * 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;
}
/** Called from GameEngine when towns or route waypoints change. */
setWorldTerrainContext(ctx: WorldTerrainContext | null): void {
this._worldTerrainContext = ctx;
}
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();
}
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);
// 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._groundGfx = new Graphics();
this.groundLayer.addChild(this._groundGfx);
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.groundLayer.addChild(this._townGfx);
// 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);
}
/**
* 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;
gfx.clear();
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 hw = TILE_WIDTH / 2;
const hh = TILE_HEIGHT / 2;
const terrainCtx = this._worldTerrainContext;
// Pass 1: tiles
for (let wx = startX; wx <= endX; wx++) {
for (let wy = startY; wy <= endY; wy++) {
const terrain = proceduralTerrain(wx, wy, terrainCtx);
const iso = worldToScreen(wx, wy);
const dark = (wx + wy) % 2 === 0;
const color = this._terrainColors(terrain, dark);
gfx.poly([
iso.x, iso.y - hh,
iso.x + hw, iso.y,
iso.x, iso.y + hh,
iso.x - hw, iso.y,
]);
gfx.fill({ color, alpha: 1 });
gfx.poly([
iso.x, iso.y - hh,
iso.x + hw, iso.y,
iso.x, iso.y + hh,
iso.x - hw, iso.y,
]);
gfx.stroke({
color: this._terrainStrokeColor(terrain),
width: 1,
alpha: 0.25,
});
}
}
// Pass 2: objects (drawn after tiles so they layer on top)
// Slightly expanded object bounds prevent enlarged props from edge clipping.
const objectPaddingTiles = 4;
const objectStartX = startX - objectPaddingTiles;
const objectEndX = endX + objectPaddingTiles;
const objectStartY = startY - objectPaddingTiles;
const objectEndY = endY + objectPaddingTiles;
for (let wx = objectStartX; wx <= objectEndX; wx++) {
for (let wy = objectStartY; wy <= objectEndY; wy++) {
const terrainHere = proceduralTerrain(wx, wy, terrainCtx);
const obj = proceduralObject(wx, wy, terrainHere, terrainCtx);
if (!obj) continue;
const iso = worldToScreen(wx, wy);
const variant = tileHash(wx, wy, 999);
if (obj === 'tree') this._drawTree(gfx, iso.x, iso.y, variant);
else if (obj === 'bush') this._drawBush(gfx, iso.x, iso.y, variant);
else if (obj === 'rock') this._drawRock(gfx, iso.x, iso.y, variant);
else if (obj === 'stump') this._drawStump(gfx, iso.x, iso.y, variant);
else if (obj === 'cart') this._drawBrokenCart(gfx, iso.x, iso.y, variant);
else if (obj === 'bones') this._drawBones(gfx, iso.x, iso.y, variant);
else if (obj === 'mushroom') this._drawMushroom(gfx, iso.x, iso.y, variant);
else if (obj === 'ruin') this._drawRuin(gfx, iso.x, iso.y, variant);
else if (obj === 'stall') this._drawMarketStall(gfx, iso.x, iso.y, variant);
else if (obj === 'well') this._drawWell(gfx, iso.x, iso.y, variant);
else if (obj === 'banner') this._drawBanner(gfx, iso.x, iso.y, variant);
else if (obj === 'barrel') this._drawBarrel(gfx, iso.x, iso.y, variant);
else if (obj === 'leaves') this._drawLeafPile(gfx, iso.x, iso.y, variant);
}
}
}
/**
* 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): void {
const gfx = this._heroGfx;
if (!gfx) return;
const { cy, iso } = this.paintHeroSilhouette(gfx, wx, wy, phase, now, 'self');
const nameTxt = this._heroNameText;
if (nameTxt && this._heroName) {
nameTxt.text = this._heroName;
nameTxt.x = iso.x;
nameTxt.y = iso.y - 42;
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): void {
const gfx = this._meetPartnerGfx;
const lbl = this._meetPartnerLabel;
if (!gfx || !lbl) return;
const { cy, iso } = this.paintHeroSilhouette(gfx, wx, wy, 'idle', now, 'meet_partner');
lbl.text = `${name} Lv.${level}`;
lbl.x = iso.x;
lbl.y = iso.y - 42;
lbl.visible = true;
lbl.zIndex = cy + 199;
}
clearMeetPartner(): void {
this._meetPartnerGfx?.clear();
if (this._meetPartnerLabel) {
this._meetPartnerLabel.visible = false;
}
}
/**
* Draw a small camp (A-frame tent + campfire) near the hero during wilderness rest (wild phase).
* Placed slightly behind the hero in screen space for a “bivouac” read.
*/
drawRestCamp(wx: number, wy: number, now: number): void {
const gfx = this._restCampGfx;
if (!gfx) return;
gfx.clear();
const iso = worldToScreen(wx, wy);
const bob = Math.sin(now * 0.004) * 1.0;
// --- Tent (screen-left of hero, reads “behind” in iso) ---
const tx = iso.x - 26;
const ty = iso.y - 4 + bob * 0.4;
// Ground shadow under tent
gfx.ellipse(tx, ty + 14, 22, 7);
gfx.fill({ color: 0x000000, alpha: 0.18 });
// Tent body (trapezoid wall + triangle roof)
gfx.poly([tx - 18, ty + 12, tx + 18, ty + 12, tx + 14, ty - 8, tx - 14, ty - 8]);
gfx.fill({ color: 0x8b6914, alpha: 0.92 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.fill({ color: 0xc4a574, alpha: 0.96 });
gfx.poly([tx - 14, ty - 8, tx, ty - 22, tx + 14, ty - 8]);
gfx.stroke({ color: 0x5c4030, width: 1.2, alpha: 0.85 });
gfx.rect(tx - 5, ty + 2, 10, 10);
gfx.fill({ color: 0x1a1510, alpha: 0.55 });
// Guy lines / pegs (tiny)
gfx.moveTo(tx - 18, ty + 12);
gfx.lineTo(tx - 26, ty + 16);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
gfx.moveTo(tx + 18, ty + 12);
gfx.lineTo(tx + 26, ty + 16);
gfx.stroke({ color: 0x4a3a2a, width: 1, alpha: 0.5 });
// --- Campfire (near tent / hero) ---
const cx = iso.x + 16;
const cy = iso.y + 10 + bob;
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();
}
/**
* 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;
drawEnemyBySlug(gfx, wx, wy, hp, maxHp, enemySlug, enemyArchetype, now, worldToScreen);
}
/**
* 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 fadeIn = Math.min(1, elapsed / 300);
const alpha = fadeIn;
if (alpha <= 0) {
txt.visible = false;
return;
}
const iso = worldToScreen(wx, wy);
const bx = iso.x;
const by = iso.y - 52; // 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 });
gfx.zIndex = by + 200; // above hero
// 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 });
}
/** 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,
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 w = 60 * scale * (b.footprintW / 2.5);
const h = 48 * scale * (b.footprintH / 2.0);
const rh = 32 * scale;
const bt = b.buildingType;
if (bt === 'house.quest_giver') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xb89040, 0x6a3a22, 0);
this._drawFence(gfx, bx, by, w, 'left');
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '!', 0xffd700, scale);
} 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);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.3, '$', 0x88dd88, scale);
} else if (bt === 'house.healer') {
this._drawHouse(gfx, bx, by, w, h, rh, 0xccccdd, 0x5555aa, 2);
this._drawBuildingIcon(gfx, bx, by - h - rh * 0.5, '+', 0xff6666, scale);
} else if (bt === 'decoration.well') {
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);
}
}
}
/** 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 - 1 * 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;
const h = 38 * s;
const rh = 26 * s;
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 - 1 * 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,
): 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;
this._drawHouse(
gfx, tx + dx, ty + dy, w, h, rh,
wallColors[i % wallColors.length]!,
roofColors[i % roofColors.length]!,
roofStyle,
);
if (i % 4 === 1) {
this._drawFence(gfx, tx + dx, ty + dy, 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;
this._drawTownStall(
gfx,
tx + Math.cos(stallAngle) * stallDist,
ty + Math.sin(stallAngle) * stallDist * 0.5,
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;
if (!gfx) return;
gfx.clear();
// Hide all existing labels first; we'll show visible ones below
for (const lbl of this._townLabels) {
lbl.visible = false;
}
const cx = camera.finalX;
const cy = camera.finalY;
const halfW = screenWidth / (2 * MAP_ZOOM) + TILE_WIDTH * 10;
const halfH = screenHeight / (2 * MAP_ZOOM) + TILE_HEIGHT * 10;
let labelIdx = 0;
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 ---
const groundW = borderRadius * 0.85;
const groundH = groundW * 0.5;
gfx.ellipse(tx, ty, groundW, groundH);
gfx.fill({ color: 0x8a7454, alpha: 0.35 });
// Inner lighter patch
gfx.ellipse(tx, ty, groundW * 0.6, groundH * 0.6);
gfx.fill({ color: 0x9a8462, alpha: 0.2 });
// Glow circle behind town
gfx.circle(tx, ty, borderRadius * 0.6);
gfx.fill({ color: 0xdaa520, alpha: 0.04 });
// --- 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, town.buildings, tx, ty, s);
this._drawCivicBuilding(gfx, civicScreen.x, civicScreen.y, s);
} else {
this._drawProceduralBuildings(gfx, tx, ty, s, spread, town.size, townSeed);
if ((townSeed & 1) === 0) {
this._drawTownFountain(gfx, tx, ty, s);
} else {
this._drawTownWell(gfx, tx, ty, s);
}
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++;
}
}
/**
* 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;
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;
// Idle sway based on NPC id
const swayY = Math.sin(now * 0.002 + npc.id * 1.7) * 2.5;
const cx = iso.x;
const cy = iso.y + swayY;
// Shadow
gfx.ellipse(cx, cy + 8, 10, 3.5);
gfx.fill({ color: 0x000000, alpha: 0.22 });
// NPC body diamond (type-specific color)
let bodyColor: number;
let bodyStroke: number;
let iconText: string;
let iconColor: number;
switch (npc.type) {
case 'quest_giver':
bodyColor = 0xdaa520;
bodyStroke = 0x8a6510;
iconText = '!';
iconColor = 0xffd700;
break;
case 'merchant':
bodyColor = 0x44aa55;
bodyStroke = 0x2a7a3a;
iconText = '$';
iconColor = 0x88dd88;
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;
}
}
/** 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;
}
}
/**
* Draw nearby heroes as semi-transparent green diamonds with name + level labels.
* Each hero gets a subtle idle sway animation.
*/
drawNearbyHeroes(
heroes: ReadonlyArray<{ name: string; level: number; positionX: number; positionY: number }>,
now: 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;
for (const hero of heroes) {
const iso = worldToScreen(hero.positionX, hero.positionY);
// Idle sway: use a per-hero offset based on name hash
const hashOffset = hero.name.length * 1.37;
const swayY = Math.sin(now * 0.003 + hashOffset) * 2;
const cx = iso.x;
const cy = iso.y + swayY;
// Shadow
gfx.ellipse(cx, cy + 8, 10, 3);
gfx.fill({ color: 0x000000, alpha: 0.2 });
// Green diamond body (smaller than hero)
const s = 0.7;
gfx.poly([
cx, cy - 16 * s,
cx + 10 * s, cy,
cx, cy + 8 * s,
cx - 10 * s, cy,
]);
gfx.fill({ color: 0x44aa55, alpha: 0.55 });
gfx.stroke({ color: 0x2d7a3a, width: 1.2, alpha: 0.6 });
// Small head circle
gfx.circle(cx, cy - 14 * s, 4 * s);
gfx.fill({ color: 0x66cc77, alpha: 0.5 });
// 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 - 22;
label.visible = true;
label.zIndex = cy + 101;
labelIdx++;
// Set gfx z-index for depth sorting
gfx.zIndex = cy + 100;
}
}
/**
* 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 fadeIn = Math.min(1, elapsed / 200);
const alpha = fadeIn;
if (alpha <= 0) {
txt.visible = false;
gfx.clear();
continue;
}
const iso = worldToScreen(item.wx, item.wy);
const bx = iso.x;
const by = iso.y - 48;
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;
}
}
/** Sort entity layer by y-position for correct isometric depth */
sortEntities(): void {
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;
}
}