feat(client): improve day/night cycle and add time indicator

Fix vertical line artifacts in nighttime overlay by using a single
rectangle for full night and a base+sweep approach for transitions.
Increase night darkness from 0.3 to 0.45 for more distinct nighttime.
Add a time indicator bar at top-center showing day/night progress
with sun/moon icons matching the EarthBound-inspired UI style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-09 17:19:05 +00:00
parent 700dddc845
commit afbf85d7ff
3 changed files with 193 additions and 24 deletions
+37 -23
View File
@@ -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);
}
}
}
+155
View File
@@ -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();
}
}
+1 -1
View File
@@ -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 = {