From 88e560f4b406fb2337d5f4b191ba3321c5db65d4 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 03:10:05 +0000 Subject: [PATCH] feat: add day/night visual overlay with sunset/sunrise transitions Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 303 ++++++++++++++++++++++++++++----- 1 file changed, 262 insertions(+), 41 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index bfb5b0b..2e960bb 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -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(); 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); + } + } }