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