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:
+262
-41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user