diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 11b3ad8..dde3467 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -10,6 +10,7 @@ import { EventsFeed } from '../ui/EventsFeed.js'; import { InventionsPanel } from '../ui/InventionsPanel.js'; import { StocksPanel } from '../ui/StocksPanel.js'; import { InteractionEmojiManager } from '../ui/InteractionEmoji.js'; +import { TimeIndicator } from '../ui/TimeIndicator.js'; import type { WorldState, StateUpdate, EntityState, Appearance, NarrationEvent, MemoryEvent } from '@dflike/shared'; import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE, DAY_HOURS, TOTAL_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS } from '@dflike/shared'; @@ -51,6 +52,7 @@ export class GameScene extends Phaser.Scene { private currentGameTime = 0; private targetGameTime = 0; private lastPhaseKey = ''; + private timeIndicator!: TimeIndicator; constructor() { super({ key: 'GameScene' }); @@ -125,6 +127,7 @@ export class GameScene extends Phaser.Scene { this.dayNightOverlay = this.add.graphics(); this.dayNightOverlay.setScrollFactor(0); this.dayNightOverlay.setDepth(999); + this.timeIndicator = new TimeIndicator(); // Input this.cursors = this.input.keyboard!.createCursorKeys(); @@ -742,6 +745,7 @@ export class GameScene extends Phaser.Scene { if (this.currentGameTime < 0) this.currentGameTime += 1; this.renderDayNightOverlay(); + this.timeIndicator.update(this.currentGameTime); this.handleCameraAndMovement(delta); } @@ -1003,33 +1007,43 @@ export class GameScene extends Phaser.Scene { this.dayNightOverlay.clear(); if (phase === 'day') return; - 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); + if (phase === 'night') { + // Full night: single rectangle, no strips + this.dayNightOverlay.fillStyle(0x0a0a2a, NIGHT_DARKNESS); + this.dayNightOverlay.fillRect(0, 0, w, h); + } else { + // Transitions: use a base layer + sweep layer to avoid strip seams + // Base layer covers the whole screen at minimum alpha for this progress + const baseAlpha = phase === 'sunset' + ? Math.max(0, progress * 2 - 1) * NIGHT_DARKNESS // 0 until halfway, then ramps up + : Math.max(0, 1 - progress * 2) * NIGHT_DARKNESS; // full until halfway, then ramps down - 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 (baseAlpha > 0.001) { + this.dayNightOverlay.fillStyle(0x0a0a2a, baseAlpha); + this.dayNightOverlay.fillRect(0, 0, w, h); } - if (alpha > 0.001) { - this.dayNightOverlay.fillStyle(0x0a0a2a, alpha); - this.dayNightOverlay.fillRect(x, 0, stripWidth + 1, h); + // Sweep layer adds additional darkness in a gradient from one side + const stripCount = 50; + const stripWidth = Math.ceil(w / stripCount); + for (let i = 0; i < stripCount; i++) { + const x = i * stripWidth; + const t = i / (stripCount - 1); + + let alpha: number; + if (phase === 'sunset') { + const stripProgress = Math.max(0, Math.min(1, progress * 2 - (1 - t))); + alpha = stripProgress * NIGHT_DARKNESS - baseAlpha; + } else { + const stripProgress = Math.max(0, Math.min(1, progress * 2 - (1 - t))); + alpha = (1 - stripProgress) * NIGHT_DARKNESS - baseAlpha; + } + + if (alpha > 0.001) { + this.dayNightOverlay.fillStyle(0x0a0a2a, alpha); + this.dayNightOverlay.fillRect(x, 0, stripWidth + 2, h); + } } } diff --git a/client/src/ui/TimeIndicator.ts b/client/src/ui/TimeIndicator.ts new file mode 100644 index 0000000..12a714a --- /dev/null +++ b/client/src/ui/TimeIndicator.ts @@ -0,0 +1,155 @@ +import { DAY_HOURS, NIGHT_HOURS, TOTAL_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS } from '@dflike/shared'; + +export class TimeIndicator { + private container: HTMLDivElement; + private marker: HTMLDivElement; + private daySection: HTMLDivElement; + private nightSection: HTMLDivElement; + private sunIcon: HTMLDivElement; + private moonIcon: HTMLDivElement; + private lastTimeKey = ''; + + constructor() { + // Outer container — top center of screen + this.container = document.createElement('div'); + this.container.style.cssText = ` + position: fixed; + top: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 1001; + display: flex; + align-items: center; + gap: 5px; + font-family: 'Press Start 2P', monospace; + pointer-events: none; + opacity: 0.85; + `; + + // Sun icon + this.sunIcon = document.createElement('div'); + this.sunIcon.style.cssText = ` + font-size: 10px; + color: #f0d060; + text-shadow: 0 0 4px #f0d06088; + line-height: 1; + `; + this.sunIcon.textContent = '☀'; + + // Track container + const track = document.createElement('div'); + track.style.cssText = ` + display: flex; + height: 8px; + border: 2px solid #e0d0b0; + border-radius: 1px; + overflow: hidden; + position: relative; + box-shadow: 0 0 6px #00000066, inset 0 1px 2px #00000044; + `; + + // Day section (proportional width) + const dayFraction = DAY_HOURS / TOTAL_HOURS; + this.daySection = document.createElement('div'); + this.daySection.style.cssText = ` + width: ${dayFraction * 120}px; + height: 100%; + background: linear-gradient(180deg, #5c8abf 0%, #3a6a9f 100%); + position: relative; + `; + + // Divider line between day/night + const divider = document.createElement('div'); + divider.style.cssText = ` + width: 1px; + height: 100%; + background: #e0d0b0; + flex-shrink: 0; + opacity: 0.5; + `; + + // Night section + this.nightSection = document.createElement('div'); + this.nightSection.style.cssText = ` + width: ${(1 - dayFraction) * 120}px; + height: 100%; + background: linear-gradient(180deg, #1a1a3e 0%, #0e0e28 100%); + position: relative; + `; + + // Marker (moves across the full track) + this.marker = document.createElement('div'); + this.marker.style.cssText = ` + position: absolute; + top: -1px; + width: 3px; + height: calc(100% + 2px); + background: #f0d060; + box-shadow: 0 0 4px #f0d060aa; + border-radius: 1px; + z-index: 2; + transition: left 0.5s linear; + `; + this.marker.style.left = '0px'; + + track.appendChild(this.daySection); + track.appendChild(divider); + track.appendChild(this.nightSection); + track.appendChild(this.marker); + + // Moon icon + this.moonIcon = document.createElement('div'); + this.moonIcon.style.cssText = ` + font-size: 9px; + color: #b8c8e8; + text-shadow: 0 0 4px #b8c8e866; + line-height: 1; + `; + this.moonIcon.textContent = '☾'; + + this.container.appendChild(this.sunIcon); + this.container.appendChild(track); + this.container.appendChild(this.moonIcon); + + document.body.appendChild(this.container); + } + + /** @param gameTime normalized 0-1 through the full day/night cycle */ + update(gameTime: number): void { + // Only update when visually changed (quantize to avoid thrash) + const key = Math.round(gameTime * 200).toString(); + if (key === this.lastTimeKey) return; + this.lastTimeKey = key; + + // gameTime 0-1 maps linearly across the track + // Track total inner width = daySection + 1px divider + nightSection + const totalWidth = 120 + 1; // 120px sections + 1px divider + const px = gameTime * totalWidth; + + this.marker.style.left = `${px}px`; + + // Determine phase for icon highlighting + const hour = gameTime * TOTAL_HOURS; + const sunsetStart = DAY_HOURS - SUNSET_DURATION_HOURS; + const isDay = hour < sunsetStart; + const sunriseStart = TOTAL_HOURS - SUNRISE_DURATION_HOURS; + const isNight = hour >= DAY_HOURS && hour < sunriseStart; + + // Pulse the active icon + if (isDay) { + this.sunIcon.style.opacity = '1'; + this.moonIcon.style.opacity = '0.4'; + } else if (isNight) { + this.sunIcon.style.opacity = '0.4'; + this.moonIcon.style.opacity = '1'; + } else { + // Transition + this.sunIcon.style.opacity = '0.7'; + this.moonIcon.style.opacity = '0.7'; + } + } + + destroy(): void { + this.container.remove(); + } +} diff --git a/shared/src/constants.ts b/shared/src/constants.ts index 3b5c0e0..03bf607 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -28,7 +28,7 @@ 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) +export const NIGHT_DARKNESS = 0.45; // max overlay opacity (0-1) // Directions (row index in spritesheet) export const Direction = {