Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
reviews:
profile: assertive
8 changes: 8 additions & 0 deletions resources/images/NightModeIconWhite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@
"tab_keybinds": "Keybinds",
"dark_mode_label": "Dark Mode",
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
"night_mode_label": "Night Mode",
"night_mode_desc": "Puts the map into a night-time mode. Purely aesthetic.",
"emojis_label": "Emojis",
"emojis_desc": "Toggle whether emojis are shown in game",
"alert_frame_label": "Alert Frame",
Expand Down
6 changes: 6 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,12 @@ class Client {
document.documentElement.classList.remove("dark");
}

if (this.userSettings.nightMode()) {
document.documentElement.classList.add("night");
} else {
document.documentElement.classList.remove("night");
}

// Attempt to join lobby
this.handleHash();

Expand Down
34 changes: 34 additions & 0 deletions src/client/UserSettingModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ export class UserSettingModal extends LitElement {
console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF");
}

toggleNightMode(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;

if (typeof enabled !== "boolean") {
console.warn("Unexpected toggle event payload", e);
return;
}
this.userSettings.set("settings.nightMode", enabled);

if (enabled) {
document.documentElement.classList.add("night");
} else {
document.documentElement.classList.remove("night");
}

this.dispatchEvent(
new CustomEvent("night-mode-changed", {
detail: { nightMode: enabled },
bubbles: true,
composed: true,
}),
);
}

private toggleEmojis(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
Expand Down Expand Up @@ -283,6 +307,16 @@ export class UserSettingModal extends LitElement {
this.toggleDarkMode(e)}
></setting-toggle>

<!-- 🌙 Night Mode -->
<setting-toggle
label="${translateText("user_setting.night_mode_label")}"
description="${translateText("user_setting.night_mode_desc")}"
id="night-mode-toggle"
.checked=${this.userSettings.nightMode()}
@change=${(e: CustomEvent<{ checked: boolean }>) =>
this.toggleNightMode(e)}
></setting-toggle>

<!-- 😊 Emojis -->
<setting-toggle
label="${translateText("user_setting.emojis_label")}"
Expand Down
3 changes: 3 additions & 0 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { NightModeLayer } from "./layers/NightModeLayer";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
Expand Down Expand Up @@ -242,6 +243,7 @@ export function createRenderer(
new FxLayer(game),
new UILayer(game, eventBus, transformHandler),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new NightModeLayer(transformHandler),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
chatDisplay,
Expand Down Expand Up @@ -321,6 +323,7 @@ export class GameRenderer {
this.redraw();
rafId = requestAnimationFrame(() => this.renderGame());
});
NightModeLayer.setGame(this.game);
}

