Commit Graph

95 Commits

Author SHA1 Message Date
Vivacious Box dcdfd7bad7 If player is localplayer, set structure border to territory color (#4366)
Resolves #4365

## Description:

Currently the border of icons are using borderColor which is grey for
local player

<img width="227" height="281" alt="image"
src="https://github.com/user-attachments/assets/9e334e19-c5b2-49ca-a85d-4576a5bbc1a9"
/>

This set it to territory color 
<img width="187" height="102" alt="image"
src="https://github.com/user-attachments/assets/9b9f27f9-69e2-4ae7-9f35-a789b56b45de"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Mr. Box
2026-06-21 15:15:01 -07:00
Evan 4ee68b4ea7 Add nuke fallout color graphics option (#4355)
## What

Adds a **Nuke fallout color** option to the in-game graphics settings
modal (Effects section), letting players recolor the fallout tint left
on territory after a nuke.

![modal](https://github.com/user-attachments/assets/placeholder)

## How

Mirrors the existing **Ocean color** override pattern:

- `GraphicsOverrides.ts` — adds `staleNukeColor` (hex string) to the
`mapOverlay` override schema.
- `RenderOverrides.ts` — `applyGraphicsOverrides` parses the hex and
writes the renderer's `staleNukeR/G/B` 0–1 float channels (`hexToRgb`
yields 0–255, so it divides by 255).
- `GraphicsSettingsModal.ts` — new hex-text + native color-picker row,
default computed from `render-settings.json`.
- `en.json` — `nuke_color_label` / `nuke_color_desc`.

The value persists via `UserSettings.graphicsOverrides()` and is cleared
by the modal's existing "Reset to defaults".

The render debug GUI already exposes the same setting as **Stale Nuke
Color** (Map Overlay), so no change was needed there.

## Testing

- `tsc --noEmit` clean.
- Verified in a headless solo game: the row renders with the green
default (`#0d8c12`), changing it persists `mapOverlay.staleNukeColor`,
and `applyGraphicsOverrides("#ff0000")` produces `staleNukeR=1, G=0,
B=0`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:28:29 -07:00
Evan cb0d79ed6d Add black outline to alliance icon for terrain contrast (#4353)
## Problem

The green alliance icon above player names blends into similarly-colored
terrain — most notably irradiated land, which is the same green — making
it hard to spot allied players.

## Fix

Add a configurable dark outline to the alliance status icon, rendered in
the status-icon shader (the icons come from a pre-baked atlas with no
regeneration script, so this is done in-shader rather than by editing
the PNG).

- **Outline**: an alpha dilation gated to the alliance icon (slot 3).
8-direction sampling of the icon's alpha builds a black halo around its
silhouette; interior pixels and all other status icons are untouched.
- **No clipping**: the alliance icon's quad is grown outward into the
atlas cell's existing transparent padding so the halo isn't clipped at
the quad edge. The icon's on-screen size and position are unchanged; 8px
of the cell's 16px mipmap-safety padding is preserved.
- **Drain stays aligned**: the alliance-expiry drain effect's cut line
and faded-icon UVs are remapped into the expanded quad space so the
animation still lines up.
- **Tunable**: width is driven by `name.statusOutlineWidth` in
`render-settings.json` (default 6 texels; 0 disables), with a matching
"Status Outline Width" slider in the debug GUI.

## Testing

`tsc` and `eslint` pass. Verified in-game: the handshake now reads
clearly against irradiated terrain, with the outline rendering fully (no
edge clipping) and the drain animation still aligned.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:17:14 -07:00
Evan 6159f1f186 Fix anonymous-names setting not hiding names on the map (#4345)
## Problem

Enabling the **hidden names** (anonymous names) setting hid names in the
leaderboard/HUD but **not on the map**.

The GL name renderer (`NamePass`) drew `slot.static.displayName` —
always the real name — and never consulted
`userSettings.anonymousNames()`. The HUD works because it calls
`PlayerView.displayName()` (which honors the setting) on each render,
but the names baked into the GPU texture bypassed that path entirely.

## Fix

Push the *resolved* name into the renderer instead of the raw static
name:

- **`WebGLFrameBuilder.syncPlayers`** registers each player with
`displayName: p.displayName()` (honors the setting) instead of
`static.displayName`. Covers enabling the setting before a game and
players who join after a toggle.
- **`WebGLFrameBuilder.refreshNames` → `MapRenderer` → `Renderer` →
`NamePass.refreshNames`** is a new path that re-resolves cached names
and forces a re-upload (resets `slot.nameLen = 0`, which also recomputes
the name half-width so it stays centered).
- **`ClientGameRunner`** listens for the `settings.anonymousNames`
change event and calls `refreshNames`, mirroring the existing
territory-patterns live toggle.

## Behavior

- Enabled before a game → players register with anonymous names.
- Toggled mid-game → map names flip to/from anonymous on the next sim
tick (~100ms), matching the leaderboard.
- Your own name is unaffected (unchanged — `PlayerView` maps the local
player's anonymous name to their real name).

## Testing

`tsc --noEmit` passes for all edited files. This is a WebGL rendering
change with no straightforward unit test; verified by tracing the data
flow (resolved name → cached `slot.static.displayName` → re-upload on
dirty).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:22:31 -07:00
Evan 707057f09f Fix ocean color change reverting nuke-created water to land (#4343)
## Problem

Changing the ocean/water color in **Graphics settings** repaints the
terrain — and any water tiles created by water nukes (land → water) snap
back to their original land appearance.

## Root cause

`TerrainPass` captures the `terrainBytes` buffer at construction and
reuses it in two places:

- `setOceanColor()` does a **full** terrain texture re-upload from
`terrainBytes` when the ocean color changes.
- `applyTerrainDelta()` applies live land→water nuke conversions, but
only wrote to the **GPU texture** — never back into `terrainBytes`.

So the CPU buffer stayed frozen at the map's original terrain. Changing
the ocean color rebuilt the whole texture from that stale buffer,
reverting every nuke crater to land.

## Fix

Write each delta byte back into `terrainBytes` inside
`applyTerrainDelta()`, so the buffer stays the live source of truth and
full re-uploads reflect conversions.

```ts
this.terrainBytes[ref] = bytes[i];
```

The indexing already lines up — `terrainBytes` is indexed by linear ref
(`y * mapW + x`), the same `ref` the delta loop iterates. The buffer is
only otherwise read once at construction by `RailroadPass`/`TerrainPass`
to seed GPU textures (which copy), so mutating it has no side effects
elsewhere.

## Testing

The WebGL passes have no unit-test harness (they need a live GL
context), so this isn't covered by an automated test. Verified by
reasoning through the data flow; can confirm in-game by nuking land into
water and then changing the ocean color.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:44:20 -07:00
Evan c7fdc4ec2d Cap renderer device-pixel-ratio at 2 (#4339)
## What

Routes every renderer call site that read `window.devicePixelRatio`
through a single `renderDpr()` helper that caps the value at **2**.

```ts
export function renderDpr(): number {
  return Math.min(window.devicePixelRatio || 2, 2);
}
```

## Why

On very high-DPI displays (DPR 3, common on phones) the WebGL backing
store was sized at 3× CSS pixels — ~9× the fragment work of 1× — for a
marginal visual gain over 2×. Capping at 2 keeps retina (DPR 2)
pixel-perfect while clamping the 3× case.

## How it stays correct

DPR isn't just the canvas size — it's one coordinate system shared by:

- the canvas backing-store size (`Renderer.resize`)
- the camera's screen↔world math (`Camera.resize` / `screenToWorld` /
`worldToScreen`)
- the camera zoom scale (`ClientGameRunner.syncCamera`)
- the constant-CSS-pixel-size world text (`WorldTextPass`)

These must all use the same DPR value or pointer hit-testing and text
sizing drift. Routing them through one helper guarantees that. The
diagnostics reporter (`Diagnostic.ts`) is intentionally left reading the
real hardware DPR, since its job is to report the actual device.

## Test

- `tsc --noEmit` clean for all touched files (one pre-existing unrelated
`marked` types error remains on `main`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:35:34 -07:00
Evan c9fe710700 Render spawn overlay with instancing to support large lobbies (#4322)
## Problem

The spawn-phase overlay stored every human's spawn center in GLSL
**uniform arrays** (capped at `MAX_SPAWNS = 32`) and looped over all of
them **per screen pixel** in a fullscreen pass.

In lobbies with more than 32 humans, centers past the cap were silently
dropped in join order — so a few seconds into the spawn phase the
**local player's own ring could disappear while the phase was still
active**. Team modes make this worse: `playerTeams` can be a raw team
count, so a single team can have far more than 32 members, all of which
need rings.

The two walls that blocked simply raising the constant:
- **Uniform arrays cap out ~96** against WebGL2's 224-vec4 fragment
floor — 1024 would never link.
- The **fullscreen per-pixel loop** over every spawn is `O(pixels ×
spawns)` — raising the cap makes it a GPU hazard during the spawn phase.

## Fix

Rewrite `SpawnOverlayPass` to draw **one instanced quad per spawn
center**, sized to that center's influence radius (mirroring
`SAMRadiusPass`). This removes the uniform-array limit and the per-pixel
loop, so cost scales with the number of spawns rather than screen area,
and the overlay supports the renderer's full ~1024-player ceiling.

Instances are ordered **enemies → teammates → self** so the local
player's ring composites on top under normal alpha blending.
Self/teammate render as breathing rings; enemies render as tile-fill
highlights on unowned tiles — identical visuals and render-settings to
before.

## Changes
- `gl/passes/SpawnOverlayPass.ts` — instanced rendering via
`DynamicInstanceBuffer` + `drawArraysInstanced`; no `MAX_SPAWNS` cap.
- `shaders/spawn-overlay/spawn-overlay.frag.glsl` — per-instance
(kind-dispatched) instead of a uniform-array loop; self white→color
pulse moved into the shader.
- `shaders/spawn-overlay/spawn-overlay.vert.glsl` — new instanced vertex
shader.

## Testing
- `tsc` (full project) + `eslint` clean.
- Headless WebGL run: shaders **compile and link** (game starts normally
with 123 players), and the genuine `updateSpawnOverlay → update() →
drawArraysInstanced()` path renders self/teammate rings and enemy tile
highlights with **no GL errors**.
- ⚠️ Not yet verified end-to-end in a real 30+ human FFA lobby (the
original repro) — that needs multiple real clients. The instanced draw
path and rendering were confirmed in singleplayer with the overlay
force-activated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:10:55 -07:00
Evan 305534cc65 Dispose WebGL renderer when a game stops 🧹 (#4295)
## Problem

`ClientGameRunner.stop()` tore down the worker, network, and sound, but
left the `MapRenderer` (and its WebGL context), the WebGL canvas, the
input overlay, and the self-driving RAF loop in place.

When you exit a game via the **Exit button** or browser **back**, the
page navigates to `/`, so the browser reclaims everything — that path is
fine. But you can start a new game **without** a reload: matchmaking and
joining another lobby go through `handleJoinLobby`, which calls
`lobbyHandle.stop(true)` then `joinLobby()` on the same document. The
old WebGL context stayed alive (the never-cancelled RAF kept it
referenced, so it wasn't even GC'd), and each new game stacked another
context. After a few games, mobile browsers hit their WebGL context
limit — matching the repro in #4267.

## Fix

`stop()` now disposes the renderer:

- cancels the self-driving RAF loop and disconnects the frame-loop
resize observer
- disposes the `MapRenderer` (frees all GPU resources)
- removes the WebGL canvas and the input overlay from the DOM

`GPURenderer.dispose()` additionally calls
`WEBGL_lose_context.loseContext()` so the context is released promptly
instead of waiting on unreliable GC. The territory-patterns settings
listener is wired to the existing graphics `AbortController` so it no
longer outlives the disposed view.

The cleanup runs unconditionally in `stop()` (a superseded join can stop
before the game becomes active) and is idempotent against repeated
`stop()` calls.

Fixes #4267

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:05:12 -07:00
Evan bb464538d0 Add Fallout effects toggle to graphics settings ☢️ (#4313)
## Summary

Adds a **"Fallout effects"** toggle to the *Effects* section of the
graphics settings modal, letting players disable the nuclear fallout
visuals (useful for performance).

Fallout is rendered by two passes — the broiling green **bloom** on
irradiated territory and its additive **light** contribution in
day/night mode. The bloom pass was already gated by
`passEnabled.falloutBloom`, but the light pass had no gate. This adds a
`passEnabled.falloutLight` flag and a single user-facing
`passEnabled.fallout` graphics override that drives both together.

## Changes

- **`RenderSettings.ts` / `render-settings.json`** — new
`passEnabled.falloutLight` flag (default `true`).
- **`LightmapPass.ts`** — gate the fallout light pass behind
`passEnabled.falloutLight`.
- **`GraphicsOverrides.ts`** — add `fallout: z.boolean()` to the
`passEnabled` override group.
- **`RenderOverrides.ts`** — apply `passEnabled.fallout` to both
`falloutBloom` and `falloutLight`.
- **`GraphicsSettingsModal.ts`** — `currentFallout()` /
`onToggleFallout()` + a toggle button (mirrors the existing Special
Effects toggle).
- **`en.json`** — `graphics_setting.fallout_label` / `fallout_desc`.

## Testing

- `tsc --noEmit` passes; JSON files validated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:56:07 -07:00
Evan e6256e6269 Flip structure icon glyph to light on dark territory 🌑 (#4312)
## Problem

In the structure render pass, when **classic icons** are enabled the
inner icon glyph is tinted with a *darkened version of the player's fill
color* (`uIconDarken = 0.3`). When a player's territory color is already
dark, both the structure shape and its glyph render dark, so the icon
blends into the shape and the dark territory behind it — making it
effectively unreadable.

(With non-classic icons the glyph is already the light `uIconColor`, so
only the classic path was affected.)

## Fix

In `structure.frag.glsl`, when classic icons are active, compute the
fill's perceptual luminance and flip the glyph to the light icon color
(`uIconColor`, white by default) when the fill is too dark:

```glsl
vec3 glyphColor = uIconColor;
if (uIconDarken > 0.0) {
  float fillLum = dot(fillColor.rgb, vec3(0.299, 0.587, 0.114));
  glyphColor = fillLum < 0.25 ? uIconColor : darken(fillColor.rgb, uIconDarken);
}
```

The non-classic path is unchanged. The change is contained to the shader
— no new uniforms or plumbing.

## Notes

- The `0.25` luminance threshold is hardcoded in the shader to keep the
change surgical. It could be promoted to a `render-settings.json` knob
if preferred.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:27:40 -07:00
Evan 6c84919801 Smooth nuke point-light position per frame in ambient mode (#4311)
## Summary

Follow-up to #4255. That PR made nuke **sprites** glide per render frame
— `UnitPass.drawMissiles` lerps each nuke's `lastPos→pos` by wall-clock
progress through the current tick. But in ambient/night mode the glow
*behind* a nuke comes from a separate pass, `PointLightPass`, whose
instance buffer is packed once per tick in `updateLights()` from the raw
`unit.pos`. Its per-frame `draw()` (run every frame via `LightmapPass`)
only set uniforms and issued the instanced draw — it never repositioned
the lights. So the sprite moved at 60fps while its light jumped once per
100ms tick.

## Fix

Mirror `UnitPass`'s smoothing in `PointLightPass`:

- `updateLights()` records a `smoothSegs` tuple `(lightIdx, lastX,
lastY, x, y)` for each `SMOOTHED_NUKE_TYPES` unit whose `lastPos !==
pos`, and stamps `lastUnitsUpdateMs`.
- A new `applySmoothing()`, called at the top of `draw()`, lerps those
lights by wall-clock tick progress (`(now - lastUnitsUpdateMs) /
tickIntervalMs`, clamped to 1) and re-uploads **only** the affected
instances. Unlike `UnitPass` (which re-uploads its tiny missile buffer
wholesale), the light buffer can hold thousands of static structure
lights, so a full per-frame re-upload would be wasteful.
- `tickIntervalMs` comes from a new `config` constructor param, wired
through in `Renderer.ts` (the same `config` already passed to
`UnitPass`).

The light now uses the exact same `lastPos→pos` endpoints and alpha as
the sprite, so the two track together.

## Test plan

- `npx tsc --noEmit`, eslint, and prettier all clean.
- `npx vitest tests/client/render --run` — 40 passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:54:11 -07:00
evanpelle 6833cef7bc Tweak render settings to look more like v31
Lower territory saturation, highlight thickness, and border darkening
to bring the rendered map closer to the v31 look.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:15:17 -07:00
Evan 1ad71b9cfa Fix hover-highlight inner border lagging during tile changes (#4303)
## Problem

When you hover over a territory, it highlights with a band that is
`highlightThicken` (default **2**) tiles deep — the edge plus 2 interior
rings, computed via a Chebyshev expansion in `border-compute.frag.glsl`.

Starting a hover triggers a full border recompute, which paints the band
correctly. But while you keep hovering and tiles change owner (territory
growing/shrinking, combat at the front), only the cheap **incremental**
scatter path runs. `BorderScatterPass.pushWithNeighbors` repainted only
the changed tile **+ its 4 cardinal neighbors** (radius 1) — fine for
normal borders, but not for the highlight band. A changed tile affects
the thickening of *every* highlight-owner tile within `highlightThicken`
of it, and those interior tiles were never repainted, so the **inner
edge of the highlight band stayed stale** ("the inside border is not
getting updated"). This was a documented trade-off in the class comment.

## Fix

When a highlight is active, `pushWithNeighbors` now repaints a Chebyshev
**box of radius `highlightThicken`** around each changed tile (the box
subsumes the cardinal cross, so normal borders still update). With no
highlight active it stays on the cheap 5-point cross, preserving the
pass's O(dirty-tiles) scaling. The extra cost (~25 vs 5 points/tile at
default) only applies while actually hovering.

## Testing

Hover over a territory while it grows/shrinks (early-game expansion or a
war front) and confirm the inner edge of the highlight band now tracks
the moving border instead of lagging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:58:29 -07:00
Evan 0639cdb29b Fix nuclear fallout covering UI overlays (#4302)
## Problem

Nuclear fallout was rendering on top of UI overlays (most visibly the
SAM launcher range circles), hiding them.

## Cause

In `renderOverlays()` (`src/client/render/gl/Renderer.ts`), the fallout
bloom pass was drawn near the end of the overlay sequence — after the
SAM radius, range circles, structures, bars, etc. — so it painted over
all of them.

## Fix

Moved `bloomPass.draw(...)` (fallout bloom) to draw right after the
ground units and before all UI overlays. Fallout is a
ground-contamination effect, so it now sits above the terrain/units but
below every UI overlay, which all render on top.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:35:53 -07:00
Evan 10ca2d1230 Restore gray in-game background to match v31 (#4301)
## Summary

The in-game map background changed from gray (v31) to near-black after
the WebGL renderer rewrite. This restores the gray.

The renderer rewrite hardcoded the base-layer clear color to `(0.04,
0.04, 0.06)` in `drawBaseLayer` (`src/client/render/gl/Renderer.ts`).
v31 set the background via `PastelTheme.backgroundColor()`, which
returned `rgb(60,60,60)`. This change sets the clear color back to that
gray.

## Notes

- The old theme-based `backgroundColor()` system was removed in the
rewrite, so this hardcoded clear color is now the single source for the
map background.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-15 20:47:06 -07:00
Evan 5be72db060 Reapply graphics overrides on debug render GUI reset (#4287)
## Problem

The debug render GUI's **"Reset to Defaults"** restored bare
`createRenderSettings()` defaults, wiping the user's graphics overrides
(colorblind theme, ocean color, lighting, name scaling, etc.) from the
live render settings. The per-prop right-click "reset to default" and
the modified-indicators had the same flaw — their captured defaults were
raw, ignoring overrides.

## Fix

Thread the existing `resolveRenderSettings` (`createRenderSettings()` +
`applyGraphicsOverrides()`) into the debug GUI as the defaults provider,
so reset restores the same settings the renderer was actually built
with.

- **`debug/index.ts`** — added a `resolveDefaults` param (defaults to
`createRenderSettings` to keep the module decoupled). The captured
`defaults` now include overrides, fixing the per-prop reset and modified
indicators too.
- **`debug/Wiring.ts`** — `wireActions` takes `resolveDefaults`; the
reset handler `deepAssign`s `resolveDefaults()` instead of
`createRenderSettings()`.
- **`ClientGameRunner.ts`** — passes `resolveRenderSettings` into
`createDebugGui`, and extracts a `refreshDerivedGraphics` helper
(terrain rebuild + re-theme/palette) from `onGraphicsChanged`, wired as
the GUI's `onSettingsChanged` so the reapplied terrain/colorblind
overrides become *visible* after reset (they're baked into GPU textures
and aren't picked up per-frame).

Side benefit: editing terrain/theme settings in the debug GUI now
refreshes those textures live too (that callback was previously never
wired).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:44:20 -07:00
evanpelle 5161d78d84 Fix nuke fallout embers flickering forever ☢️
The ember/particle flicker in the fallout effect was gated only by the
fallout bit, which is permanent on tiles that stay unowned. It also ramped
to full strength as the per-tile heat decayed to 0 and animated on the
global tick, so it kept flickering indefinitely after the blast had cooled —
visible both as the bloom dots and (more prominently) as the ambient ember
light when dynamic lighting is enabled.

Fade both with heat so they vanish along with the glow:
- extract.frag.glsl: bloom dots multiplied by the glow's opacity
- fallout-light.frag.glsl: ember light multiplied by heat

Heat decay timing is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:45:29 -07:00
Evan 52bcae5106 Replace dark mode with player-adjustable lighting (#4280)
## What

Removes the binary **dark mode** feature and replaces it with a
player-adjustable **Lighting** section in graphics settings.

### In-game settings
- Removed the Dark Mode toggle from both `SettingsModal` and
`UserSettingModal`, and `darkMode()`/`toggleDarkMode()`/`DARK_MODE_KEY`
from `UserSettings`.

### New Lighting section (Graphics Settings)
- **Ambient light** slider (1–3): mapped to the renderer's ambient as
`ambient = 1 / level`. **1.0 = no effect (unchanged look), 3.0 = darkest
with the strongest structure glow.**
- **Light falloff** slider (1–3): writes straight to
`lighting.falloffPower`.
- Lighting auto-enables only when ambient < 1, so the default (slider at
1) has zero GPU cost — off by default.

### Removed dark-mode overrides
- Deleted `applyDarkModeOverride()` + `DARK_AMBIENT` and their wiring in
`ClientGameRunner`, `gl/index.ts`, and the `DARK_MODE_KEY` listener.
- Removed the `.dark` HUD-class toggle in `Main.ts` and the
`userSettings.darkMode()` read in `PlayerIcons`.

### Train glow
- `UT_TRAIN` light reduced (intensity `2.0 → 0.5`, radius `8 → 6`) so
structures dominate the glow.

## Notes
- Removing the dark-mode setting also retires the HUD's Tailwind dark
theme (same setting). The dormant `dark:` CSS variants and unused
white-icon assets are left in place (out of scope).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:42:19 -07:00
evanpelle 44f5e14a0f Fix captured trade ships rendering yellow in alt-view
In alt-view, trade ships are colored by their owner's affiliation:
self green, ally yellow, enemy red. The FLAG_TRADE_FRIENDLY override
recolors an enemy ship red→yellow when it's heading to a self/allied
port. That flag was decided solely from the destination port's owner,
ignoring who owns the ship — so a captured trade ship (now owned by us,
heading to our port) got flagged yellow instead of keeping its green
affiliation color.

Gate FLAG_TRADE_FRIENDLY on the ship being enemy-owned, since self/allied
ships already render the correct color without the override. Also fixes
our own trade ships heading to an ally's port flipping green→yellow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:16:42 -07:00
Evan 8b9bda1c8b Add ocean color override to graphics settings (#4269)
## What

Adds a **Terrain** section to the graphics settings modal with a color
picker and a hex-code text field (paste a `#rrggbb` code) for the
**ocean** (deep water) color.

## Details

- The picked color sets the *shallow-water base*; the existing per-depth
brightness gradient is preserved (deeper water still darkens).
- Only deep water is affected — shoreline water and land are untouched.
- Follows the same override pattern as every other graphics setting: the
default lives in `render-settings.json` (`terrain.oceanColor`), the
override is a field in `GraphicsOverrides`, and `applyGraphicsOverrides`
copies it into the live `RenderSettings`.
- Rebased on #4271 (settings resolved before renderer construction): the
terrain texture **bakes the resolved ocean color at construction**, so a
saved override shows on load with no special-casing. Terrain is baked
into a GPU texture rather than read per-frame, so a *live* change still
triggers an explicit `view.rebuildTerrain()`.
- Resetting graphics overrides clears it back to the default ocean
color.

## Testing
Verified live in a headless singleplayer game:
- A **saved** ocean override renders green deep-water on load, baked at
construction with no settings-change event fired.
- A mid-game color change recolors the deep ocean instantly, gradient
preserved, shoreline/land untouched.

`tsc` and ESLint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:13:46 -07:00
Evan 54a7042303 Resolve render settings before renderer construction (#4271)
## What

The client now resolves render settings (defaults + user overrides) **up
front** and passes the result into the renderer, instead of the renderer
constructing defaults itself and the client re-applying overrides
afterward.

```
before:  new GPURenderer(...)         // this.settings = createRenderSettings()  (defaults)
         view.getSettings() → deepAssign(defaults) → applyGraphicsOverrides(...)  // patch after the fact

after:   const settings = createRenderSettings(); applyGraphicsOverrides(settings, ...); applyDarkModeOverride(settings, ...)
         new GPURenderer(..., settings)   // built with the final values
```

## Why

- **Removes the construct-with-defaults / re-apply-overrides dance.**
Every pass — including texture-baking ones like terrain that read their
settings *once* at build time rather than every frame — is now built
with the final values on the first try. (This is the cleanup that
motivated the change, surfaced while adding a terrain color override in
a separate PR.)
- **Fixes a latent context-restore bug.** On WebGL context loss/restore
the renderer was rebuilt via `createRenderSettings()` → fresh
**defaults**, silently dropping any user overrides until the next
settings change. `MapRenderer` now holds the resolved settings object
and hands the same one to the recreated `GPURenderer`, so overrides
survive a restore.

Live setting changes still work exactly as before:
`regenerateRenderSettings()` re-resolves and `deepAssign`s onto the
renderer's live settings object in place (passes hold a reference, so
they pick it up next frame).

## Changes
- `Renderer.ts` (`GPURenderer`) — constructor takes a `settings:
RenderSettings`; drops the internal `createRenderSettings()` call.
- `MapRenderer.ts` — holds the resolved settings and passes it through
on construction and on context-restore re-init.
- `ClientGameRunner.ts` — new `resolveRenderSettings()` helper used both
at construction and by `regenerateRenderSettings()`; `createWebGLView`
takes the resolved settings; the now-redundant initial
`regenerateRenderSettings()` call is removed.

## Testing
Verified live in a headless singleplayer game:
- A saved `nameScaleFactor` override is present in `getSettings()`
immediately after game start, with no settings-change event fired
(construction path).
- A mid-game override change is reflected in the live settings
(regenerate/in-place path).
- The map renders correctly through spawn phase.

`tsc` and ESLint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:22:08 -07:00
Evan 3a8249dfd1 Add structure icon size graphics override (#4270)
## Summary

Adds a new **Structure icon size** option to `GraphicsOverrides`,
exposed as a slider in the Graphics Settings modal. Players can now
scale how large structure icons are drawn on the map.

## Changes

- **`GraphicsOverrides.ts`** — add `iconSize: z.number()` to the
`structure` override schema.
- **`RenderOverrides.ts`** — apply the override onto
`settings.structure.iconSize` (consumed by
`StructurePass`/`StructureLevelPass` shaders).
- **`GraphicsSettingsModal.ts`** — add a slider (range 20–120, step 5)
in the "Structure Icons" section, with getter/handler following the
existing pattern. Falls back to the `render-settings.json` default of 60
when unset.
- **`resources/lang/en.json`** — add `icon_size_label` /
`icon_size_desc` (English only, per i18n rules).
- **`tests/GraphicsOverrides.test.ts`** — schema-validation cases plus
application tests (override sets the value; absence keeps the default).

The setting persists via the existing `userSettings.graphicsOverrides()`
localStorage flow and takes effect live through the existing
`regenerateRenderSettings` wiring.

## Testing

- `npx vitest tests/GraphicsOverrides.test.ts --run` — 35 passed
- `tsc --noEmit` — no new type errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:09:59 -07:00
Evan f76f133589 Structure level numbers: classic bitmap font by default + graphics toggle (#4264)
## What

Structure **level numbers** now render in the **`round_6x6_modified`**
bitmap font by default (matching the old PIXI-based `StructureLayer` /
`v31`), with a graphics setting to switch back to the smooth
`overpass-bold` MSDF font.

Two commits:

1. **Default to the classic bitmap font** — `StructureLevelPass` drew
level digits from the `overpass-bold` MSDF atlas (the one `NamePass`
uses for player names); switch the default to the `round_6x6_modified`
pixel font (white digits with a baked-in dark outline).
2. **Add a runtime toggle** — load both fonts and switch between them
live via a new `Classic level numbers` graphics setting.

## How

- `StructureLevelPass` loads both atlases up front and selects one per
frame from `settings.structureLevel.classicFont`, re-laying-out the
digits when the toggle flips (digit advances differ between the fonts).
The fragment shader is a single program with a `uClassic` branch: direct
bitmap sample (white fill + baked outline) vs. MSDF median + synthesized
outline.
- New override `structure.classicNumbers` in `GraphicsOverrides`
(default `true` = classic), applied onto
`settings.structureLevel.classicFont` in `applyGraphicsOverrides` — so
it switches live, like the existing colorblind/classic-icons toggles.
- `GraphicsSettingsModal` gets a `Classic level numbers` toggle next to
`Classic icons` (with `en.json` strings).

## Testing

- `tsc --noEmit`, ESLint, Prettier, and `npm run build-prod` all pass.
- Ran the game headless, built/upgraded cities to level 2–3, and
confirmed: the classic toggle renders the pixel font, flipping it
renders the smooth MSDF font, and flipping back restores the pixel font
— switching live with no shader errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:07:17 -07:00
Evan f4db4a33c8 Send nukes as motion plans and render them smoothly per frame (#4255)
## Summary

Follow-up to #4244's payload work: nukes were the last per-tick movers
flooding the worker → main update stream.

- **Core**: nuke trajectories are fully determined at launch
(precomputed parabola), so `NukeExecution` now records a `GridPathPlan`
when the nuke is built — same mechanism trade ships use — and the client
derives the position each tick. Per-tick `UnitUpdate`s for nukes in
flight are suppressed; only targetable flips and deletion
(interception/detonation) still emit. This covers atom bombs, hydrogen
bombs, and MIRV warheads (dozens of per-tick movers per MIRV
separation).
- The plan path replays a separate pathfinder rather than reusing the
stored trajectory array: the curve's cached points don't advance exactly
one index per tick, and the plan must match the movement pathfinder's
exact per-tick tile sequence.
  - `startTick` accounts for MIRV warheads' staggered `waitTicks`.
- **Render**: `UnitPass.drawMissiles` now lerps each nuke's instance
position `lastPos→pos` by wall-clock progress through the current tick,
so nukes glide along their arc at render framerate instead of jumping
once per 100ms tick. Both endpoints are real simulated positions — the
rendered nuke trails the sim by at most one tick and settles exactly on
it when ticks stop. Plan-driven units sync `lastPos` on path-stall ticks
so the lerp never replays a segment. Shells keep their existing
two-instance trail; SAM missiles are unchanged.

## Test plan

- New `tests/nukes/NukeMotionPlan.test.ts`: tick-exact alignment between
the recorded plan and core nuke position over the whole flight
(mirroring `GameView.advanceMotionPlannedUnits` math), `waitTicks`
offset, and that no per-tick unit updates are emitted in flight except
targetable flips and deletion.
- Full suite passes (1452 + 65), tsc/eslint/prettier clean.
- Verified in-game (headless Chromium, real WebGL): atom bomb arcs from
silo to target with the client position driven by the plan, missile
sprite renders intact while the smoothing rewrites the instance buffer
every frame, detonation FX land at the target.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:59:03 -07:00
evanpelle 49a12519d7 Tune structure rendering to match the previous version's look
Bring the WebGL structure renderer closer to the old canvas render:
larger icons, classic icon styling by default, and more prominent,
better-positioned level numbers.
2026-06-13 09:29:03 -07:00
Evan 7ec26df4b4 Fix three high-impact renderer performance issues (#4251)
## Summary

A performance review of `src/client/render/` found three issues where
per-tick work silently defeated existing optimizations. All three are
surgical fixes with no behavior change.

### 1. Relation matrix forced a full-map border recompute every tick

`buildRelationMatrix` ran unconditionally every tick and
`updateRelations` was pushed unconditionally, so every tick paid:
- a 1 MB `fill(0)` + rebuild on the CPU,
- a 1 MB `texSubImage2D` upload (~10 MB/s steady-state),
- a **full map-resolution border fragment pass** via `globalDirty` —
which also called `scatter.clear()`, making the incremental
`BorderScatterPass`/`patchTile` path dead code during live play.

Now the matrix is rebuilt and uploaded only when alliances/embargoes
actually change. `PlayerUpdate`s are delta-encoded (`diffPlayerUpdate`
content-compares `allies`/`embargoes`), so field presence is a reliable
change signal. The WebGL context-restore path force-pushes relations,
matching the existing structures/railroads pattern.

### 2. Heat decay pass + full-map blit ran every frame, forever

`HeatManager.decayHeat()` set `heatActive = true` on every tick
regardless of whether any fallout existed. With `heatDecayPerTick: 1`
the drain window (255 ticks) was always re-armed before expiring, so the
map-sized decay/transition fragment pass **plus a full-map R16UI
`blitFramebuffer`** ran at 60 Hz for the entire game — even if no nuke
was ever fired. On large maps this was likely the biggest fixed GPU cost
in the renderer.

Now `TerritoryPass` flags FALLOUT-bit flips at GPU-write time (delta,
drip-drain, and conservatively on full uploads), and the renderer
activates the heat pipeline only then. While inactive, `updateHeat()`
does no GL work at all. Skipping the prev-tile blit while inactive is
safe because the transition shader only reads the fallout bit, and every
fallout flip activates the pipeline before its tile flush reaches the
GPU.

### 3. `computePlayerStatus` was O(players × units) per tick

The per-player loop scanned **all units** looking for that player's
nukes (~1M+ iterations/tick at scale). Inverted to a single pass over
units building per-owner `nukeActive`/`nukeTargetsMe` sets, then O(1)
lookups in the player loop.

## Testing

- Full suite passes (1386 + 65 tests), including the 19 existing
`computePlayerStatus` behavior tests; `tsc --noEmit` and ESLint clean.
- Verified in a live singleplayer game (headless Chromium): territory
fill, borders, names/troop counts, and leaderboard all render correctly.
- Fallout path verified end-to-end: built a missile silo, launched an
atom bomb (1235 fallout tiles in tile state), and the fallout glow
rendered at the impact site — under the new gating that glow can only
appear if the `falloutTouched → activate()` chain works.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:06:53 -07:00
evanpelle 4149b3e4cb Pulse spawn ring white→team color for self in team games
In team games the local player's spawn breathing ring now pulses
white→own team color (matching teammates' rings) instead of
white→gold. Gold pulse is unchanged for teamless games (singleplayer/FFA).
Self ring stays larger than teammates' via existing self/mate radii.
2026-06-12 16:05:04 -07:00
evanpelle ac6d8d739a Make attack ring size tunable and increase it to 30px
The transport-target ring size was hardcoded as RING_SCREEN_PX in
attack-ring.vert.glsl. Promote it to a uRingScreenPx uniform fed from
a new fx.attackRingScreenPx entry in render-settings.json, with an
"Attack Ring Size (px)" slider in the debug GUI's FX folder.

Also bump the size from 20 to 30 screen px so the ring is easier
to spot. The inner/outer ring fractions (0.5/0.8 of the quad) stay
shader constants.
2026-06-12 15:41:03 -07:00
evanpelle 03b405eea7 Color nuke telegraph circles by launcher relation (self/ally/enemy)
The blast-radius warning circle was always red, so players couldn't
tell who launched an incoming nuke. Now it's green for your own
nukes, yellow for ally/teammate nukes, and red for everyone else's.

Each telegraph carries a relation (0=self, 1=friendly, 2=enemy),
classified from the per-tick relation matrix — the same friend/foe
logic alt-view uses — and passed to the shader as a per-instance
attribute. Replay/spectator mode (no local player) stays all red.
Colors are tunable via the nukeTelegraph slice in render-settings.json.
2026-06-12 15:32:25 -07:00
evanpelle 9e2648f80c increase nuke trajectory line width from 1.25=>2.5 so it's more visible 2026-06-12 15:05:06 -07:00
Evan 5648a37317 Classic icons: darken player color for icon glyph instead of black (#4246)
## Summary

The "classic icons" graphics setting currently renders structure icon
glyphs as flat black. In the v0.31 canvas renderer, classic icons used
`structureColors().dark` — a darkened version of the owning player's
territory color. This PR restores that look in the WebGL renderer.

- New `structure.iconDarken` render setting (HSV value multiplier on the
player fill color; `0` = off, default).
- New `uIconDarken` uniform in `structure.frag.glsl`: when > 0, the
glyph color is `darken(playerFill, uIconDarken)` instead of the flat
`uIconColor`.
- Classic mode (`classicIcons: true`) now sets `iconDarken = 0.45`
instead of `iconR/G/B = 0`. Border darken, fill, and the 0.75
translucency are unchanged.
- Default (non-classic) icons are unaffected (white glyph, `iconDarken =
0`).

Under-construction structures keep the gray fill, so their glyph darkens
to a darker gray — matching v31's construction styling.

## Verification

Drove a solo game headlessly with classic icons on and built structures:
glyphs render as darkened versions of each player's color (dark purple
on a purple player, per-bot hues on bot structures). Pixel-sampled the
screenshot: glyph measured `rgb(89,58,142)` vs `rgb(84,50,139)`
predicted for the 0.45-darkened player color at 0.75 alpha (flat black
would measure `rgb(38,26,60)`). Control run with classic off shows the
unchanged white glyph. `tests/GraphicsOverrides.test.ts` updated; all
pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:46:20 -07:00
Evan aa4b490e68 Simplify WebGL renderer integration: remove dead extension code, untangle GameView naming (#4240)
## Summary

The WebGL renderer was adapted from an external extension and carried a
lot of machinery this integration never uses (replay playback, its own
input/event system, a GL radial menu). This PR is two mechanical cleanup
passes with **no behavior change**: delete the dead code, then untangle
the `GameView` naming collision.

**78 files, +142 / −2,197.**

### Pass 1 — remove dead extension baggage

- **Replay/copy mode**: `FrameData.tileMode` was hard-coded `"live"`;
the copy branches in `frame/Upload.ts`, `UploadOptions` (never passed),
`applyFullFrame`/`applyFullTiles`/`applyDelta` on the facade and
`GPURenderer`, `HeatManager.resetForSeek`, and the seek-upload methods
on `TerritoryPass`/`TrailPass` were all unreachable. Also deletes
`types/Replay.ts`, `types/FrameSource.ts`, `types/GameUpdates.ts`,
`types/Game.ts` (imported only by the types barrel).
- **FrameEvents**: trimmed from 14 fields to the 3 actually populated
and read (`deadUnits`, `conquestEvents`, `bonusEvents`). The other 11
fed the extension's stats system and were never written or read here.
- **GL radial menu**: `RadialMenuPass`, its 4 shaders, and ~10 API
methods on facade + renderer had zero callers — the game uses the DOM/d3
radial menu in `hud/layers/RadialMenu.ts`. The pass was constructed and
drawn every frame for nothing.
- **Facade event system**: `GameViewEventMap` defined 10 event types
(`click`, `hover`, `scroll`, …) but only `contextrestored` was ever
emitted — input actually flows through `InputHandler` → EventBus →
controllers. Replaced the listener map with a single `onContextRestored`
callback and deleted `Events.ts`. Also fixed the stale header comment
claiming the facade handles user interaction.
- **Unused API surface**: removed ~20 facade/renderer methods with zero
callers (camera passthroughs like
`panTo`/`zoomTo`/`fitMap`/`screenToWorld`, hit-testing queries, SAM
replay setters, `setSelectedUnit`, `clearFx`/`setFxTimeFn`,
`onFrame`/`afterRender`/fps tracking).

Deliberately left alone: `Camera`'s pan/zoom primitives (building blocks
for a possible future camera unification) and the `timeFn` plumbing
inside the FX passes (deeply embedded as defaults; only the dead
renderer-level wrappers were removed).

### Pass 2 — untangle the three GameViews

- `render/gl/GameView.ts` → **`MapRenderer.ts`** (class `MapRenderer`).
Every importer was already aliasing it as `WebGLGameView` to dodge the
collision with the simulation-mirror `GameView` in `client/view/`, so
this removes aliasing rather than adding churn. `render/CLAUDE.md`
updated.
- Deleted the `src/core/game/GameView.ts` back-compat shim (its own TODO
asked for this). All 51 importers now import from `src/client/view/`
directly via a new 3-line barrel `view/index.ts`.

## Test plan

- `tsc --noEmit` clean, `eslint` clean
- Full test suite passes (1,385 + 65 server tests)
- Manual verification via headless Chromium: started a singleplayer game
and confirmed the renderer works end-to-end — terrain draws, spawn-phase
overlay shows, territories fill with borders after spawning, player
names/flags render, no renderer console errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:21:24 -07:00
FrederikJA b1e9955af3 Coordinate grid (#4224)
**Add approved & assigned issue number here:**

Resolves #3839

## Description:
A bunch of small updates to make the coordinate grid a lot nicer.
 - Removes black backgrounds on text.
 - Allows user to modify the opacity of the coordinate grid
 - Persist the enable state of the coordinate grid

### Before
<img width="2344" height="1168" alt="image"
src="https://github.com/user-attachments/assets/22c2fb77-9db6-41bf-a50a-987f651cc19a"
/>

### After
<img width="2331" height="1174" alt="image"
src="https://github.com/user-attachments/assets/0e5a9575-8a79-407b-8d78-8564df02b259"
/>

<img width="407" height="947" alt="image"
src="https://github.com/user-attachments/assets/b9e5f9f1-3cc1-4832-b7d4-38e1f5e93d57"
/>

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FrederikJA
2026-06-11 20:11:34 -07:00
Evan 94f2293149 Reduce main bundle size by ~44% gzipped (732 KB → 412 KB) (#4229)
## Summary

Cuts the main JS chunk from **2,891 KB (732 KB gzip)** to **1,679 KB
(412 KB gzip)** by fixing two bundling issues and removing/replacing
heavy dependencies. Measured with a per-module `renderedLength` analysis
of the rolldown output (its prod sourcemaps are malformed, so
sourcemap-based tools misattribute sizes).

| Chunk | Before | After |
|---|---|---|
| `index-*.js` (min) | 2,891 KB | 1,679 KB |
| `index-*.js` (gzip) | 732 KB | **412 KB** |

## Changes

- **Sim worker moved out of the main bundle (~512 KB).** The
`?worker&inline` payload is now reached through a dynamic `import()`, so
it lands in its own lazy chunk fetched when a game starts. The worker
itself still uses Vite's inline Blob mechanism (with its `data:` URL
fallback) — runtime instantiation is byte-for-byte unchanged.
- **Replaced `lit-markdown` with `marked` + the already-bundled
DOMPurify (~380 KB).** lit-markdown transitively pulled sanitize-html,
htmlparser2, postcss, and two copies of entities into the client just to
render news markdown. New `src/client/Markdown.ts` matches its
image-stripping default.
- **Dropped `colorjs.io` (~114 KB).** It was only used for ΔE2000
distance in `ColorAllocator`; colord's lab plugin (already imported
there) provides the same CIEDE2000 via `.delta()`. Only relative
magnitudes are compared, so allocation behavior is unchanged.
- **`msdf-atlas.json` (~319 KB) fetched at runtime** like the atlas PNG,
preloaded in parallel with worker init in `ClientGameRunner` so
game-load latency is unaffected.
- **Tailwind CSS no longer shipped twice (~158 KB).** `o-modal` imported
`styles.css?inline`, duplicating the emitted stylesheet as a JS string.
It now adopts a constructed stylesheet built from the document's own CSS
(HTTP-cache hit in prod, `<style>` tags + HMR re-sync in dev) via
`SharedStyles.ts`.
- **Debug GUI lazy-loaded.** lil-gui + `gl/debug/*` now load on first
toggle (46 KB lazy chunk) instead of shipping in the main bundle.

Also looked at the `import * as d3` in RadialMenu (~84 KB) but left it:
rolldown tree-shakes the metapackage well and all but ~2 KB is the
genuine dependency closure of the selection/transition/shape/color APIs
in use.

## Test plan

- [x] `tsc --noEmit` clean
- [x] ESLint clean
- [x] Full test suite passes (1,374 + 65 tests)
- [x] `npm run build-prod` succeeds; worker/debug chunks present in
`asset-manifest.json` for the R2 upload
- [ ] Manual smoke test in dev: start a game (worker dev path), open a
modal (shared stylesheet), open news modal (markdown rendering)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:07:16 -07:00
evanpelle 74fc239f96 Dim untargetable nukes so players can tell SAMs can't hit them
Nukes flying outside SAM-targetable range now render at reduced alpha
(unit.untargetableAlpha, default 0.6), including the hydrogen bomb's
glow halo. Adds a FLAG_FLICKER_UNTARGETABLE instance flag in UnitPass
driven by the existing UnitState.targetable field.

Also fixes the alt-view trade-friendly check to match its flag exactly,
so retreating warships (flag 4) no longer render ally-yellow in alt view.
2026-06-11 15:44:02 -07:00
evanpelle 03a5d691ee Add white glow behind hovered player's name
The hovered player (tile owner under the cursor, already tracked via
uHighlightOwnerID for cull bypass) now gets a soft white glow behind
their name. The glow is derived from the MSDF distance field: a white
band past the outline with quadratic falloff, composited behind the
glyph and clamped to the SDF margin so it never clips at quad edges.

Glow size and strength are tunable via hoverGlowWidth/hoverGlowAlpha
in render-settings.json, exposed as sliders in the graphics settings
modal (persisted as graphics overrides) and in the debug GUI.

Includes schema and apply tests for the new override fields,
covering the 0 edge case (0 disables the glow, not "unset").
2026-06-11 15:27:56 -07:00
evanpelle 2d747d0f8b Flash alliance icon when renewal prompt is active
When an alliance is within the renewal-prompt window, the alliance
icon above the player's name now pulses, ramping from 2 Hz to 5 Hz
as expiry approaches (same effect as the traitor flash).

The flash window is driven by allianceExtensionPromptOffset() — the
same Config value that triggers the "renew alliance" prompt in the
actionable events display — so the two always stay in sync.

The shader only knew the alliance fraction, not absolute time, so
computePlayerStatus now also emits allianceRemainingTicks, packed
into the free pd7.w slot of the player-data texture.
2026-06-11 14:41:46 -07:00
evanpelle 95d5a0439b Make SAM intercept X marker 5x larger
Bump markerXRadius from 8 to 40 px in render-settings.json so the
intercept point on nuke trajectories is easier to spot. The marker
shader draws in normalized quad space, so the stroke and outline
scale with it automatically. Also raise the debug GUI slider max
for the X marker from 16 to 64 to keep the new value tunable.
2026-06-11 14:12:12 -07:00
Evan 1db02acdc2 Move theme data into the render-settings JSON pipeline (#4223)
**Add approved & assigned issue number here:**

N/A — maintainer refactor.

## Description:

Replaces the theme class hierarchy
(`BaseTheme`/`PastelTheme`/`ColorblindTheme`) with theme JSON files —
`default-theme.json` and `colorblind-theme.json` — combined with
`render-settings.json` at runtime into a single graphics-configuration
pipeline (`settings.theme`). One `SettingsTheme` class keeps the
algorithms (color allocation, team-variation generation, LAB-contrast
structure colors) and reads all data from `ThemeSettings`; adding a
theme is now just adding a JSON file.

Colorblind mode (#4150) is fully preserved:

- Same palettes — the 32-color CVD-safe pool and Okabe-Ito team colors
are baked into `colorblind-theme.json`
- The relative border rule (`l × 0.6`) is expressed as a
`borderLightnessScale` knob alongside the default theme's absolute
`borderDarken`
- The mid-game re-theme wiring (`refreshPlayerColors`/`refreshPalette`)
and the affiliation/friend-foe tint overrides are unchanged;
`applyGraphicsOverrides` now also swaps the `settings.theme` slice
- `deepAssign` replaces arrays wholesale so differing palette lengths
survive theme switches

Verified against the previous implementation with an equivalence test
(since removed): default-theme colors are byte-identical including
allocation order; colorblind team/derived colors are byte-identical, and
FFA assignment may permute within the same palette (hex baking rounds
upstream's fractional-RGB colord objects, which can flip the allocator's
greedy delta-E ordering — rendered colors round identically either way).

Also removes dead theme surface (`terrainColor`, `backgroundColor`,
`falloutColor`, `font`, `textColor`, spawn-highlight variants,
`PastelThemeDark`) — GL terrain colors and dark mode were already
handled in the renderer. Note this means the colorblind terrain bands
from #4150 were dead code (nothing calls `terrainColor`; GL terrain
comes from `ColorUtils.encodeTerrainTile`); wiring CVD-safe terrain into
the terrain texture would be a follow-up.

## Please complete the following:

- [x] I have added screenshots for all UI updates — N/A, no UI changes
(verified color-identical)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — N/A, no user-visible text
- [x] I have added relevant tests to the test directory —
`tests/Colors.test.ts` updated for the new pipeline (team colors from
theme JSON, colorblind palette/border tests)

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evanpelle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:50:50 -07:00
noahschmal 21776e81af Feature/colorblind mode (#4150)
**Add approved & assigned issue number here:**

Resolves #2549

## Description:

Adds colorblind mode. Similar to dark mode, it exists as a toggle in
settings. When enabled, it swaps the game's theme (which is refactored
to extend from a theme base class) to use more colorblind-friendly
colors and brightness variations. Borders are darkened, and terrarin is
separated by lightness. Friendly/Foe colors and switched to blue/orange
instead of red/green.

The theme refactor supports adding new themes without having to
reimplement the color distribution system. New themes can extend the
BaseTheme and supply the data, such as palettes, team-color variations,
and terrain.

New setting:
<img width="880" height="273" alt="Screenshot 2026-06-04 at 11 30 27 AM"
src="https://github.com/user-attachments/assets/d5d573d5-cc64-4ac1-95c2-00627faf17cc"
/>

New color palette:
<img width="1119" height="757" alt="Screenshot 2026-06-04 at 11 30
59 AM"
src="https://github.com/user-attachments/assets/2bb15bc9-992b-41ae-ab0e-b01fe0c3c6bb"
/>

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

jetaviz
2026-06-11 10:53:03 -07:00
Evan 7137347b7d Fade player names under the cursor, with a graphics setting to tune it (#4221)
## Description:

Player name plates can block the view of what's underneath them
(structures, units, terrain). This PR fades the entire name plate —
name, troop count, flag, and emoji/status row — to 25% opacity while the
cursor is over it, so you can see and click what's behind it.

**How it works:**

- `HoverHighlightController` pushes the cursor's world position into the
renderer on mouse move.
- `NamePass` hit-tests the cursor against each player's name plate
bounds on the CPU (mirroring the lerp/sizing math in `name.vert.glsl`)
and passes the matched player's ID to the text, icon, and status-icon
programs, which apply the alpha multiplier in their shaders.

**Graphics setting:**

- New "Name opacity under cursor" slider in the Graphics Settings modal
(Name Labels section), range 0–1, default 0.25. Setting it to 1 disables
the fade entirely.
- Wired through the existing `GraphicsOverrides` pipeline: changes apply
live and are cleared by "Reset to defaults".
- Tuning knob exposed as `name.hoverFadeAlpha` in `render-settings.json`
and the debug GUI.

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-06-11 09:25:13 -07:00
evanpelle 9189aac687 Improve railroad visibility: own-rail contrast color and thickness setting
Local-player rails previously rendered in the white focused-border color
from the palette, making them hard to see on light territory. Rails now
use a dedicated local rail color: white normally, flipped to black when
the territory backdrop is too light for white to read against (patterns
average their primary/secondary brightness).

Also add a railThickness render setting (0.5-3, default 1), exposed in
the Graphics Settings modal and the debug GUI, and persisted via
GraphicsOverrides. In the medium-zoom LOD, rails are now drawn as
screen-space anti-aliased lines around each tile's rail centerline,
accumulated from the 3x3 neighborhood so thick lines spill cleanly into
neighboring tiles; detailed mode scales its sub-grid band widths.

- PlayerView: compute railColor() (white/black by backdrop brightness)
- RailroadPass/shader: uLocalPlayerID, uLocalRailColor, uRailThickness
- render-settings.json, RenderSettings, GraphicsOverrides,
  RenderOverrides: new railroad.railThickness knob
- GraphicsSettingsModal: "Train track thickness" slider (+ en.json keys)
- tests: schema + apply coverage for railroad overrides
2026-06-10 18:57:02 -07:00
evanpelle 000f1442c4 Pulse local spawn ring white→gold for visibility on any terrain
The local player's spawn ring was a static near-white tint, which could
wash out against light backgrounds. Drive the ring color from the
existing breath animation in SpawnOverlayPass so it pulses between
white and gold at 60fps — one end of the pulse always contrasts with
the terrain. Remove the now-unused hardcoded self tint from
WebGLFrameBuilder; the pass owns the self color now.
2026-06-10 16:54:05 -07:00
evanpelle 3552b08f7a Color player name labels by player type
Name text fill now darkens based on player type so human players stand
out from AI: human = black, nation = a bit gray, bot = greyer. Shades are
tunable in render-settings.json (nameShadeNation, nameShadeBot; human is
always 0).

Repurpose the previously-unused pd3.z slot (was isHuman, dead in the
fragment shader) to carry a per-player grayscale shade, and use it as the
name fill color directly so it applies in both day and night.
2026-06-09 19:58:46 -07:00
evanpelle b5840d7887 Fix troop count precision in name labels, throttle/stagger updates
Replace the hand-rolled formatTroops() in the name-pass with the canonical
renderTroops() so map name labels match troop precision used elsewhere in
the UI (Leaderboard, PlayerPanel, etc.).

Also refresh each player's troop string at most every 500ms instead of
every simulation tick, staggered by slot index so GPU string uploads
spread across the window rather than bursting on a single tick.
2026-06-09 19:35:32 -07:00
evanpelle 2d28d5463b Add territory saturation and opacity graphics settings
Expose two new user-configurable map-overlay controls in the graphics
settings modal: territory saturation (mutes fill colors toward grayscale)
and territory opacity (lets terrain show through the fill).

The territory fragment shader blends the fill toward its luminance based
on uSaturation and applies uTerritoryAlpha as the absolute fill opacity.
Both are wired through RenderSettings, the GraphicsOverrides schema,
applyGraphicsOverrides, the debug Layout sliders, and TerritoryPass
uniforms, with defaults (saturation 1, alpha 0.588) in render-settings.json.
Adds the corresponding en.json label/description strings.
2026-06-09 19:16:04 -07:00
evanpelle 65e99b25e7 Add retreating warship indicator and warship 2-color treatment
Warships now render with a dedicated center accent band so their state
reads at a glance:
- Normal: center + outer ring share the territory color (2-color look),
  hull uses the border color.
- Angry (attacking): outer ring and center turn red.
- Retreating to repair: the center blinks black.

The warship sprite center moved to its own gray value (100) in the unit
atlas so the shader can drive it via a new fourth replacement band, with
no per-unit-type branching — the missiles' shared 130 blend band is
untouched.

Warship repair-retreat (warshipState.state === "retreating") now feeds
the existing UnitState.retreating boolean in UnitView, which UnitPass
maps to a FLAG_RETREATING instance flag.
2026-06-08 17:32:21 -07:00
evanpelle 611560a0b2 Restyle spawn-phase self highlight: gold instead of white
The local player's spawn ring was plain white, which was hard to see
against white mountain terrain. Recolor it to a bright gold so it stays
visible regardless of the terrain underneath, and make it stand out more
overall during the spawn phase:
- Recolor the self ring from white to a bright gold tint
- Keep the ring center transparent so your territory shows through,
  ramping up to solid at the inner edge
- Raise the breathing opacity floor (35% -> 65%) so the ring stays more
  solid through the dim part of the pulse
- Speed up the breathing animation and enlarge the self ring radii
2026-06-08 16:04:28 -07:00
evanpelle 99a20ac032 Shrink warship shell sprite to a single pixel
The shell (unit-atlas.png col 7) was a centered 3×3 white square, so it
rendered as a 3×3-world-tile block. Replace it with a single centered
white pixel so shells render as one pixel, matching the original
pixel-shell look. The atlas is sampled with NEAREST and unitSize is 13
(1 atlas px ≈ 1 world tile), so the lone pixel stays crisp.

Update the UnitPass header comments that described the shell as 3×3.
2026-06-08 14:31:02 -07:00
evanpelle ea95069604 Move special effects toggle to graphics settings, wire to renderer
The special effects toggle wrote settings.specialEffects but nothing in
the WebGL pipeline read it — the FX pass is gated on passEnabled.fx. The
setting was orphaned when the old canvas renderer was removed, so the
toggle had no visual effect.

Move the toggle into the graphics settings modal (under a new Effects
section) and remove it from the in-game settings modal and the homepage
user settings modal. Rewire it to a passEnabled.fx graphics override so
it actually toggles the FX pass, applied live via the existing graphics
override listener.

Delete the now-dead fxLayer()/toggleFxLayer() from UserSettings.

Note: users who previously disabled special effects will reset to on,
since the old settings.specialEffects key is no longer read.
2026-06-08 14:15:07 -07:00