feat: add day/night visual overlay with sunset/sunrise transitions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 03:10:05 +00:00
parent a778bee58d
commit 88e560f4b4
+262 -41
View File
@@ -6,7 +6,7 @@ import { NpcInfoPanel } from '../ui/NpcInfoPanel.js';
import { CommandPanel } from '../ui/CommandPanel.js';
import { InteractionEmojiManager } from '../ui/InteractionEmoji.js';
import type { WorldState, StateUpdate, EntityState, Appearance } from '@dflike/shared';
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE } from '@dflike/shared';
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE, DAY_HOURS, NIGHT_HOURS, TOTAL_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS } from '@dflike/shared';
interface EntitySprite {
sprite: Phaser.GameObjects.Sprite;
@@ -33,6 +33,10 @@ export class GameScene extends Phaser.Scene {
private followCommandPanel!: CommandPanel;
private highlightEnabled = false;
private highlightedSpriteId: number | null = null;
private dayNightOverlay!: Phaser.GameObjects.Graphics;
private currentGameTime = 0;
private targetGameTime = 0;
private lastPhaseKey = '';
constructor() {
super({ key: 'GameScene' });
@@ -64,6 +68,11 @@ export class GameScene extends Phaser.Scene {
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 = {
@@ -143,18 +152,17 @@ export class GameScene extends Phaser.Scene {
}
private drawWorld(): void {
const { worldWidth, worldHeight, terrain, decorations, pointsOfInterest } = this.worldState;
const { worldWidth, worldHeight, terrain, decorations, trunkDecorations, pointsOfInterest } = this.worldState;
// --- LPC autotile layout (3 cols x 6 rows per tileset, 32x32 tiles) ---
// Row 0: [inner-SE=0] [inner-SW=1] [??=2]
// Row 1: [inner-NE=3] [inner-NW=4] [??=5]
// Row 2: [outer-NW=6] [N-edge=7] [outer-NE=8]
// Row 3: [W-edge=9] [center=10] [E-edge=11]
// Row 4: [outer-SW=12][S-edge=13] [outer-SE=14]
// Row 5: [fill-1=15] [fill-2=16] [fill-3=17]
//
// "outer" = convex corner of the terrain island
// "inner" = concave notch (diagonal-only neighbor is the other terrain)
// --- 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)
@@ -164,28 +172,55 @@ export class GameScene extends Phaser.Scene {
};
// Pick autotile for a GRASS tile in the watergrass layer.
// watergrass.png = grass-on-water: edge tiles show grass with water bleeding through.
// 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 nw = isW(-1, -1), ne = isW(1, -1), sw = isW(-1, 1), se = isW(1, 1);
const waterCount = (n ? 1 : 0) + (s ? 1 : 0) + (w ? 1 : 0) + (e ? 1 : 0);
// Outer corners (two cardinal sides are water)
if (n && w) return 6;
if (n && e) return 8;
if (s && w) return 12;
if (s && e) return 14;
// 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 7;
if (s) return 13;
if (w) return 9;
if (e) return 11;
// Inner corners skipped — watergrass inner tiles are designed for water
// tiles (mostly water with small grass patch), not for grass tiles.
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.
@@ -205,24 +240,29 @@ export class GameScene extends Phaser.Scene {
if (w) return 9;
if (e) return 11;
// Inner corners (diagonal-only grass → small transparent notch in dirt)
if (se) return 0;
if (sw) return 1;
if (ne) return 3;
if (nw) return 4;
// 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 decoData: number[][] = []; // tree decorations
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++) {
@@ -231,9 +271,9 @@ export class GameScene extends Phaser.Scene {
// Base: always solid grass
grassRow.push(FILL);
// Water layer: watergrass edges on GRASS tiles, solid fill on WATER tiles
// Water layer: watergrass edges on GRASS tiles, inner corners or fill on WATER tiles
if (t === Terrain.WATER) {
waterRow.push(FILL);
waterRow.push(pickWaterInnerCorner(x, y));
} else if (t === Terrain.GRASS) {
waterRow.push(pickWaterEdge(x, y));
} else {
@@ -247,7 +287,11 @@ export class GameScene extends Phaser.Scene {
dirtRow.push(-1);
}
// Decorations
// 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);
}
@@ -255,9 +299,41 @@ export class GameScene extends Phaser.Scene {
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[][],
@@ -280,14 +356,21 @@ export class GameScene extends Phaser.Scene {
return layer;
};
createLayer(grassData, 'grass', 'lpc_grass', -4);
createLayer(waterData, 'water', 'lpc_watergrass', -3);
createLayer(dirtData, 'dirt', 'lpc_dirt', -2);
createLayer(decoData, 'trees', 'lpc_treetop', -1);
// 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(0);
graphics.setDepth(-6);
for (const poi of pointsOfInterest) {
const color = poi.type === 'food' ? 0xcc8833 : 0x3355cc;
graphics.fillStyle(color, 0.7);
@@ -320,6 +403,8 @@ export class GameScene extends Phaser.Scene {
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);
@@ -365,6 +450,7 @@ export class GameScene extends Phaser.Scene {
}
private handleStateUpdate(update: StateUpdate): void {
this.targetGameTime = update.gameTime;
const activeIds = new Set<number>();
for (const entity of update.entities) {
@@ -376,6 +462,9 @@ export class GameScene extends Phaser.Scene {
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);
@@ -437,6 +526,20 @@ export class GameScene extends Phaser.Scene {
}
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);
}
@@ -581,4 +684,122 @@ export class GameScene extends Phaser.Scene {
this.highlightedSpriteId = null;
}
}
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
const sunriseEnd = TOTAL_HOURS; // hour 18 (wraps to 0)
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 phaseKey = `${phase}-${Math.round(progress * 100)}`;
// Skip redraw if nothing changed
if (phaseKey === this.lastPhaseKey) return;
this.lastPhaseKey = phaseKey;
this.dayNightOverlay.clear();
if (phase === 'day') return;
const w = this.cameras.main.width;
const h = this.cameras.main.height;
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);
}
}
}