resizeCanvas() {
Expand Down
180 changes: 180 additions & 0 deletions src/client/graphics/layers/NightModeLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";

export class NightModeLayer implements Layer {
setGame(game: GameView) {
this.game = game;
}

private darkenColor: [number, number, number] = [0, 0, 0];
private darkenAlpha: number = 0.8;
private flashlightRadius: number = 50;
private userSettingsInstance = new UserSettings();
private mouseX: number = 0;
private mouseY: number = 0;

// Add game reference
private game: GameView | null = null;

private handleMouseMove(event: MouseEvent) {
const rect = this.transformHandler.boundingRect();
this.mouseX = event.clientX - rect.left;
this.mouseY = event.clientY - rect.top;
}

init(): void {}
tick(): void {}
redraw(): void {}

constructor(
private transformHandler: TransformHandler,
game?: GameView, // Add game parameter
) {
this.game = game ?? null;
if (this.userSettingsInstance.nightMode()) {
document.documentElement.classList.add("night");
} else {
document.documentElement.classList.remove("night");
}
document.addEventListener("mousemove", (e) => this.handleMouseMove(e));
}

// New method to set game reference after construction

renderLayer(context: CanvasRenderingContext2D): void {
if (!this.userSettingsInstance.nightMode()) return;

const width = this.transformHandler.width();
const height = this.transformHandler.boundingRect().height;
const cellSize = this.transformHandler.scale;

// Fill the entire screen with dark
context.fillStyle = `rgba(${this.darkenColor[0]}, ${this.darkenColor[1]}, ${this.darkenColor[2]}, ${this.darkenAlpha})`;
context.fillRect(0, 0, width, height);

// ===== NEW: Render city lights =====
if (this.game) {
this.renderCityLights(context, cellSize);
}

// Render flashlight effect around mouse
this.renderFlashlight(context, width, height, cellSize);
}

/**
* Renders illumination for all cities on the map.
* Creates a glow effect similar to satellite images of Earth at night.
*/
private renderCityLights(
context: CanvasRenderingContext2D,
cellSize: number,
): void {
// Get all cities in the game
const cities = this.game!.units(UnitType.City);

for (const city of cities) {
// Get city position
const tileRef = city.tile();
const cityX = this.game!.x(tileRef);
const cityY = this.game!.y(tileRef);

// Convert tile coordinates to screen coordinates
const screenX = cityX * cellSize;
const screenY = cityY * cellSize;

// Get city level for scaling the light effect
const cityLevel = city.level();

// Render city glow - you can customize this pattern
this.renderCityGlow(context, screenX, screenY, cellSize, cityLevel);
}
}

/**
* Renders a glow effect for a single city.
* Customize this method to achieve your desired lighting pattern.
*/
private renderCityGlow(
context: CanvasRenderingContext2D,
x: number,
y: number,
cellSize: number,
level: number,
): void {
// Example 1: Simple bright square (like a satellite image)
const lightRadius = 5 + level * 2; // Larger cities have bigger glow

for (let dy = -lightRadius; dy <= lightRadius; dy++) {
for (let dx = -lightRadius; dx <= lightRadius; dx++) {
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= lightRadius) {
// Brightness decreases with distance
const brightness = 1 - distance / lightRadius;
const alpha = this.darkenAlpha * 0.8 * brightness;

context.fillStyle = `rgba(255, 220, 150, ${alpha})`;
context.fillRect(
x + dx * cellSize,
y + dy * cellSize,
cellSize,
cellSize,
);
}
}
}

// Example 2: Add a brighter core
context.fillStyle = `rgba(255, 255, 200, ${this.darkenAlpha * 0.9})`;
context.fillRect(x, y, cellSize, cellSize);
}

/**
* Renders the flashlight effect around the mouse cursor.
* Extracted from original renderLayer for better organization.
*/
private renderFlashlight(
context: CanvasRenderingContext2D,
width: number,
height: number,
cellSize: number,
): void {
const startX =
Math.floor(
Math.max(this.mouseX - this.flashlightRadius * cellSize, 0) / cellSize,
) * cellSize;
const endX =
Math.ceil(
Math.min(this.mouseX + this.flashlightRadius * cellSize, width) /
cellSize,
) * cellSize;

const startY =
Math.floor(
Math.max(this.mouseY - this.flashlightRadius * cellSize, 0) / cellSize,
) * cellSize;
const endY =
Math.ceil(
Math.min(this.mouseY + this.flashlightRadius * cellSize, height) /
cellSize,
) * cellSize;

for (let y = startY; y < endY; y += cellSize) {
for (let x = startX; x < endX; x += cellSize) {
const dist = Math.hypot(
(this.mouseX - (x + cellSize / 2)) / cellSize,
(this.mouseY - (y + cellSize / 2)) / cellSize,
);

const brightness = Math.max(0, 1 - dist / this.flashlightRadius);

if (brightness > 0) {
context.fillStyle = `rgba(200,200,130,${(this.darkenAlpha / 2) * brightness})`;
context.fillRect(x, y, cellSize, cellSize);
}
}
}
Comment on lines +191 to +205
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Performance consideration: flashlight rendering complexity.

Similar to city glow, the flashlight effect uses nested loops that could iterate many times per frame depending on flashlightRadius and cellSize. While this is for a single cursor location (unlike multiple cities), it still runs every frame.

Consider using a radial gradient for better performance:

private renderFlashlight(
  context: CanvasRenderingContext2D,
  width: number,
  height: number,
  cellSize: number,
): void {
  const radius = this.flashlightRadius * cellSize;
  const gradient = context.createRadialGradient(
    this.mouseX, this.mouseY, 0,
    this.mouseX, this.mouseY, radius
  );
  
  gradient.addColorStop(0, `rgba(200, 200, 130, ${this.darkenAlpha / 2})`);
  gradient.addColorStop(1, 'rgba(200, 200, 130, 0)');
  
  context.fillStyle = gradient;
  context.fillRect(
    Math.max(0, this.mouseX - radius),
    Math.max(0, this.mouseY - radius),
    Math.min(width, radius * 2),
    Math.min(height, radius * 2)
  );
}

This eliminates the nested loops while achieving a similar visual effect with much better performance.

🤖 Prompt for AI Agents
In src/client/graphics/layers/NightModeLayer.ts around lines 164 to 178, the
current flashlight implementation iterates nested loops per frame which is
expensive; replace the cell-by-cell drawing with a single canvas radial
gradient: create a radial gradient centered at (this.mouseX, this.mouseY) with
inner radius 0 and outer radius this.flashlightRadius * cellSize, add two color
stops (opaque-ish at 0 using rgba(200,200,130,this.darkenAlpha/2) and fully
transparent at 1), set context.fillStyle to that gradient, and fill only the
bounding rectangle covering the gradient (clamped to canvas bounds) so the
effect matches visually but avoids per-cell loops for much better performance.

}
}
32 changes: 32 additions & 0 deletions src/client/graphics/layers/SettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import exitIcon from "../../../../resources/images/ExitIconWhite.svg";
import explosionIcon from "../../../../resources/images/ExplosionIconWhite.svg";
import mouseIcon from "../../../../resources/images/MouseIconWhite.svg";
import nightModeIcon from "../../../../resources/images/NightModeIconWhite.svg";
import ninjaIcon from "../../../../resources/images/NinjaIconWhite.svg";
import settingsIcon from "../../../../resources/images/SettingIconWhite.svg";
import treeIcon from "../../../../resources/images/TreeIconWhite.svg";
Expand Down Expand Up @@ -136,6 +137,12 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}

