Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
Expand Down Expand Up @@ -244,6 +245,7 @@ export function createRenderer(
new FxLayer(game),
new UILayer(game, eventBus, transformHandler),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new NightModeLayer(game, transformHandler),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
chatDisplay,
Expand Down
210 changes: 210 additions & 0 deletions src/client/graphics/layers/NightModeLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { Cell, 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 {
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;
private maxCityLightLevel: number = 15;

private mouseMoveHandler = (e: MouseEvent) => this.handleMouseMove(e);

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 game: GameView | null,
private transformHandler: TransformHandler,
) {
document.addEventListener("mousemove", this.mouseMoveHandler);
}

// 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 glowBitmaps: Map<number, ImageBitmap> = new Map();
//lazy generator & cache for little lag
private async getGlowBitmap(
level: number,
cellSize: number,
): Promise<ImageBitmap> {
const cappedLevel = this.maxCityLightLevel;

// Check cache first
const cached = this.glowBitmaps.get(cappedLevel);
if (cached) return cached;

// Not in cache → generate, store, and return
const bitmap = await this.createGlowBitmap(cappedLevel, cellSize);
this.glowBitmaps.set(cappedLevel, bitmap);

return bitmap;
}
Comment on lines +64 to +79
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The level parameter is ignored—glow bitmaps are only cached for maxCityLightLevel.

The getGlowBitmap() method accepts a level parameter but immediately replaces it with cappedLevel = this.maxCityLightLevel (line 68), meaning all cities get the same sized glow regardless of their actual level. This defeats the purpose of passing cityLevel from line 134.

If you want uniform city lights, remove the unused parameter. If you want level-based scaling, use the actual level (capped to max):

   private async getGlowBitmap(
     level: number,
     cellSize: number,
   ): Promise<ImageBitmap> {
-    const cappedLevel = this.maxCityLightLevel;
+    const cappedLevel = Math.min(level, this.maxCityLightLevel);
 
     // Check cache first
     const cached = this.glowBitmaps.get(cappedLevel);

This way, level-1 cities get smaller glows than level-15 cities, and the cache stores one bitmap per level (0-15), not just one bitmap for all cities.

🤖 Prompt for AI Agents
In src/client/graphics/layers/NightModeLayer.ts around lines 64–79, the method
currently ignores the incoming level and always uses this.maxCityLightLevel for
caching and bitmap creation; change it to cap the provided level (e.g. const
cappedLevel = Math.min(level, this.maxCityLightLevel)) and use that cappedLevel
as the cache key and when calling createGlowBitmap so each level has its own
bitmap (or remove the level parameter if you deliberately want uniform glows).


private async createGlowBitmap(
level: number,
cellSize: number,
): Promise<ImageBitmap> {
const lightRadius = (10 + level * 2) * cellSize;
const size = lightRadius * 2;

// Use OffscreenCanvas for faster bitmap creation
const offscreen = new OffscreenCanvas(size, size);
const ctx = offscreen.getContext("2d")!;

// Glow gradient (you can customize color stops here)
const gradient = ctx.createRadialGradient(
lightRadius,
lightRadius,
0,
lightRadius,
lightRadius,
lightRadius,
);
gradient.addColorStop(0, "rgba(255, 230, 120, 0.8)");
gradient.addColorStop(0.4, "rgba(255, 180, 80, 0.4)");
gradient.addColorStop(1, "rgba(255, 140, 40, 0)");

ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(lightRadius, lightRadius, lightRadius, 0, Math.PI * 2);
ctx.fill();

return await createImageBitmap(offscreen);
}

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 screenPos = this.transformHandler.worldToScreenCoordinates(
new Cell(cityX, cityY),
);
const screenX = screenPos.x;
const screenY = screenPos.y;

// 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 async renderCityGlow(
context: CanvasRenderingContext2D,
x: number,
y: number,
cellSize: number,
level: number,
): Promise<void> {
const glow = await this.getGlowBitmap(level, cellSize);

// Compute radius for positioning (still capped)
const cappedLevel = this.maxCityLightLevel;
const radius = (10 + cappedLevel * 2) * cellSize;

context.drawImage(glow, x - radius, y - radius);
}

/**
* 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.

}
destroy?(): void {
document.removeEventListener("mousemove", this.mouseMoveHandler);
}
}
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