docs: add day/night cycle implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
# Day/Night Cycle Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a visual day/night cycle with directional sunset/sunrise transitions, tied to NPC energy drain timing.
|
||||
|
||||
**Architecture:** Server tracks game time as a normalized float (0-1) and broadcasts it with state updates. Client renders a camera-fixed Graphics overlay with gradient darkness strips and warm glow effects during transitions.
|
||||
|
||||
**Tech Stack:** TypeScript, Phaser 3.80 (Graphics API), Socket.IO
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Day/Night Constants
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/src/constants.ts:12-17`
|
||||
|
||||
**Step 1: Add constants after existing NPC needs block**
|
||||
|
||||
Add these constants after line 17 in `shared/src/constants.ts`:
|
||||
|
||||
```typescript
|
||||
// Day/night cycle
|
||||
export const DAY_NIGHT_RATIO = 2; // day is 2x as long as night
|
||||
export const DAY_HOURS = 12; // game hours of daytime
|
||||
export const NIGHT_HOURS = DAY_HOURS / DAY_NIGHT_RATIO; // 6 hours of night
|
||||
export const TOTAL_HOURS = DAY_HOURS + NIGHT_HOURS; // 18 hours total cycle
|
||||
export const SUNSET_DURATION_HOURS = 1; // game hours for sunset transition
|
||||
export const SUNRISE_DURATION_HOURS = 1; // game hours for sunrise transition
|
||||
export const NIGHT_DARKNESS = 0.3; // max overlay opacity (0-1)
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add shared/src/constants.ts
|
||||
git commit -m "feat: add day/night cycle constants"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add gameTime to StateUpdate type
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/src/types.ts:145-148`
|
||||
|
||||
**Step 1: Add gameTime field to StateUpdate interface**
|
||||
|
||||
Change the `StateUpdate` interface at line 145 of `shared/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
export interface StateUpdate {
|
||||
entities: EntityState[];
|
||||
tick: number;
|
||||
gameTime: number; // 0.0-1.0 normalized position in day/night cycle
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add shared/src/types.ts
|
||||
git commit -m "feat: add gameTime to StateUpdate type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Track and broadcast game time on server
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/game/GameLoop.ts:1,17,71,87-89`
|
||||
- Modify: `server/src/network/stateSerializer.ts:87-90`
|
||||
|
||||
**Step 1: Import day/night constants in GameLoop**
|
||||
|
||||
At line 1 of `server/src/game/GameLoop.ts`, add imports:
|
||||
|
||||
```typescript
|
||||
import { TICK_RATE, BROADCAST_EVERY_N_TICKS, ENERGY_DECAY_PER_TICK, DAY_NIGHT_RATIO } from '@dflike/shared';
|
||||
```
|
||||
|
||||
**Step 2: Add getGameTime() method to GameLoop**
|
||||
|
||||
After the `getTick()` method (line 87-89), add:
|
||||
|
||||
```typescript
|
||||
getGameTime(): number {
|
||||
// Day length in ticks = time for energy to drain from 100 to 0
|
||||
const dayTicks = 100 / ENERGY_DECAY_PER_TICK;
|
||||
const nightTicks = dayTicks / DAY_NIGHT_RATIO;
|
||||
const cycleTicks = dayTicks + nightTicks;
|
||||
return (this.tick % cycleTicks) / cycleTicks;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add gameTime to serializeStateUpdate**
|
||||
|
||||
In `server/src/network/stateSerializer.ts`, change the `serializeStateUpdate` function signature to accept `gameTime`:
|
||||
|
||||
```typescript
|
||||
export function serializeStateUpdate(world: World, tick: number, gameTime: number): StateUpdate {
|
||||
const entities = world.query('position').map(id => serializeEntity(world, id));
|
||||
return { entities, tick, gameTime };
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Pass gameTime from SocketServer broadcast**
|
||||
|
||||
In `server/src/network/SocketServer.ts` line 26, update the call:
|
||||
|
||||
```typescript
|
||||
const update = serializeStateUpdate(this.gameLoop.world, this.gameLoop.getTick(), this.gameLoop.getGameTime());
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/src/game/GameLoop.ts server/src/network/stateSerializer.ts server/src/network/SocketServer.ts
|
||||
git commit -m "feat: server tracks and broadcasts game time"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add day/night overlay to GameScene
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/scenes/GameScene.ts`
|
||||
|
||||
This is the main visual implementation. Add a Graphics overlay that renders darkness and glow based on the server's gameTime.
|
||||
|
||||
**Step 1: Add day/night state properties**
|
||||
|
||||
Add these properties to the `GameScene` class (after `highlightedSpriteId` at line 35):
|
||||
|
||||
```typescript
|
||||
private dayNightOverlay!: Phaser.GameObjects.Graphics;
|
||||
private currentGameTime = 0;
|
||||
private targetGameTime = 0;
|
||||
private lastPhaseKey = '';
|
||||
```
|
||||
|
||||
**Step 2: Import day/night constants**
|
||||
|
||||
Update the import from `@dflike/shared` at line 9:
|
||||
|
||||
```typescript
|
||||
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';
|
||||
```
|
||||
|
||||
**Step 3: Create the overlay in create()**
|
||||
|
||||
After the camera setup (after line 65 `this.cameras.main.setBounds(...)`), add:
|
||||
|
||||
```typescript
|
||||
// Day/night overlay
|
||||
this.dayNightOverlay = this.add.graphics();
|
||||
this.dayNightOverlay.setScrollFactor(0);
|
||||
this.dayNightOverlay.setDepth(999);
|
||||
```
|
||||
|
||||
**Step 4: Add the day/night rendering methods**
|
||||
|
||||
Add these methods to the GameScene class:
|
||||
|
||||
```typescript
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Store gameTime from state updates**
|
||||
|
||||
In `handleStateUpdate` (line 443), add at the top of the method:
|
||||
|
||||
```typescript
|
||||
this.targetGameTime = update.gameTime;
|
||||
```
|
||||
|
||||
**Step 6: Update the overlay each frame**
|
||||
|
||||
In the `update` method (line 518-520), add interpolation and rendering:
|
||||
|
||||
```typescript
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/scenes/GameScene.ts
|
||||
git commit -m "feat: add day/night visual overlay with sunset/sunrise transitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual testing and tuning
|
||||
|
||||
**Step 1: Start the server and client**
|
||||
|
||||
```bash
|
||||
cd server && npm run dev &
|
||||
cd client && npm run dev &
|
||||
```
|
||||
|
||||
**Step 2: Verify the cycle works**
|
||||
|
||||
Open the game in browser. With the current energy drain rate, the full cycle is ~500 seconds (~8.3 minutes). To see a sunset quickly, either:
|
||||
- Wait ~5 minutes for sunset to begin, OR
|
||||
- Temporarily set `ENERGY_DECAY_PER_TICK = 0.5` in constants to speed up the cycle (~30s full cycle) for testing
|
||||
|
||||
**Step 3: Check these behaviors:**
|
||||
- Day phase: no overlay visible
|
||||
- Sunset: darkness sweeps in from right to left, warm glow appears on left edge
|
||||
- Night: uniform dark blue overlay at ~30% opacity, still easy to see NPCs
|
||||
- Sunrise: darkness lifts from right to left, golden glow on right edge
|
||||
- Transitions are smooth (no flickering or stuttering)
|
||||
- Overlay stays fixed to camera (doesn't move when panning)
|
||||
- Overlay covers full screen when resizing browser window
|
||||
|
||||
**Step 4: Revert any test constant changes, commit if tuning adjustments were made**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final cleanup commit
|
||||
|
||||
**Step 1: Run type checking**
|
||||
|
||||
```bash
|
||||
cd shared && npx tsc --noEmit
|
||||
cd server && npx tsc --noEmit
|
||||
cd client && npx tsc --noEmit
|
||||
```
|
||||
|
||||
**Step 2: Fix any type errors, commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: complete day/night cycle system"
|
||||
```
|
||||
Reference in New Issue
Block a user