From 3be39b0c636e37b046bfe52cc5528f32298802fd Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 02:57:21 +0000 Subject: [PATCH] docs: add day/night cycle implementation plan Co-Authored-By: Claude Opus 4.6 --- .../docs/plans/2026-03-08-day-night-cycle.md | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 .claude/worktrees/relationship-system/docs/plans/2026-03-08-day-night-cycle.md diff --git a/.claude/worktrees/relationship-system/docs/plans/2026-03-08-day-night-cycle.md b/.claude/worktrees/relationship-system/docs/plans/2026-03-08-day-night-cycle.md new file mode 100644 index 0000000..b3518c8 --- /dev/null +++ b/.claude/worktrees/relationship-system/docs/plans/2026-03-08-day-night-cycle.md @@ -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" +```