Files
dflike/client/src/scenes/GameScene.ts
T
2026-03-09 02:55:52 +00:00

1091 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Phaser from 'phaser';
import type { SocketClient } from '../network/SocketClient.js';
import { CharacterCompositor } from '../sprites/CharacterCompositor.js';
import { PortraitCompositor } from '../sprites/PortraitCompositor.js';
import { NpcInfoPanel } from '../ui/NpcInfoPanel.js';
import { CommandPanel } from '../ui/CommandPanel.js';
import { SuperlativesPanel } from '../ui/SuperlativesPanel.js';
import { LeftPanel } from '../ui/LeftPanel.js';
import { EventsFeed } from '../ui/EventsFeed.js';
import { InventionsPanel } from '../ui/InventionsPanel.js';
import { StocksPanel } from '../ui/StocksPanel.js';
import { InteractionEmojiManager } from '../ui/InteractionEmoji.js';
import type { WorldState, StateUpdate, EntityState, Appearance, NarrationEvent, MemoryEvent } from '@dflike/shared';
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE, DAY_HOURS, TOTAL_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS } from '@dflike/shared';
interface EntitySprite {
sprite: Phaser.GameObjects.Sprite;
lastState: EntityState;
}
export class GameScene extends Phaser.Scene {
private client!: SocketClient;
private compositor!: CharacterCompositor;
private worldState!: WorldState;
private entitySprites: Map<number, EntitySprite> = new Map();
private creatingEntities: Set<number> = new Set();
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private wasd!: Record<string, Phaser.Input.Keyboard.Key>;
private mode: 'avatar' | 'camera' | 'follow' = 'camera';
private modeText!: Phaser.GameObjects.Text;
private moveThrottle = 0;
private followTargetIndex = 0;
private followThrottle = 0;
private portraitCompositor!: PortraitCompositor;
private npcInfoPanel!: NpcInfoPanel;
private emojiManager!: InteractionEmojiManager;
private commandPanel!: CommandPanel;
private followCommandPanel!: CommandPanel;
private superlativesPanel!: SuperlativesPanel;
private leftPanel!: LeftPanel;
private eventsFeed!: EventsFeed;
private inventionsPanel!: InventionsPanel;
private stocksPanel!: StocksPanel;
private highlightEnabled = false;
private highlightedSpriteId: number | null = null;
private dayNightOverlay!: Phaser.GameObjects.Graphics;
private narrationEvents: NarrationEvent[] = [];
private npcThoughts: Map<number, { text: string; emoji: string }> = new Map();
private memoryEvents: Map<number, MemoryEvent[]> = new Map();
private activeThoughtEmojis: Map<number, Phaser.GameObjects.Text> = new Map();
private currentGameTime = 0;
private targetGameTime = 0;
private lastPhaseKey = '';
constructor() {
super({ key: 'GameScene' });
}
init(data: { client: SocketClient; worldState: WorldState }): void {
this.client = data.client;
this.worldState = data.worldState;
}
create(): void {
this.compositor = new CharacterCompositor(this);
this.portraitCompositor = new PortraitCompositor();
this.npcInfoPanel = new NpcInfoPanel();
this.emojiManager = new InteractionEmojiManager();
this.commandPanel = new CommandPanel('command-panel', 'COMMANDS', [{ key: '1', label: 'Spawn NPC' }]);
this.commandPanel.show(); // Start in camera mode, so show immediately
this.followCommandPanel = new CommandPanel('follow-command-panel', 'FOLLOW', [
{ key: '\u2190 \u2192', label: 'Cycle NPC' },
{ key: '1', label: 'Highlight' },
{ key: 'TAB', label: 'Camera Mode' },
]);
this.superlativesPanel = new SuperlativesPanel();
this.eventsFeed = new EventsFeed();
this.inventionsPanel = new InventionsPanel();
this.stocksPanel = new StocksPanel();
this.leftPanel = new LeftPanel(
this.superlativesPanel.getElement(),
this.eventsFeed.getElement(),
this.inventionsPanel.getElement(),
this.stocksPanel.getElement(),
);
this.inventionsPanel.onUnseenChanged = (hasUnseen) => {
if (hasUnseen && this.leftPanel.getActiveTab() !== 'inventions') {
this.leftPanel.showNotificationDot();
}
};
this.stocksPanel.onUnseenChanged = (hasUnseen) => {
if (hasUnseen && this.leftPanel.getActiveTab() !== 'resources') {
this.leftPanel.showResourcesNotificationDot();
}
};
document.addEventListener('left-panel-opened', ((e: CustomEvent) => {
if (e.detail.tab === 'stats') {
this.client.subscribeSuperlatives();
}
if (e.detail.tab === 'inventions') {
this.inventionsPanel.clearUnseen();
}
if (e.detail.tab === 'resources') {
this.stocksPanel.clearUnseen();
}
}) as EventListener);
document.addEventListener('left-panel-closed', () => {
this.client.unsubscribeSuperlatives();
});
// Draw tile grid
this.drawWorld();
// Setup camera
const worldPixelW = this.worldState.worldWidth * TILE_SIZE;
const worldPixelH = this.worldState.worldHeight * TILE_SIZE;
this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH);
// Day/night overlay
this.dayNightOverlay = this.add.graphics();
this.dayNightOverlay.setScrollFactor(0);
this.dayNightOverlay.setDepth(999);
// Input
this.cursors = this.input.keyboard!.createCursorKeys();
this.wasd = {
W: this.input.keyboard!.addKey('W'),
A: this.input.keyboard!.addKey('A'),
S: this.input.keyboard!.addKey('S'),
D: this.input.keyboard!.addKey('D'),
};
// Tab to toggle mode
this.input.keyboard!.addKey('TAB').on('down', () => {
const prevMode = this.mode;
if (this.mode === 'camera') {
this.mode = 'follow';
} else {
this.mode = 'camera';
}
this.updateModeText();
// Panel visibility
if (this.mode === 'follow') {
const npcIds = this.getNpcIds();
if (npcIds.length > 0) {
this.client.followNpc(npcIds[this.followTargetIndex]);
}
this.showFollowPanel();
this.commandPanel.hide();
this.followCommandPanel.show();
} else if (prevMode === 'follow') {
this.client.followNpc(null);
this.npcInfoPanel.hide();
this.followCommandPanel.hide();
this.clearHighlight();
this.highlightEnabled = false;
}
// Command panel visibility
if (this.mode === 'camera') {
this.commandPanel.show();
} else {
this.commandPanel.hide();
}
});
// Spawn NPC command (camera mode only)
this.input.keyboard!.addKey('ONE').on('down', () => {
if (this.mode === 'follow') {
this.highlightEnabled = !this.highlightEnabled;
if (this.highlightEnabled) {
const npcIds = this.getNpcIds();
const targetId = npcIds[this.followTargetIndex];
if (targetId != null) this.applyHighlight(targetId);
} else {
this.clearHighlight();
}
return;
}
if (this.mode !== 'camera') return;
const cam = this.cameras.main;
const centerTileX = Math.floor((cam.scrollX + cam.width / 2) / TILE_SIZE);
const centerTileY = Math.floor((cam.scrollY + cam.height / 2) / TILE_SIZE);
this.client.spawnNpc(centerTileX, centerTileY);
});
// UI text
this.modeText = this.add.text(10, 10, '', {
fontSize: '16px', color: '#ffffff', backgroundColor: '#000000aa',
padding: { x: 8, y: 4 },
}).setScrollFactor(0).setDepth(1000);
this.updateModeText();
// Spawn initial entities, then listen for state updates
this.spawnEntities(this.worldState.entities).then(() => {
this.client.onStateUpdate = (update: StateUpdate) => {
this.handleStateUpdate(update);
};
});
this.client.onSuperlativesUpdate = (data) => {
this.superlativesPanel.update(data);
};
this.client.onNarrationEvent = (event) => {
this.eventsFeed.addEvent(event);
this.narrationEvents.push(event);
if (this.narrationEvents.length > 50) {
this.narrationEvents = this.narrationEvents.slice(-50);
}
this.refreshRecentEvents();
};
this.client.onNarrationUpdate = (data) => {
this.eventsFeed.updateEvent(data.id, data.narration);
const existing = this.narrationEvents.find(e => e.id === data.id);
if (existing) {
existing.narration = data.narration;
}
this.refreshRecentEvents();
};
this.client.onNarrationHistory = (events) => {
this.eventsFeed.loadHistory(events);
this.narrationEvents = events.slice(-50);
this.refreshRecentEvents();
};
this.client.onNpcThought = (data) => {
this.npcThoughts.set(data.entityId, { text: data.text, emoji: data.emoji });
this.refreshThought();
this.showThoughtEmoji(data.entityId, data.emoji);
};
this.client.onMemoryEvent = (data) => {
const events = this.memoryEvents.get(data.entityId) ?? [];
events.push(data.event);
if (events.length > 50) events.splice(0, events.length - 50);
this.memoryEvents.set(data.entityId, events);
if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) {
const npcIds = this.getNpcIds();
const followedId = npcIds[this.followTargetIndex];
if (followedId === data.entityId) {
this.npcInfoPanel.updateHistory(this.memoryEvents.get(data.entityId) ?? []);
}
}
};
this.client.onMemoryHistory = (data) => {
this.memoryEvents.set(data.entityId, data.events.slice(-50));
if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) {
const npcIds = this.getNpcIds();
const followedId = npcIds[this.followTargetIndex];
if (followedId === data.entityId) {
this.npcInfoPanel.updateHistory(data.events);
}
}
};
this.client.onInventionEvent = (data) => {
this.inventionsPanel.addInvention(data);
};
this.client.onInventionHistory = (data) => {
this.inventionsPanel.loadHistory(data);
};
this.client.onStockpileEvent = (data) => {
this.stocksPanel.addLogEntry(data);
};
this.client.onStockpileHistory = (data) => {
this.stocksPanel.loadHistory(data);
};
this.client.onStockpileSummary = (data) => {
this.stocksPanel.updateSummary(data);
};
document.addEventListener('narration-follow', ((e: CustomEvent) => {
const { entityId } = e.detail;
const npcIds = this.getNpcIds();
const idx = npcIds.indexOf(entityId);
if (idx === -1) return;
this.followTargetIndex = idx;
this.client.followNpc(entityId);
if (this.mode !== 'follow') {
this.mode = 'follow';
this.showFollowPanel();
this.followCommandPanel.show();
this.commandPanel.hide();
} else {
this.updateFollowPanel();
}
this.updateModeText();
this.leftPanel.collapse();
}) as EventListener);
document.addEventListener('superlative-follow', ((e: CustomEvent) => {
const { entityId } = e.detail;
const npcIds = this.getNpcIds();
const idx = npcIds.indexOf(entityId);
if (idx === -1) return;
this.followTargetIndex = idx;
this.client.followNpc(entityId);
if (this.mode !== 'follow') {
this.mode = 'follow';
this.showFollowPanel();
this.followCommandPanel.show();
this.commandPanel.hide();
} else {
this.updateFollowPanel();
}
this.updateModeText();
this.leftPanel.collapse();
}) as EventListener);
this.client.onPlayerLeft = (_data) => {
// Entity removal will be handled by next state update
};
}
private drawWorld(): void {
const { worldWidth, worldHeight, terrain, decorations, trunkDecorations, pointsOfInterest } = this.worldState;
// --- LPC watergrass autotile layout (3 cols x 6 rows, 32x32 tiles) ---
// The 3×3 grid (rows 2-4) shows a WATER pond on GRASS background:
// center (10) = solid water; corners = mostly grass with water toward center.
// Row 0: [grass=0] [innerSE=1] [innerSW=2]
// Row 1: [grass=3] [innerNE=4] [innerNW=5]
// Row 2: [corner=6] [edge=7] [corner=8] ← water in: SE, bottom, SW
// Row 3: [edge=9] [center=10] [edge=11] ← water in: right, all, left
// Row 4: [corner=12] [edge=13] [corner=14] ← water in: NE, top, NW
// Row 5: [fill=15] [fill=16] [fill=17]
const FILL = 16; // solid fill tile (row 5, col 1)
const getTerr = (x: number, y: number): number => {
if (x < 0 || y < 0 || x >= worldWidth || y >= worldHeight) return Terrain.GRASS;
return terrain[y * worldWidth + x];
};
// Pick autotile for a GRASS tile in the watergrass layer.
// watergrass.png 3×3 grid (rows 2-4) shows a WATER pond on a GRASS
// background: center tile (10) is solid water, corners are mostly grass.
// So tile indices are the OPPOSITE of what the position names suggest:
// tile 6 "NW corner" has water in SE (toward center)
// tile 14 "SE corner" has water in NW (toward center)
// Applied to GRASS tiles, checking which neighbors are WATER.
const pickWaterEdge = (x: number, y: number): number => {
const isW = (dx: number, dy: number) => getTerr(x + dx, y + dy) === Terrain.WATER;
const n = isW(0, -1), s = isW(0, 1), w = isW(-1, 0), e = isW(1, 0);
const waterCount = (n ? 1 : 0) + (s ? 1 : 0) + (w ? 1 : 0) + (e ? 1 : 0);
// 3+ cardinal water neighbors: use edge tile for the ONE non-water side
if (waterCount >= 3) {
if (!n) return 13; // only grass on top → water below
if (!s) return 7; // only grass on bottom → water above
if (!w) return 11; // only grass on left → water right
if (!e) return 9; // only grass on right → water left
return FILL; // all 4 sides water → solid water
}
// Corners (two cardinal sides are water — use tile whose water faces that direction)
if (n && w) return 14; // water in NW → tile 14 has water in NW
if (n && e) return 12; // water in NE → tile 12 has water in NE
if (s && w) return 8; // water in SW → tile 8 has water in SW
if (s && e) return 6; // water in SE → tile 6 has water in SE
// Edges (one cardinal side is water)
if (n) return 13; // water on top
if (s) return 7; // water on bottom
if (w) return 11; // water on left
if (e) return 9; // water on right
return -1;
};
// Pick inner-corner autotile for a WATER tile in the watergrass layer.
// Inner corner tiles have mostly water with a small grass notch:
// Tile 1: grass notch in SE Tile 2: grass notch in SW
// Tile 4: grass notch in NE Tile 5: grass notch in NW
// Used when a diagonal neighbor is grass but both adjacent cardinals are water.
const pickWaterInnerCorner = (x: number, y: number): number => {
const isG = (dx: number, dy: number) => getTerr(x + dx, y + dy) !== Terrain.WATER;
const isW = (dx: number, dy: number) => getTerr(x + dx, y + dy) === Terrain.WATER;
// Check each diagonal: only apply if both adjacent cardinals are water
if (isG(-1, -1) && isW(-1, 0) && isW(0, -1)) return 5; // NW grass notch
if (isG(1, -1) && isW(1, 0) && isW(0, -1)) return 4; // NE grass notch
if (isG(-1, 1) && isW(-1, 0) && isW(0, 1)) return 2; // SW grass notch
if (isG(1, 1) && isW(1, 0) && isW(0, 1)) return 1; // SE grass notch
return FILL;
};
// Pick autotile for a DIRT tile in the dirt layer.
// dirt.png = dirt-on-transparent: edge tiles show dirt with transparent edges.
// Applied to DIRT tiles, checking which neighbors are NOT dirt.
const pickDirtEdge = (x: number, y: number): number => {
const notDirt = (dx: number, dy: number) => getTerr(x + dx, y + dy) !== Terrain.DIRT;
const n = notDirt(0, -1), s = notDirt(0, 1), w = notDirt(-1, 0), e = notDirt(1, 0);
const nw = notDirt(-1, -1), ne = notDirt(1, -1), sw = notDirt(-1, 1), se = notDirt(1, 1);
// Outer corners (two sides are not-dirt → this dirt tile is at a corner of the dirt body)
if (n && w) return 6;
if (n && e) return 8;
if (s && w) return 12;
if (s && e) return 14;
// Edges
if (n) return 7;
if (s) return 13;
if (w) return 9;
if (e) return 11;
// Inner corners (diagonal-only grass → small transparent notch in dirt)
// Tile 1: notch SE, Tile 2: notch SW, Tile 4: notch NE, Tile 5: notch NW
if (se) return 1;
if (sw) return 2;
if (ne) return 4;
if (nw) return 5;
// Fully surrounded by dirt
return FILL;
};
// Build tile data arrays for each layer
const grassData: number[][] = []; // base: solid grass everywhere
const waterData: number[][] = []; // watergrass transitions + solid water
const dirtData: number[][] = []; // dirt transitions + solid dirt
const moundData: number[][] = []; // grass mound at tree bases
const trunkData: number[][] = []; // tree trunks
const decoData: number[][] = []; // tree canopy decorations
// First pass: build terrain layers + trunk/canopy
for (let y = 0; y < worldHeight; y++) {
const grassRow: number[] = [];
const waterRow: number[] = [];
const dirtRow: number[] = [];
const trunkRow: number[] = [];
const decoRow: number[] = [];
for (let x = 0; x < worldWidth; x++) {
const t = terrain[y * worldWidth + x];
// Base: always solid grass
grassRow.push(FILL);
// Water layer: watergrass edges on GRASS tiles, inner corners or fill on WATER tiles
if (t === Terrain.WATER) {
waterRow.push(pickWaterInnerCorner(x, y));
} else if (t === Terrain.GRASS || t === Terrain.STONE) {
waterRow.push(pickWaterEdge(x, y));
} else {
waterRow.push(-1);
}
// Dirt layer: dirt edges on DIRT tiles (checking for non-dirt neighbors)
if (t === Terrain.DIRT) {
dirtRow.push(pickDirtEdge(x, y));
} else {
dirtRow.push(-1);
}
// Trunk decorations
const trunk = trunkDecorations[y * worldWidth + x];
trunkRow.push(trunk >= 0 ? trunk : -1);
// Canopy decorations
const deco = decorations[y * worldWidth + x];
decoRow.push(deco >= 0 ? deco : -1);
}
grassData.push(grassRow);
waterData.push(waterRow);
dirtData.push(dirtRow);
trunkData.push(trunkRow);
decoData.push(decoRow);
}
// Second pass: generate grass mound at tree bases from trunk positions.
// Trunk center-bottom tiles (7=type1, 10=type2) mark the tree anchor.
// Place grass.png 3×3 island tiles (rows 2-4) centered at and below the anchor.
// grass island: row2=[6,7,8] row3=[9,10,11] row4=[12,13,14]
const MOUND_OFFSETS = [
// Top row of mound at same level as trunk bottom
{ dx: -1, dy: 0, tile: 6 }, { dx: 0, dy: 0, tile: 7 }, { dx: 1, dy: 0, tile: 8 },
// Bottom row below trunk
{ dx: -1, dy: 1, tile: 12 }, { dx: 0, dy: 1, tile: 13 }, { dx: 1, dy: 1, tile: 14 },
];
for (let y = 0; y < worldHeight; y++) {
const moundRow: number[] = new Array(worldWidth).fill(-1);
moundData.push(moundRow);
}
for (let y = 0; y < worldHeight; y++) {
for (let x = 0; x < worldWidth; x++) {
const trunk = trunkDecorations[y * worldWidth + x];
// Check for trunk center-bottom (tile 7 for type 1, tile 10 for type 2)
if (trunk === 7 || trunk === 10) {
for (const { dx, dy, tile } of MOUND_OFFSETS) {
const mx = x + dx, my = y + dy;
if (mx >= 0 && mx < worldWidth && my >= 0 && my < worldHeight) {
moundData[my][mx] = tile;
}
}
}
}
}
// Helper to create a scaled tilemap layer
const createLayer = (
data: number[][],
tilesetName: string,
textureKey: string,
depth: number,
) => {
const tm = this.make.tilemap({
data,
tileWidth: TILESET_TILE_SIZE,
tileHeight: TILESET_TILE_SIZE,
});
const ts = tm.addTilesetImage(
tilesetName, textureKey,
TILESET_TILE_SIZE, TILESET_TILE_SIZE, 0, 0,
)!;
const layer = tm.createLayer(0, ts, 0, 0)!;
layer.setScale(TILESET_SCALE);
layer.setDepth(depth);
return layer;
};
// Ground layers — always behind characters
createLayer(grassData, 'grass', 'lpc_grass', -10);
createLayer(waterData, 'water', 'lpc_watergrass', -9);
createLayer(dirtData, 'dirt', 'lpc_dirt', -8);
createLayer(moundData, 'mound', 'lpc_grass', -7);
// Tree layers — in front of characters so NPCs walk "behind" trees.
// Characters use depth = y * TILE_SIZE (0 to worldHeight * TILE_SIZE).
const aboveCharDepth = worldHeight * TILE_SIZE + 1;
createLayer(trunkData, 'trunk', 'lpc_trunk', aboveCharDepth);
createLayer(decoData, 'trees', 'lpc_treetop', aboveCharDepth + 1);
// Points of interest markers
const graphics = this.add.graphics();
graphics.setDepth(-6);
for (const poi of pointsOfInterest) {
const color = poi.type === 'food' ? 0xcc8833 : 0x3355cc;
graphics.fillStyle(color, 0.7);
graphics.fillRect(
poi.position.x * TILE_SIZE + 8,
poi.position.y * TILE_SIZE + 8,
TILE_SIZE - 16,
TILE_SIZE - 16,
);
}
// Stone deposit markers
const stoneGraphics = this.add.graphics();
stoneGraphics.setDepth(-6);
for (let y = 0; y < worldHeight; y++) {
for (let x = 0; x < worldWidth; x++) {
if (terrain[y * worldWidth + x] === Terrain.STONE) {
// Gray stone tile with slight variation
stoneGraphics.fillStyle(0x888888, 0.8);
stoneGraphics.fillRect(
x * TILE_SIZE,
y * TILE_SIZE,
TILE_SIZE,
TILE_SIZE,
);
// Darker border for definition
stoneGraphics.lineStyle(1, 0x666666, 0.6);
stoneGraphics.strokeRect(
x * TILE_SIZE + 1,
y * TILE_SIZE + 1,
TILE_SIZE - 2,
TILE_SIZE - 2,
);
}
}
}
}
private async spawnEntities(entities: EntityState[]): Promise<void> {
for (const entity of entities) {
if (!this.entitySprites.has(entity.id) && entity.appearance) {
await this.createEntitySprite(entity);
}
}
}
private async createEntitySprite(entity: EntityState): Promise<void> {
const textureKey = await this.compositor.preloadAppearance(entity.appearance);
// Create animations for this texture
this.createAnimations(textureKey);
const sprite = this.add.sprite(
entity.position.x * TILE_SIZE + TILE_SIZE / 2,
entity.position.y * TILE_SIZE + TILE_SIZE / 2,
textureKey,
0,
);
// Y-based depth so characters walk behind tree trunks/canopies
sprite.setDepth(entity.position.y * TILE_SIZE);
this.entitySprites.set(entity.id, { sprite, lastState: entity });
this.playAnimation(sprite, textureKey, entity.movement.direction, entity.movement.state);
}
private createAnimations(textureKey: string): void {
const dirNames = ['down', 'left', 'up', 'right'];
for (let dir = 0; dir < 4; dir++) {
const walkKey = `${textureKey}_walk_${dirNames[dir]}`;
const idleKey = `${textureKey}_idle_${dirNames[dir]}`;
if (!this.anims.exists(walkKey)) {
this.anims.create({
key: walkKey,
frames: this.anims.generateFrameNumbers(textureKey, {
start: dir * SPRITE_COLS + 1,
end: dir * SPRITE_COLS + SPRITE_COLS - 1,
}),
frameRate: 8,
repeat: -1,
});
}
if (!this.anims.exists(idleKey)) {
this.anims.create({
key: idleKey,
frames: [{ key: textureKey, frame: dir * SPRITE_COLS }],
frameRate: 1,
repeat: 0,
});
}
}
}
private playAnimation(sprite: Phaser.GameObjects.Sprite, textureKey: string, direction: number, state: string): void {
const dirNames = ['down', 'left', 'up', 'right'];
const dirName = dirNames[direction] ?? 'down';
const animKey = state === 'walking'
? `${textureKey}_walk_${dirName}`
: `${textureKey}_idle_${dirName}`;
if (sprite.anims.currentAnim?.key !== animKey) {
sprite.play(animKey);
}
}
private handleStateUpdate(update: StateUpdate): void {
this.targetGameTime = update.gameTime;
const activeIds = new Set<number>();
for (const entity of update.entities) {
activeIds.add(entity.id);
const existing = this.entitySprites.get(entity.id);
if (existing) {
// Interpolation target
const targetX = entity.position.x * TILE_SIZE + TILE_SIZE / 2;
const targetY = entity.position.y * TILE_SIZE + TILE_SIZE / 2;
// Update Y-based depth for tree occlusion
existing.sprite.setDepth(entity.position.y * TILE_SIZE);
// Smooth movement via tween
const dx = Math.abs(existing.sprite.x - targetX);
const dy = Math.abs(existing.sprite.y - targetY);
if (dx > 1 || dy > 1) {
this.tweens.add({
targets: existing.sprite,
x: targetX,
y: targetY,
duration: 200,
ease: 'Linear',
});
}
// Update animation
const textureKey = this.compositor.getKey(entity.appearance);
this.playAnimation(existing.sprite, textureKey, entity.movement.direction, entity.movement.state);
existing.lastState = entity;
} else if (entity.appearance && !this.creatingEntities.has(entity.id)) {
this.creatingEntities.add(entity.id);
this.createEntitySprite(entity).finally(() => {
this.creatingEntities.delete(entity.id);
});
}
}
// Remove entities no longer in update
for (const [id, es] of this.entitySprites) {
if (!activeIds.has(id)) {
es.sprite.destroy();
if (id === this.highlightedSpriteId) {
this.highlightedSpriteId = null;
}
this.entitySprites.delete(id);
}
}
// Update interaction emojis
const entityPositions = new Map<number, { screenX: number; screenY: number }>();
const cam = this.cameras.main;
for (const entity of update.entities) {
const es = this.entitySprites.get(entity.id);
if (es) {
const screenX = (es.sprite.x - cam.scrollX) * cam.zoom;
const screenY = (es.sprite.y - cam.scrollY) * cam.zoom;
entityPositions.set(entity.id, { screenX, screenY });
}
}
this.emojiManager.update(update.entities, cam, entityPositions);
// Live-update the follow panel if visible
if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) {
const npcIds = this.getNpcIds();
const followedId = npcIds[this.followTargetIndex];
const followed = this.entitySprites.get(followedId);
if (followed) {
this.npcInfoPanel.updateInfo(followed.lastState);
this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(followedId));
}
}
}
update(_time: number, delta: number): void {
// Interpolate game time toward server value
const lerpSpeed = 0.1;
const diff = this.targetGameTime - this.currentGameTime;
// Handle wraparound (0.99 -> 0.01 should go forward, not backward)
if (Math.abs(diff) > 0.5) {
this.currentGameTime = this.targetGameTime;
} else {
this.currentGameTime += diff * lerpSpeed;
}
// Keep in 0-1 range
if (this.currentGameTime >= 1) this.currentGameTime -= 1;
if (this.currentGameTime < 0) this.currentGameTime += 1;
this.renderDayNightOverlay();
this.handleCameraAndMovement(delta);
}
private handleCameraAndMovement(delta: number): void {
const cam = this.cameras.main;
const speed = 6.25; // tiles per second for camera pan
const left = this.cursors.left?.isDown || this.wasd.A.isDown;
const right = this.cursors.right?.isDown || this.wasd.D.isDown;
const up = this.cursors.up?.isDown || this.wasd.W.isDown;
const down = this.cursors.down?.isDown || this.wasd.S.isDown;
if (this.mode === 'camera') {
// Pan camera freely
const panSpeed = speed * TILE_SIZE;
if (left) cam.scrollX -= panSpeed * delta / 1000;
if (right) cam.scrollX += panSpeed * delta / 1000;
if (up) cam.scrollY -= panSpeed * delta / 1000;
if (down) cam.scrollY += panSpeed * delta / 1000;
} else if (this.mode === 'avatar') {
// Avatar mode — send movement input (throttled)
this.moveThrottle -= delta;
if (this.moveThrottle <= 0) {
let dx = 0, dy = 0;
if (left) dx = -1;
else if (right) dx = 1;
else if (up) dy = -1;
else if (down) dy = 1;
if (dx !== 0 || dy !== 0) {
this.client.sendInput({ type: 'move', direction: { dx, dy } });
this.moveThrottle = 150;
}
}
// Follow own entity
const myEntityId = this.client.entityId;
if (myEntityId != null) {
const mySprite = this.entitySprites.get(myEntityId);
if (mySprite) {
cam.centerOn(mySprite.sprite.x, mySprite.sprite.y);
}
}
} else {
// Follow mode — track an NPC
this.followThrottle -= delta;
const npcIds = this.getNpcIds();
if (npcIds.length === 0) return;
// Clamp index to valid range
if (this.followTargetIndex >= npcIds.length) {
this.followTargetIndex = 0;
}
// Cycle NPCs with left/right (throttled)
if (this.followThrottle <= 0) {
if (left) {
this.followTargetIndex = (this.followTargetIndex - 1 + npcIds.length) % npcIds.length;
this.followThrottle = 150;
this.client.followNpc(npcIds[this.followTargetIndex]);
if (this.highlightEnabled) {
this.applyHighlight(npcIds[this.followTargetIndex]);
}
this.updateModeText();
this.updateFollowPanel();
} else if (right) {
this.followTargetIndex = (this.followTargetIndex + 1) % npcIds.length;
this.followThrottle = 150;
this.client.followNpc(npcIds[this.followTargetIndex]);
if (this.highlightEnabled) {
this.applyHighlight(npcIds[this.followTargetIndex]);
}
this.updateModeText();
this.updateFollowPanel();
}
}
// Center camera on followed NPC
const targetId = npcIds[this.followTargetIndex];
const targetSprite = this.entitySprites.get(targetId);
if (targetSprite) {
cam.centerOn(targetSprite.sprite.x, targetSprite.sprite.y);
}
}
}
private async showFollowPanel(): Promise<void> {
const npcIds = this.getNpcIds();
if (npcIds.length === 0) return;
const targetId = npcIds[this.followTargetIndex];
const es = this.entitySprites.get(targetId);
if (!es) return;
const portraitUrl = await this.portraitCompositor.compositePortrait(es.lastState.appearance);
this.npcInfoPanel.show(es.lastState, portraitUrl);
this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(targetId));
this.npcInfoPanel.updateHistory(this.memoryEvents.get(targetId) ?? []);
const thought = this.npcThoughts.get(targetId);
this.npcInfoPanel.updateThought(thought?.text ?? null);
}
private async updateFollowPanel(): Promise<void> {
const npcIds = this.getNpcIds();
if (npcIds.length === 0) return;
const targetId = npcIds[this.followTargetIndex];
const es = this.entitySprites.get(targetId);
if (!es) return;
const portraitUrl = await this.portraitCompositor.compositePortrait(es.lastState.appearance);
this.npcInfoPanel.updatePortrait(portraitUrl);
this.npcInfoPanel.updateInfo(es.lastState);
this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(targetId));
this.npcInfoPanel.updateHistory(this.memoryEvents.get(targetId) ?? []);
const thought = this.npcThoughts.get(targetId);
this.npcInfoPanel.updateThought(thought?.text ?? null);
}
private getNpcIds(): number[] {
return [...this.entitySprites.entries()]
.filter(([, es]) => !es.lastState.playerControlled)
.map(([id]) => id)
.sort((a, b) => a - b);
}
private updateModeText(): void {
if (this.mode === 'follow') {
const npcIds = this.getNpcIds();
const targetId = npcIds[this.followTargetIndex];
const es = targetId != null ? this.entitySprites.get(targetId) : undefined;
const label = es?.lastState.name ?? (targetId != null ? `NPC #${targetId}` : 'none');
this.modeText.setText(`Mode: FOLLOW: ${label} [TAB to toggle]`);
} else {
this.modeText.setText(`Mode: ${this.mode.toUpperCase()} [TAB to toggle]`);
}
}
private applyHighlight(entityId: number): void {
const es = this.entitySprites.get(entityId);
if (!es) return;
this.clearHighlight();
es.sprite.postFX.addGlow(0xff0000, 2, 0, false, 0.1, 4);
this.highlightedSpriteId = entityId;
}
private clearHighlight(): void {
if (this.highlightedSpriteId != null) {
const es = this.entitySprites.get(this.highlightedSpriteId);
if (es) {
es.sprite.postFX.clear();
}
this.highlightedSpriteId = null;
}
}
private getEventsForEntity(entityId: number): NarrationEvent[] {
return this.narrationEvents
.filter(e => e.entityIds[0] === entityId || e.entityIds[1] === entityId)
.slice(-3);
}
private refreshRecentEvents(): void {
if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) {
const npcIds = this.getNpcIds();
const followedId = npcIds[this.followTargetIndex];
if (followedId != null) {
this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(followedId));
}
}
}
private refreshThought(): void {
if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) {
const npcIds = this.getNpcIds();
const followedId = npcIds[this.followTargetIndex];
if (followedId != null) {
const thought = this.npcThoughts.get(followedId);
this.npcInfoPanel.updateThought(thought?.text ?? null);
}
}
}
private showThoughtEmoji(entityId: number, emoji: string): void {
const es = this.entitySprites.get(entityId);
if (!es) return;
// Destroy existing emoji for this NPC (only one at a time)
const existing = this.activeThoughtEmojis.get(entityId);
if (existing) {
this.tweens.killTweensOf(existing);
existing.destroy();
this.activeThoughtEmojis.delete(entityId);
}
const x = es.sprite.x;
const y = es.sprite.y - 30;
const text = this.add.text(x, y, emoji, { fontSize: '16px' })
.setOrigin(0.5)
.setDepth(1000)
.setAlpha(0);
this.activeThoughtEmojis.set(entityId, text);
// Fade in
this.tweens.add({
targets: text,
alpha: 1,
y: y - 8,
duration: 500,
ease: 'Power2',
onComplete: () => {
// Hold, then fade out
this.time.delayedCall(4000, () => {
this.tweens.add({
targets: text,
alpha: 0,
y: y - 16,
duration: 500,
ease: 'Power2',
onComplete: () => {
text.destroy();
this.activeThoughtEmojis.delete(entityId);
},
});
});
},
});
}
private getDayNightPhase(gameTime: number): { phase: 'day' | 'sunset' | 'night' | 'sunrise'; progress: number } {
const hour = gameTime * TOTAL_HOURS;
const sunsetStart = DAY_HOURS - SUNSET_DURATION_HOURS; // hour 11
const sunsetEnd = DAY_HOURS; // hour 12
const sunriseStart = TOTAL_HOURS - SUNRISE_DURATION_HOURS; // hour 17
if (hour < sunsetStart) {
return { phase: 'day', progress: 0 };
} else if (hour < sunsetEnd) {
return { phase: 'sunset', progress: (hour - sunsetStart) / SUNSET_DURATION_HOURS };
} else if (hour < sunriseStart) {
return { phase: 'night', progress: 1 };
} else {
return { phase: 'sunrise', progress: (hour - sunriseStart) / SUNRISE_DURATION_HOURS };
}
}
private renderDayNightOverlay(): void {
const { phase, progress } = this.getDayNightPhase(this.currentGameTime);
const w = this.cameras.main.width;
const h = this.cameras.main.height;
const phaseKey = `${phase}-${Math.round(progress * 100)}-${w}-${h}`;
// Skip redraw if nothing changed
if (phaseKey === this.lastPhaseKey) return;
this.lastPhaseKey = phaseKey;
this.dayNightOverlay.clear();
if (phase === 'day') return;
const stripCount = 50;
const stripWidth = Math.ceil(w / stripCount);
// Darkness overlay
for (let i = 0; i < stripCount; i++) {
const x = i * stripWidth;
// t goes 0 (left) to 1 (right)
const t = i / (stripCount - 1);
let alpha: number;
if (phase === 'night') {
alpha = NIGHT_DARKNESS;
} else if (phase === 'sunset') {
// Right side darkens first: right strips reach full darkness earlier
// At progress=0, alpha=0 everywhere. At progress=1, alpha=NIGHT_DARKNESS everywhere.
// Each strip's individual progress is shifted: right strips lead.
const stripProgress = Math.max(0, Math.min(1, progress * 2 - (1 - t)));
alpha = stripProgress * NIGHT_DARKNESS;
} else {
// Sunrise: right side lightens first
const stripProgress = Math.max(0, Math.min(1, progress * 2 - (1 - t)));
alpha = (1 - stripProgress) * NIGHT_DARKNESS;
}
if (alpha > 0.001) {
this.dayNightOverlay.fillStyle(0x0a0a2a, alpha);
this.dayNightOverlay.fillRect(x, 0, stripWidth + 1, h);
}
}
// Glow effect during transitions
if (phase === 'sunset' || phase === 'sunrise') {
this.renderGlow(w, h, phase, progress);
}
}
private renderGlow(w: number, h: number, phase: 'sunset' | 'sunrise', progress: number): void {
const glowStripCount = 20;
const glowWidth = w * 0.18; // 18% of screen
const glowStripW = glowWidth / glowStripCount;
// Bell curve: peaks at progress=0.5, zero at 0 and 1
const bellIntensity = Math.sin(progress * Math.PI);
const maxGlowAlpha = 0.15 * bellIntensity;
if (maxGlowAlpha < 0.005) return;
// Sunset glow colors (left edge): warm pink/orange/purple
// Sunrise glow colors (right edge): golden/amber
const isRight = phase === 'sunrise';
for (let i = 0; i < glowStripCount; i++) {
// Fade from edge inward
const edgeFade = 1 - (i / glowStripCount);
const alpha = maxGlowAlpha * edgeFade * edgeFade; // quadratic falloff
if (alpha < 0.005) continue;
let x: number;
if (isRight) {
x = w - glowWidth + i * glowStripW;
} else {
x = (glowStripCount - 1 - i) * glowStripW;
}
// Blend colors across the glow width
const colorT = i / glowStripCount;
let color: number;
if (phase === 'sunset') {
// Left edge: purple -> pink -> orange (outer to inner)
if (colorT < 0.33) color = 0x9933aa; // purple
else if (colorT < 0.66) color = 0xff6688; // pink
else color = 0xff9944; // orange
} else {
// Right edge: deep gold -> amber -> pale gold (outer to inner)
if (colorT < 0.33) color = 0xffaa22; // deep gold
else if (colorT < 0.66) color = 0xffcc44; // amber
else color = 0xffdd88; // pale gold
}
this.dayNightOverlay.fillStyle(color, alpha);
this.dayNightOverlay.fillRect(x, 0, glowStripW + 1, h);
}
}
}