private onToggleNightModeButtonClick() {
this.userSettings.toggleNightMode();
this.eventBus.emit(new RefreshGraphicsEvent());
this.requestUpdate();
}

private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
this.requestUpdate();
Expand Down Expand Up @@ -321,6 +328,31 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>

<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onToggleNightModeButtonClick}"
>
<img
src=${nightModeIcon}
alt="nightModeIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.night_mode_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.night_mode_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.nightMode()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>

<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onToggleSpecialEffectsButtonClick}"
Expand Down
13 changes: 13 additions & 0 deletions src/core/game/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export class UserSettings {
return this.get("settings.darkMode", false);
}

nightMode() {
return this.get("settings.nightMode", false);
}

leftClickOpensMenu() {
return this.get("settings.leftClickOpensMenu", false);
}
Expand Down Expand Up @@ -128,6 +132,15 @@ export class UserSettings {
}
}

toggleNightMode() {
this.set("settings.nightMode", !this.nightMode());
if (this.nightMode()) {
document.documentElement.classList.add("night");
} else {
document.documentElement.classList.remove("night");
}
}

// For development only. Used for testing patterns, set in the console manually.
getDevOnlyPattern(): PlayerPattern | undefined {
const data = localStorage.getItem("dev-pattern") ?? undefined;
Expand Down
Loading
Loading