797ea2d410
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1091 lines
39 KiB
TypeScript
1091 lines
39 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|