docs: add day/night cycle implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 02:57:21 +00:00
parent 6800d35124
commit 3be39b0c63
@@ -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"
```