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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user