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>
This commit is contained in:
Evan
2026-06-12 14:21:24 -07:00
committed by GitHub
parent aa22339f96
commit aa4b490e68
79 changed files with 142 additions and 2197 deletions
-98
View File
@@ -1,98 +0,0 @@
import type { UnitState } from "../types";
/** Event data emitted by GameView for map interactions. */
export interface MapPointerEvent {
/** CSS pixel X relative to viewport (clientX). */
screenX: number;
/** CSS pixel Y relative to viewport (clientY). */
screenY: number;
/** World-space X (fractional; floor for tile column). */
worldX: number;
/** World-space Y (fractional; floor for tile row). */
worldY: number;
/** Tile column (integer, -1 if out of bounds). */
tileX: number;
/** Tile row (integer, -1 if out of bounds). */
tileY: number;
/** Territory owner at this tile (0 = unowned/OOB). */
ownerID: number;
/** Nearest mobile unit under cursor, or null. */
unit: UnitState | null;
/** Nearest structure under cursor, or null. */
structure: UnitState | null;
/** Mouse button: 0 = left, 1 = middle, 2 = right. */
button: number;
/** Shift key held. */
shiftKey: boolean;
/** Ctrl/Meta key held. */
ctrlKey: boolean;
/** Alt key held. */
altKey: boolean;
}
/** Scroll event data emitted by GameView. */
export interface MapScrollEvent {
deltaX: number;
deltaY: number;
shiftKey: boolean;
ctrlKey: boolean;
altKey: boolean;
}
/** Alt-view temporarily peeked (space hold — enables altview + gridview). */
export interface AltViewPeekEvent {
active: boolean;
}
/** Grid-view default toggled (persistent resting state changed via 'M'). */
export interface GridViewToggleEvent {
active: boolean;
}
/** Map of event names to their payload types. */
export interface GameViewEventMap {
/** Left-click (pointerdown + pointerup with < 10px movement). */
click: MapPointerEvent;
/** Double-click. */
dblclick: MapPointerEvent;
/** Middle-click (auxclick with button 1). */
middleclick: MapPointerEvent;
/** Right-click / context menu. */
contextmenu: MapPointerEvent;
/** Hovered entity changed (owner, unit, or structure differs from previous). */
hover: MapPointerEvent;
/** Scroll with modifier keys (unmodified scroll is consumed by zoom). */
scroll: MapScrollEvent;
/** User selected a radial menu item. */
menuselect: RadialMenuSelectEvent;
/** Alt-view temporarily peeked (space hold — enables altview + gridview). */
altviewpeek: AltViewPeekEvent;
/** Grid-view default toggled (M key). */
gridviewtoggle: GridViewToggleEvent;
/** WebGL Context successfully restored after a loss. (Requires full state re-upload) */
contextrestored: { type: "restored" };
}
/** A single item in the radial context menu. */
export interface RadialMenuItem {
/** Unique identifier for this action. */
id: string;
/** Emoji key into the atlas (e.g. "⚔️"), or empty string for no icon. */
icon: string;
/** RGB color [01]. */
color: [number, number, number];
/** Whether this action is currently available. */
enabled: boolean;
/** If present, clicking this item opens a submenu with these items. */
subItems?: RadialMenuItem[];
}
/** Emitted when the user selects a radial menu item. */
export interface RadialMenuSelectEvent {
/** Index of the selected segment. */
index: number;
/** The item's id. */
id: string;
}
export type GameViewEventType = keyof GameViewEventMap;
@@ -1,11 +1,14 @@
/**
* GameView public facade for the openfront-gl renderer.
* MapRenderer public facade for the WebGL map renderer.
*
* Wraps GPURenderer (rendering) and Camera (viewport math) as private
* implementation details. Handles all user interaction: drag-to-pan,
* wheel-to-zoom, click detection, hover tracking, and hit-testing.
* Wraps GPURenderer as a private implementation detail and survives WebGL
* context loss: when the context is lost the renderer is disposed, and on
* restore a fresh GPURenderer is created and `onContextRestored` fires so
* the owner can re-upload all simulation state.
*
* Consumers only touch GameView they never import GPURenderer or Camera.
* This is a pure data sink. Input handling lives in InputHandler/EventBus;
* camera state is pushed in each frame via setCameraState. Consumers only
* touch MapRenderer they never import GPURenderer or Camera.
*/
import type { Config } from "../../../core/configuration/Config";
@@ -25,27 +28,21 @@ import type {
TilePair,
UnitState,
} from "../types";
import type {
GameViewEventMap,
GameViewEventType,
RadialMenuItem,
} from "./Events";
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
import type { AttackTroopLabel } from "./passes/WorldTextPass";
import { GPURenderer } from "./Renderer";
import type { RenderSettings } from "./RenderSettings";
export class GameView {
export class MapRenderer {
private renderer: GPURenderer | null = null;
private resizeObs: ResizeObserver | null = null;
private listeners = new Map<string, Set<(e: unknown) => void>>();
private cachedIcons: { key: string; img: CanvasImageSource }[] = [];
// Stored for context recreation
private cachedOnFrame: ((ms: number) => void) | null = null;
private cachedAfterRender: ((canvas: HTMLCanvasElement) => void) | null =
null;
/**
* Called after a lost WebGL context is restored and the renderer has been
* recreated. The owner must re-upload all simulation state (textures and
* geometry are gone).
*/
onContextRestored: (() => void) | null = null;
constructor(
private canvas: HTMLCanvasElement,
@@ -66,10 +63,10 @@ export class GameView {
});
this.resizeObs.observe(canvas);
canvas.addEventListener("webglcontextlost", this.onContextLost, false);
canvas.addEventListener("webglcontextlost", this.handleContextLost, false);
canvas.addEventListener(
"webglcontextrestored",
this.onContextRestored,
this.handleContextRestored,
false,
);
}
@@ -85,18 +82,11 @@ export class GameView {
this.caf,
);
// Restore cached state
if (this.cachedIcons.length > 0) {
this.renderer.registerRadialMenuIcons(this.cachedIcons);
}
this.renderer.onFrame = this.cachedOnFrame;
this.renderer.afterRender = this.cachedAfterRender;
const rect = this.canvas.getBoundingClientRect();
if (rect.width > 0) this.renderer.resize(rect.width, rect.height);
};
private onContextLost = (e: Event) => {
private handleContextLost = (e: Event) => {
e.preventDefault();
if (this.renderer) {
this.renderer.dispose();
@@ -104,141 +94,19 @@ export class GameView {
}
};
private onContextRestored = () => {
private handleContextRestored = () => {
this.initRenderer();
this.emit("contextrestored", { type: "restored" });
this.onContextRestored?.();
};
// ---- Event system ----
on<K extends GameViewEventType>(
event: K,
handler: (e: GameViewEventMap[K]) => void,
): void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(handler as (e: unknown) => void);
}
off<K extends GameViewEventType>(
event: K,
handler: (e: GameViewEventMap[K]) => void,
): void {
this.listeners.get(event)?.delete(handler as (e: unknown) => void);
}
private emit<K extends GameViewEventType>(
event: K,
data: GameViewEventMap[K],
): void {
const set = this.listeners.get(event);
if (set)
for (const fn of set) (fn as (e: GameViewEventMap[K]) => void)(data);
}
// ---- Radial menu ----
showRadialMenu(
screenX: number,
screenY: number,
items: RadialMenuItem[],
centerItem?: RadialMenuItem,
): void {
this.renderer?.showRadialMenu(screenX, screenY, items, centerItem);
}
hideRadialMenu(): void {
this.renderer?.hideRadialMenu();
}
openRadialSubMenu(subItems: RadialMenuItem[]): void {
this.renderer?.openRadialSubMenu(subItems);
}
goBackRadialMenu(): void {
this.renderer?.goBackRadialMenu();
}
get radialMenuVisible(): boolean {
return this.renderer?.radialMenuVisible ?? false;
}
registerRadialMenuIcons(
icons: { key: string; img: CanvasImageSource }[],
): void {
this.cachedIcons = icons;
this.renderer?.registerRadialMenuIcons(icons);
}
// ---- Camera ----
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
return this.renderer?.screenToWorld(screenX, screenY) ?? { x: 0, y: 0 };
}
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
return this.renderer?.worldToScreen(worldX, worldY) ?? { x: 0, y: 0 };
}
panTo(worldX: number, worldY: number): void {
this.renderer?.panTo(worldX, worldY);
}
zoomTo(level: number): void {
this.renderer?.zoomTo(level);
}
fitMap(): void {
this.renderer?.fitMap();
}
focusOwner(ownerID: number): void {
this.renderer?.focusOwner(ownerID);
}
focusBBox(
minX: number,
minY: number,
maxX: number,
maxY: number,
padding?: number,
): void {
this.renderer?.focusBBox(minX, minY, maxX, maxY, padding);
}
getCameraState(): { x: number; y: number; z: number } {
return this.renderer?.getCameraState() ?? { x: 0, y: 0, z: 1 };
}
setCameraState(x: number, y: number, z: number): void {
this.renderer?.setCameraState(x, y, z);
}
getOwnerAtWorld(worldX: number, worldY: number): number {
return this.renderer?.getOwnerAtWorld(worldX, worldY) ?? 0;
}
// ---- Data upload ----
applyFullFrame(
tileState: Uint16Array,
trailState: Uint8Array,
nukeEvents?: Array<{ tick: number; tiles: number[] }>,
currentTick?: number,
): void {
this.renderer?.applyFullFrame(
tileState,
trailState,
nukeEvents,
currentTick,
);
}
applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void {
this.renderer?.applyFullTiles(tileState, trailState);
}
applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void {
this.renderer?.applyDelta(changedTiles, trailState);
}
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
this.renderer?.uploadLiveDelta(tileState, changedTiles);
}
@@ -318,12 +186,6 @@ export class GameView {
updateAttackRings(rings: AttackRingInput[]): void {
this.renderer?.updateAttackRings(rings);
}
clearFx(): void {
this.renderer?.clearFx();
}
setFxTimeFn(fn: () => number): void {
this.renderer?.setFxTimeFn(fn);
}
/** Update ghost structure preview (build-mode visualization). null = clear. */
updateGhostPreview(data: GhostPreviewData | null): void {
@@ -349,11 +211,6 @@ export class GameView {
// ---- Selection box ----
/** Show/hide the stippled selection box around a unit (warship selection). */
setSelectedUnit(unitId: number | null): void {
this.renderer?.setSelectedUnit(unitId);
}
/** Set multiple selected units (multi-select). Pass [] to clear. */
setSelectedUnits(unitIds: readonly number[]): void {
this.renderer?.setSelectedUnits(unitIds);
@@ -364,17 +221,8 @@ export class GameView {
this.renderer?.showMoveIndicator(tileX, tileY, ownerID);
}
// ---- SAM radius (replay) ----
// ---- SAM radius ----
setSAMRadiusVisible(visible: boolean): void {
this.renderer?.setSAMRadiusVisible(visible);
}
setSAMPerspective(playerID: number, allies: Set<number>): void {
this.renderer?.setSAMPerspective(playerID, allies);
}
setSAMColorMode(mode: "perspective" | "owner"): void {
this.renderer?.setSAMColorMode(mode);
}
setSAMAllianceClusters(clusters: Map<number, number>): void {
this.renderer?.setSAMAllianceClusters(clusters);
}
@@ -409,29 +257,18 @@ export class GameView {
getSettings(): RenderSettings {
return this.renderer?.getSettings() ?? ({} as RenderSettings);
}
get fps(): number {
return this.renderer?.fps ?? 0;
}
set onFrame(cb: ((ms: number) => void) | null) {
this.cachedOnFrame = cb;
if (this.renderer) this.renderer.onFrame = cb;
}
set afterRender(cb: ((canvas: HTMLCanvasElement) => void) | null) {
this.cachedAfterRender = cb;
if (this.renderer) this.renderer.afterRender = cb;
}
// ---- Lifecycle ----
dispose(): void {
this.resizeObs?.disconnect();
this.resizeObs = null;
this.listeners.clear();
this.onContextRestored = null;
this.renderer?.dispose();
this.canvas.removeEventListener("webglcontextlost", this.onContextLost);
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
this.canvas.removeEventListener(
"webglcontextrestored",
this.onContextRestored,
this.handleContextRestored,
);
}
}
+1 -229
View File
@@ -27,7 +27,6 @@ import type {
UnitState,
} from "../types";
import { Camera } from "./Camera";
import type { RadialMenuItem } from "./Events";
import { BarPass } from "./passes/BarPass";
import { BorderComputePass } from "./passes/BorderComputePass";
import { BorderStampPass } from "./passes/BorderStampPass";
@@ -44,7 +43,6 @@ import { NightCompositePass } from "./passes/NightCompositePass";
import { NukeTelegraphPass } from "./passes/NukeTelegraphPass";
import { NukeTrajectoryPass } from "./passes/NukeTrajectoryPass";
import { PointLightPass } from "./passes/PointLightPass";
import { RadialMenuPass } from "./passes/RadialMenuPass";
import { RailroadPass } from "./passes/RailroadPass";
import { RangeCirclePass } from "./passes/RangeCirclePass";
import { SAMRadiusPass } from "./passes/SamRadiusPass";
@@ -121,7 +119,6 @@ export class GPURenderer {
private railroadPass: RailroadPass;
private barPass: BarPass;
private worldTextPass: WorldTextPass;
private radialMenuPass: RadialMenuPass;
private selectionBoxPass: SelectionBoxPass;
private moveIndicatorPass: MoveIndicatorPass;
private nukeTrajectoryPass: NukeTrajectoryPass;
@@ -154,15 +151,7 @@ export class GPURenderer {
private mapW = 0;
private mapH = 0;
// FPS tracking
private frameTimes: Float64Array = new Float64Array(60);
private frameIdx = 0;
private frameCount = 0;
fps = 0;
onFrame: ((ms: number) => void) | null = null;
afterRender: ((canvas: HTMLCanvasElement) => void) | null = null;
// Hit-testing references
// Last-uploaded unit/structure maps (selection box + bar pass inputs)
private lastUnits: Map<number, UnitState> = new Map();
private lastStructures: Map<number, UnitState> = new Map();
@@ -479,7 +468,6 @@ export class GPURenderer {
this.barPass = new BarPass(gl, header, this.settings, config);
this.worldTextPass = new WorldTextPass(gl, this.settings, config);
this.worldTextPass.setMapWidth(this.mapW);
this.radialMenuPass = new RadialMenuPass(gl);
this.selectionBoxPass = new SelectionBoxPass(gl);
this.moveIndicatorPass = new MoveIndicatorPass(gl, this.settings);
this.nukeTrajectoryPass = new NukeTrajectoryPass(gl, this.settings);
@@ -571,80 +559,14 @@ export class GPURenderer {
this.camera.resize(cssWidth, cssHeight);
}
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
return this.camera.screenToWorld(screenX, screenY);
}
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
return this.camera.worldToScreen(worldX, worldY);
}
panTo(worldX: number, worldY: number): void {
this.camera.panTo(worldX, worldY);
}
panBy(dx: number, dy: number): void {
this.camera.panBy(dx, dy);
}
zoomTo(level: number): void {
this.camera.zoomTo(level);
}
zoomBy(factor: number): void {
this.camera.zoomBy(factor);
}
zoomAtScreen(factor: number, screenX: number, screenY: number): void {
this.camera.zoomAtScreen(factor, screenX, screenY);
}
fitMap(): void {
this.camera.fitMap();
}
focusBBox(
minX: number,
minY: number,
maxX: number,
maxY: number,
padding?: number,
): void {
this.camera.focusBBox(minX, minY, maxX, maxY, padding);
}
getCameraState(): { x: number; y: number; z: number } {
return {
x: this.camera.offsetX,
y: this.camera.offsetY,
z: this.camera.zoom,
};
}
setCameraState(x: number, y: number, z: number): void {
this.camera.setCameraState(x, y, z);
}
get zoom(): number {
return this.camera.zoom;
}
// ---------------------------------------------------------------------------
// Data upload
// ---------------------------------------------------------------------------
applyFullFrame(
tileState: Uint16Array,
trailState: Uint8Array,
nukeEvents?: Array<{ tick: number; tiles: number[] }>,
currentTick?: number,
): void {
this.territoryPass.uploadFullTileState(tileState);
this.trailPass.uploadFullState(trailState);
this.heatManager.resetForSeek(tileState, nukeEvents, currentTick);
}
applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void {
this.territoryPass.uploadFullTileState(tileState);
this.trailPass.uploadFullState(trailState);
}
applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void {
this.territoryPass.uploadDeltaTiles(changedTiles);
this.trailPass.uploadFullState(trailState);
}
uploadTileAndTrailState(
tileState: Uint16Array,
trailState: Uint8Array,
@@ -912,15 +834,6 @@ export class GPURenderer {
this.fxPass.updateAttackRings(rings);
}
clearFx(): void {
this.fxPass.clear();
this.worldTextPass.clear();
}
setFxTimeFn(fn: () => number): void {
this.fxPass.setTimeFn(fn);
this.worldTextPass.setTimeFn(fn);
}
updateGhostPreview(data: GhostPreviewData | null): void {
this.structurePass.updateGhostPreview(data);
this.railroadPass.updateGhostPreview(data);
@@ -980,64 +893,6 @@ export class GPURenderer {
);
}
focusOwner(ownerID: number): void {
if (ownerID !== 0) {
const bbox = this.territoryPass.getBBoxForOwner(ownerID);
if (bbox) {
this.camera.focusBBox(bbox.minX, bbox.minY, bbox.maxX, bbox.maxY);
return;
}
}
this.camera.focusBBox(0, 0, this.mapW - 1, this.mapH - 1);
}
getOwnerAtWorld(worldX: number, worldY: number): number {
const tx = Math.floor(worldX);
const ty = Math.floor(worldY);
if (tx < 0 || ty < 0 || tx >= this.mapW || ty >= this.mapH) return 0;
return this.territoryPass.getOwnerAt(ty * this.mapW + tx);
}
getUnitAtWorld(
worldX: number,
worldY: number,
radius: number,
): UnitState | null {
let best: UnitState | null = null;
let bestDist = radius * radius;
const w = this.mapW;
for (const u of this.lastUnits.values()) {
const dx = (u.pos % w) - worldX;
const dy = Math.floor(u.pos / w) - worldY;
const d2 = dx * dx + dy * dy;
if (d2 < bestDist) {
bestDist = d2;
best = u;
}
}
return best;
}
getStructureAtWorld(
worldX: number,
worldY: number,
radius: number,
): UnitState | null {
let best: UnitState | null = null;
let bestDist = radius * radius;
const w = this.mapW;
for (const s of this.lastStructures.values()) {
const dx = (s.pos % w) - worldX;
const dy = Math.floor(s.pos / w) - worldY;
const d2 = dx * dx + dy * dy;
if (d2 < bestDist) {
bestDist = d2;
best = s;
}
}
return best;
}
setLocalPlayerID(id: number): void {
if (id === this.localPlayerID) return;
this.localPlayerID = id;
@@ -1051,21 +906,6 @@ export class GPURenderer {
this.railroadPass.setLocalRailColor(r, g, b);
}
setSAMRadiusVisible(visible: boolean): void {
this.samRadiusPass.setVisible(visible);
}
setSAMPerspective(playerID: number, allies: Set<number>): void {
this.samRadiusPass.setLocalPlayer(playerID);
this.samRadiusPass.setAllies(allies);
this.unitPass.setLocalPlayer(playerID);
this.unitPass.setAllies(allies);
}
setSAMColorMode(mode: "perspective" | "owner"): void {
this.samRadiusPass.setColorMode(mode);
}
setSAMAllianceClusters(clusters: Map<number, number>): void {
this.samRadiusPass.setAllianceClusters(clusters);
}
@@ -1096,57 +936,10 @@ export class GPURenderer {
return this.settings;
}
// ---------------------------------------------------------------------------
// Radial menu
// ---------------------------------------------------------------------------
showRadialMenu(
anchorX: number,
anchorY: number,
items: RadialMenuItem[],
centerItem?: RadialMenuItem,
): void {
this.radialMenuPass.show(anchorX, anchorY, items, centerItem);
}
hideRadialMenu(): void {
this.radialMenuPass.hide();
}
openRadialSubMenu(subItems: RadialMenuItem[]): void {
this.radialMenuPass.openSubMenu(subItems);
}
goBackRadialMenu(): void {
this.radialMenuPass.goBack();
}
setRadialMenuHover(index: number): void {
this.radialMenuPass.setHover(index);
}
radialMenuHitTest(screenX: number, screenY: number): number {
return this.radialMenuPass.hitTest(screenX, screenY);
}
get radialMenuVisible(): boolean {
return this.radialMenuPass.isVisible;
}
getRadialMenuItems(): readonly RadialMenuItem[] {
return this.radialMenuPass.getItems();
}
getRadialMenuItemAt(index: number): RadialMenuItem | null {
return this.radialMenuPass.getItemAt(index);
}
registerRadialMenuIcons(
icons: { key: string; img: CanvasImageSource }[],
): void {
this.radialMenuPass.registerIcons(icons);
}
// ---------------------------------------------------------------------------
// Selection box (warship selection)
// ---------------------------------------------------------------------------
setSelectedUnit(unitId: number | null): void {
this.setSelectedUnits(unitId === null ? [] : [unitId]);
}
setSelectedUnits(unitIds: readonly number[]): void {
// Copy in (callers may mutate their array).
this.selectedUnitIds.length = 0;
@@ -1222,27 +1015,9 @@ export class GPURenderer {
// ---------------------------------------------------------------------------
draw(): void {
const now = performance.now();
this.trackFps(now);
this.uploadTextures();
this.computeTextures();
this.renderFrame();
if (this.onFrame) this.onFrame(performance.now() - now);
if (this.afterRender) this.afterRender(this.canvas);
}
private trackFps(now: number): void {
this.frameTimes[this.frameIdx] = now;
this.frameIdx = (this.frameIdx + 1) % this.frameTimes.length;
if (this.frameCount < this.frameTimes.length) this.frameCount++;
if (this.frameCount > 1) {
const oldest =
this.frameTimes[
(this.frameIdx - this.frameCount + this.frameTimes.length) %
this.frameTimes.length
];
this.fps = (this.frameCount - 1) / ((now - oldest) / 1000);
}
}
private uploadTextures(): void {
@@ -1369,8 +1144,6 @@ export class GPURenderer {
this.worldTextPass.tick(zoom);
this.worldTextPass.draw(cam, zoom);
this.radialMenuPass.draw();
gl.disable(gl.BLEND);
}
@@ -1405,7 +1178,6 @@ export class GPURenderer {
this.namePass.dispose();
this.fxPass.dispose();
this.worldTextPass.dispose();
this.radialMenuPass.dispose();
this.selectionBoxPass.dispose();
this.moveIndicatorPass.dispose();
this.nukeTrajectoryPass.dispose();
+1 -9
View File
@@ -1,17 +1,9 @@
export type { AttackRingInput } from "../types";
// createDebugGui is intentionally not re-exported here — it pulls lil-gui and
// the debug GUI into the main bundle; dynamically import "./debug/index".
export type {
GameViewEventMap,
GameViewEventType,
MapPointerEvent,
MapScrollEvent,
RadialMenuItem,
RadialMenuSelectEvent,
} from "./Events";
export { GameView } from "./GameView";
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
export type { GraphicsOverrides } from "./GraphicsOverrides";
export { MapRenderer } from "./MapRenderer";
export { preloadAtlasData } from "./passes/name-pass/AtlasData";
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
export {
@@ -1,577 +0,0 @@
/**
* RadialMenuPass — renders a radial (pie-wheel) context menu as screen-space
* arc segments with emoji icons.
*
* Supports one level of submenus: when a submenu is open, the parent items
* shrink into a smaller inner ring, a back button appears in the center, and
* the submenu items take the outer ring.
*
* Rendering elements (reused for each ring via drawRing):
* 1. Arcs: single quad with SDF annulus + angular segment masking + borders
* 2. Center button: filled circle drawn by the innermost ring
* 3. Icons: instanced quads sampling the emoji atlas
*/
import type { RadialMenuItem } from "../Events";
import { createProgram } from "../utils/GlUtils";
import arcFragSrc from "../shaders/radial-menu/arcs.frag.glsl?raw";
import arcVertSrc from "../shaders/radial-menu/arcs.vert.glsl?raw";
import iconFragSrc from "../shaders/radial-menu/icon.frag.glsl?raw";
import iconVertSrc from "../shaders/radial-menu/icon.vert.glsl?raw";
import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json";
import { assetUrl } from "src/core/AssetUrls";
const emojiAtlasUrl = assetUrl("atlases/emoji-atlas.png");
// ---------------------------------------------------------------------------
// Ring layout configs (CSS pixels)
// ---------------------------------------------------------------------------
interface RingConfig {
outerR: number;
innerR: number;
/** Icon half-size; if a function, receives the segment count. */
iconHalf: number | ((n: number) => number);
/** Opacity multiplier applied to colors (1 = full, <1 = dimmed). */
dim: number;
}
/** Normal top-level ring (game: innerRadius 40, arcWidth 55). */
const RING_NORMAL: RingConfig = {
outerR: 95,
innerR: 40,
iconHalf: (n) => (n <= 4 ? 20 : n <= 6 ? 17 : 14),
dim: 1.0,
};
/** Submenu active ring (game: innerRadius 75, arcWidth 65). */
const RING_SUBMENU: RingConfig = {
outerR: 140,
innerR: 75,
iconHalf: (n) => (n <= 4 ? 22 : n <= 6 ? 18 : 14),
dim: 1.0,
};
/** Parent ring when submenu is open (game: scales to 0.65). */
const RING_PARENT: RingConfig = {
outerR: 70,
innerR: 32,
iconHalf: 12,
dim: 0.5,
};
const MAX_SEGMENTS = 8;
/** Hit-test return value for the center button. */
export const CENTER_INDEX = -2;
const BACK_ITEM: RadialMenuItem = {
id: "__back__",
icon: "back-icon",
color: [0.45, 0.45, 0.45],
enabled: true,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildEmojiMap(): Map<string, number> {
const map = new Map<string, number>();
const emojis = (emojiAtlasMeta as { emojis: Record<string, number> }).emojis;
for (const [key, idx] of Object.entries(emojis)) {
map.set(key, idx);
}
return map;
}
// ---------------------------------------------------------------------------
// RadialMenuPass
// ---------------------------------------------------------------------------
export class RadialMenuPass {
private gl: WebGL2RenderingContext;
// Programs
private arcProg: WebGLProgram;
private iconProg: WebGLProgram;
private vao: WebGLVertexArrayObject;
// Arc uniform locations
private arcU: {
anchor: WebGLUniformLocation;
viewport: WebGLUniformLocation;
outerR: WebGLUniformLocation;
innerR: WebGLUniformLocation;
segCount: WebGLUniformLocation;
hoveredSeg: WebGLUniformLocation;
segColors: WebGLUniformLocation;
hasCenterBtn: WebGLUniformLocation;
centerColor: WebGLUniformLocation;
centerHovered: WebGLUniformLocation;
};
// Icon uniform locations
private iconU: {
anchor: WebGLUniformLocation;
viewport: WebGLUniformLocation;
outerR: WebGLUniformLocation;
innerR: WebGLUniformLocation;
segCount: WebGLUniformLocation;
iconHalf: WebGLUniformLocation;
emojiIndices: WebGLUniformLocation;
centerEmojiIdx: WebGLUniformLocation;
segOpacity: WebGLUniformLocation;
emojiAtlas: WebGLUniformLocation;
emojiCell: WebGLUniformLocation;
emojiCols: WebGLUniformLocation;
emojiAtlasW: WebGLUniformLocation;
emojiAtlasH: WebGLUniformLocation;
};
// Emoji + icon atlas
private emojiTex: WebGLTexture | null = null;
private emojiReady = false;
private emojiMap: Map<string, number>;
private atlasImg: HTMLImageElement | null = null;
private pendingIcons: { key: string; img: CanvasImageSource }[] = [];
// ---- State ----
private visible = false;
private anchorX = 0;
private anchorY = 0;
private items: RadialMenuItem[] = [];
private centerItem: RadialMenuItem | null = null;
private hoveredIndex = -1; // -1 = none, 0..n-1 = segment, CENTER_INDEX = center
// Submenu (one level)
private _inSubmenu = false;
private savedItems: RadialMenuItem[] = [];
private savedCenterItem: RadialMenuItem | null = null;
constructor(gl: WebGL2RenderingContext) {
this.gl = gl;
this.emojiMap = buildEmojiMap();
// Shared quad VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
// Arc program
this.arcProg = createProgram(gl, arcVertSrc, arcFragSrc);
this.arcU = {
anchor: gl.getUniformLocation(this.arcProg, "uAnchor")!,
viewport: gl.getUniformLocation(this.arcProg, "uViewport")!,
outerR: gl.getUniformLocation(this.arcProg, "uOuterR")!,
innerR: gl.getUniformLocation(this.arcProg, "uInnerR")!,
segCount: gl.getUniformLocation(this.arcProg, "uSegCount")!,
hoveredSeg: gl.getUniformLocation(this.arcProg, "uHoveredSeg")!,
segColors: gl.getUniformLocation(this.arcProg, "uSegColors")!,
hasCenterBtn: gl.getUniformLocation(this.arcProg, "uHasCenterBtn")!,
centerColor: gl.getUniformLocation(this.arcProg, "uCenterColor")!,
centerHovered: gl.getUniformLocation(this.arcProg, "uCenterHovered")!,
};
// Icon program
this.iconProg = createProgram(gl, iconVertSrc, iconFragSrc);
gl.useProgram(this.iconProg);
gl.uniform1i(gl.getUniformLocation(this.iconProg, "uEmojiAtlas"), 0);
const em = emojiAtlasMeta as {
width: number;
height: number;
cellSize: number;
cols: number;
};
gl.uniform1f(
gl.getUniformLocation(this.iconProg, "uEmojiCell")!,
em.cellSize,
);
gl.uniform1f(gl.getUniformLocation(this.iconProg, "uEmojiCols")!, em.cols);
gl.uniform1f(
gl.getUniformLocation(this.iconProg, "uEmojiAtlasW")!,
em.width,
);
gl.uniform1f(
gl.getUniformLocation(this.iconProg, "uEmojiAtlasH")!,
em.height,
);
this.iconU = {
anchor: gl.getUniformLocation(this.iconProg, "uAnchor")!,
viewport: gl.getUniformLocation(this.iconProg, "uViewport")!,
outerR: gl.getUniformLocation(this.iconProg, "uOuterR")!,
innerR: gl.getUniformLocation(this.iconProg, "uInnerR")!,
segCount: gl.getUniformLocation(this.iconProg, "uSegCount")!,
iconHalf: gl.getUniformLocation(this.iconProg, "uIconHalf")!,
emojiIndices: gl.getUniformLocation(this.iconProg, "uEmojiIndices")!,
centerEmojiIdx: gl.getUniformLocation(this.iconProg, "uCenterEmojiIdx")!,
segOpacity: gl.getUniformLocation(this.iconProg, "uSegOpacity")!,
emojiAtlas: gl.getUniformLocation(this.iconProg, "uEmojiAtlas")!,
emojiCell: gl.getUniformLocation(this.iconProg, "uEmojiCell")!,
emojiCols: gl.getUniformLocation(this.iconProg, "uEmojiCols")!,
emojiAtlasW: gl.getUniformLocation(this.iconProg, "uEmojiAtlasW")!,
emojiAtlasH: gl.getUniformLocation(this.iconProg, "uEmojiAtlasH")!,
};
this.loadEmojiAtlas();
}
private loadEmojiAtlas(): void {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
this.atlasImg = img;
this.rebuildAtlasTexture();
};
img.src = emojiAtlasUrl;
}
/**
* Register additional icon images to append to the atlas texture.
* Call from the adapter after loading game SVG icons.
*/
registerIcons(icons: { key: string; img: CanvasImageSource }[]): void {
this.pendingIcons = icons;
if (this.atlasImg) this.rebuildAtlasTexture();
}
private rebuildAtlasTexture(): void {
if (!this.atlasImg) return;
const gl = this.gl;
const meta = emojiAtlasMeta as {
width: number;
height: number;
cellSize: number;
cols: number;
emojis: Record<string, number>;
};
const baseCount = Object.keys(meta.emojis).length;
const totalCount = baseCount + this.pendingIcons.length;
const rows = Math.ceil(totalCount / meta.cols);
const height = Math.max(meta.height, rows * meta.cellSize);
const canvas = document.createElement("canvas");
canvas.width = meta.width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
// Draw existing emoji atlas
ctx.drawImage(this.atlasImg, 0, 0);
// Append extra icons into new cells (preserving aspect ratio)
// Minimal padding — SVGs are already clean vectors, maximize resolution
const pad = Math.floor(meta.cellSize * 0.04);
const size = meta.cellSize - pad * 2;
for (let i = 0; i < this.pendingIcons.length; i++) {
const idx = baseCount + i;
const col = idx % meta.cols;
const row = Math.floor(idx / meta.cols);
const img = this.pendingIcons[i].img;
const nw = (img as HTMLImageElement).naturalWidth || size;
const nh = (img as HTMLImageElement).naturalHeight || size;
const aspect = nw / nh;
let dw = size,
dh = size;
if (aspect > 1) dh = size / aspect;
else dw = size * aspect;
const ox = (size - dw) / 2;
const oy = (size - dh) / 2;
ctx.drawImage(
img,
col * meta.cellSize + pad + ox,
row * meta.cellSize + pad + oy,
dw,
dh,
);
this.emojiMap.set(this.pendingIcons[i].key, idx);
}
// Upload texture
this.emojiTex ??= gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, this.emojiTex);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.generateMipmap(gl.TEXTURE_2D);
this.emojiReady = true;
// Update atlas height uniform (texture may be taller now)
gl.useProgram(this.iconProg);
gl.uniform1f(this.iconU.emojiAtlasH, height);
}
resolveEmoji(icon: string): number {
return this.emojiMap.get(icon) ?? -1;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
show(
anchorX: number,
anchorY: number,
items: RadialMenuItem[],
centerItem?: RadialMenuItem,
): void {
this.visible = true;
this.anchorX = anchorX;
this.anchorY = anchorY;
this.items = items.slice(0, MAX_SEGMENTS);
this.centerItem = centerItem ?? null;
// Cursor is at the anchor — center button starts hovered
this.hoveredIndex = this.centerItem ? CENTER_INDEX : -1;
this._inSubmenu = false;
this.savedItems = [];
this.savedCenterItem = null;
}
openSubMenu(subItems: RadialMenuItem[]): void {
this.savedItems = this.items;
this.savedCenterItem = this.centerItem;
this.items = subItems.slice(0, MAX_SEGMENTS);
this.centerItem = BACK_ITEM;
this._inSubmenu = true;
this.hoveredIndex = -1;
}
goBack(): void {
if (!this._inSubmenu) return;
this.items = this.savedItems;
this.centerItem = this.savedCenterItem;
this._inSubmenu = false;
this.savedItems = [];
this.savedCenterItem = null;
this.hoveredIndex = -1;
}
hide(): void {
this.visible = false;
this.hoveredIndex = -1;
this._inSubmenu = false;
this.savedItems = [];
this.savedCenterItem = null;
}
setHover(index: number): void {
this.hoveredIndex = index;
}
get isVisible(): boolean {
return this.visible;
}
get inSubmenu(): boolean {
return this._inSubmenu;
}
getItems(): readonly RadialMenuItem[] {
return this.items;
}
getCenterItem(): RadialMenuItem | null {
return this.centerItem;
}
/** Look up an item by hit-test index. */
getItemAt(index: number): RadialMenuItem | null {
if (index === CENTER_INDEX) return this.centerItem;
if (index >= 0 && index < this.items.length) return this.items[index];
return null;
}
// ---------------------------------------------------------------------------
// Hit testing
// ---------------------------------------------------------------------------
hitTest(screenX: number, screenY: number): number {
if (!this.visible) return -1;
const dx = screenX - this.anchorX;
const dy = screenY - this.anchorY;
const dist = Math.sqrt(dx * dx + dy * dy);
const active = this._inSubmenu ? RING_SUBMENU : RING_NORMAL;
const centerR = this._inSubmenu ? RING_PARENT.innerR : RING_NORMAL.innerR;
const ringInner = active.innerR;
const ringOuter = active.outerR;
// Center button
if (dist < centerR) return this.centerItem ? CENTER_INDEX : -1;
// Gap / parent ring zone (non-interactive)
if (dist < ringInner) return -1;
// Active ring
if (dist > ringOuter || this.items.length === 0) return -1;
let angle = Math.atan2(dx, -dy); // 0 = top, CW positive
if (angle < 0) angle += Math.PI * 2;
const n = this.items.length;
const segArc = (Math.PI * 2) / n;
// Rotate so first segment is centered at top (game: startAngle = -π/n)
const shifted = (angle + Math.PI / n) % (Math.PI * 2);
return Math.min(Math.floor(shifted / segArc), n - 1);
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
draw(): void {
if (!this.visible) return;
if (this.items.length === 0 && !this.centerItem) return;
const gl = this.gl;
const dpr = window.devicePixelRatio || 1;
const vw = gl.drawingBufferWidth;
const vh = gl.drawingBufferHeight;
const ax = this.anchorX * dpr;
const ay = this.anchorY * dpr;
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.bindVertexArray(this.vao);
// Parent ring (dimmed, non-interactive) — drawn first so active ring overlays
if (this._inSubmenu && this.savedItems.length > 0) {
const p = RING_PARENT;
this.drawRing(
ax,
ay,
vw,
vh,
p,
this.savedItems,
-1,
BACK_ITEM,
this.hoveredIndex === CENTER_INDEX,
);
}
// Active ring — expands when in submenu
const active = this._inSubmenu ? RING_SUBMENU : RING_NORMAL;
this.drawRing(
ax,
ay,
vw,
vh,
active,
this.items,
this.hoveredIndex >= 0 ? this.hoveredIndex : -1,
this._inSubmenu ? null : this.centerItem,
!this._inSubmenu && this.hoveredIndex === CENTER_INDEX,
);
}
/** Draw a single ring (arcs + icons) using a RingConfig. */
private drawRing(
ax: number,
ay: number,
vw: number,
vh: number,
cfg: RingConfig,
items: readonly RadialMenuItem[],
hoveredSeg: number,
centerItem: RadialMenuItem | null,
centerHovered: boolean,
): void {
const gl = this.gl;
const dpr = window.devicePixelRatio || 1;
const n = items.length;
const hasCenter = centerItem !== null;
const outerR = cfg.outerR * dpr;
const innerFrac = cfg.innerR / cfg.outerR;
const dim = cfg.dim;
const ih =
typeof cfg.iconHalf === "function" ? cfg.iconHalf(n) : cfg.iconHalf;
const iconHalf = ih * dpr;
// --- Arcs ---
gl.useProgram(this.arcProg);
gl.uniform2f(this.arcU.anchor, ax, ay);
gl.uniform2f(this.arcU.viewport, vw, vh);
gl.uniform1f(this.arcU.outerR, outerR);
gl.uniform1f(this.arcU.innerR, innerFrac);
gl.uniform1i(this.arcU.segCount, n);
gl.uniform1i(this.arcU.hoveredSeg, hoveredSeg);
gl.uniform1i(this.arcU.hasCenterBtn, hasCenter ? 1 : 0);
if (hasCenter) {
const cc = centerItem.color;
gl.uniform3f(
this.arcU.centerColor,
cc[0] * dim,
cc[1] * dim,
cc[2] * dim,
);
gl.uniform1i(this.arcU.centerHovered, centerHovered ? 1 : 0);
}
const colors = new Float32Array(MAX_SEGMENTS * 4);
for (let i = 0; i < n; i++) {
const c = items[i].color;
colors[i * 4 + 0] = c[0] * dim;
colors[i * 4 + 1] = c[1] * dim;
colors[i * 4 + 2] = c[2] * dim;
colors[i * 4 + 3] = items[i].enabled ? 1 : 0;
}
gl.uniform4fv(this.arcU.segColors, colors);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// --- Icons ---
if (!this.emojiReady || (n === 0 && !hasCenter)) return;
gl.useProgram(this.iconProg);
gl.uniform2f(this.iconU.anchor, ax, ay);
gl.uniform2f(this.iconU.viewport, vw, vh);
gl.uniform1f(this.iconU.outerR, outerR);
gl.uniform1f(this.iconU.innerR, innerFrac);
gl.uniform1i(this.iconU.segCount, n);
gl.uniform1f(this.iconU.iconHalf, iconHalf);
const indices = new Float32Array(MAX_SEGMENTS);
const opacities = new Float32Array(MAX_SEGMENTS);
indices.fill(-1);
opacities.fill(1);
for (let i = 0; i < n; i++) {
indices[i] = this.resolveEmoji(items[i].icon);
opacities[i] = items[i].enabled ? 1.0 : 0.3;
}
gl.uniform1fv(this.iconU.emojiIndices, indices);
gl.uniform1fv(this.iconU.segOpacity, opacities);
const centerIdx = hasCenter ? this.resolveEmoji(centerItem.icon) : -1;
gl.uniform1f(this.iconU.centerEmojiIdx, centerIdx);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.emojiTex!);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, n + 1);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.arcProg);
gl.deleteProgram(this.iconProg);
gl.deleteVertexArray(this.vao);
if (this.emojiTex) gl.deleteTexture(this.emojiTex);
}
}
+1 -66
View File
@@ -16,7 +16,7 @@ import type { TilePair } from "../../types";
import type { RenderSettings } from "../RenderSettings";
import { getPaletteSize } from "../utils/ColorUtils";
import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils";
import { OWNER_MASK, TILE_DEFINES } from "../utils/TileCodec";
import { TILE_DEFINES } from "../utils/TileCodec";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
import territoryFragSrc from "../shaders/map-overlay/territory.frag.glsl?raw";
@@ -193,15 +193,6 @@ export class TerritoryPass {
// Tile data upload
// ---------------------------------------------------------------------------
/** Full tile state upload (on seek). */
uploadFullTileState(tileState: Uint16Array): void {
this.cpuTileState.set(tileState);
this.clearDripBuckets();
this.scatter.clear();
this.fullUploadPending = true;
this.tilesDirty = true;
}
/** Live-game path: snapshot the initial tile state and clear pending drip. */
setLiveRef(tileState: Uint16Array): void {
this.cpuTileState.set(tileState);
@@ -221,25 +212,6 @@ export class TerritoryPass {
this.borderPatchConsumer = fn;
}
/** Apply tile deltas (during playback). */
uploadDeltaTiles(changedTiles: TilePair[]): void {
const ts = this.cpuTileState;
const w = this.mapW;
const pending = this.fullUploadPending;
const borderFn = this.borderPatchConsumer;
for (let i = 0; i < changedTiles.length; i++) {
const tp = changedTiles[i];
ts[tp.ref] = tp.state;
if (!pending) {
const x = tp.ref % w;
const y = (tp.ref - x) / w;
this.scatter.push(x, y, tp.state);
if (borderFn) borderFn(x, y);
}
}
this.tilesDirty = true;
}
/**
* Live delta: dispatch each changed tile into a round-robin drip bucket.
* Stable per-ref hash means repeated updates to the same tile stay in
@@ -317,43 +289,6 @@ export class TerritoryPass {
this.currentBucket = 0;
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/**
* Get ownerID at a tile reference. Returns 0 for unowned.
* Reads display state (post-drip), so queries match what's visible.
*/
getOwnerAt(tileRef: number): number {
const ts = this.cpuTileState;
if (tileRef < 0 || tileRef >= ts.length) return 0;
return ts[tileRef] & OWNER_MASK;
}
/** AABB of all tiles owned by ownerID. */
getBBoxForOwner(
ownerID: number,
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const w = this.mapW;
const ts = this.cpuTileState;
for (let i = 0; i < ts.length; i++) {
if ((ts[i] & OWNER_MASK) === ownerID) {
const x = i % w;
const y = (i - x) / w;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
return minX === Infinity ? null : { minX, minY, maxX, maxY };
}
// ---------------------------------------------------------------------------
// GPU flush + draw
// ---------------------------------------------------------------------------
-19
View File
@@ -115,25 +115,6 @@ export class TrailPass {
this.trailsDirty = true;
}
/** Full trail state upload (on seek). */
uploadFullState(trailState: Uint8Array): void {
this.liveTrailRef = null;
this.cpuTrailState.set(trailState);
this.trailsDirty = true;
}
/** Set a single trail tile (during playback advance). */
setTile(ref: number, ownerID: number): void {
this.cpuTrailState[ref] = ownerID;
this.trailsDirty = true;
}
/** Clear all trails (on seek before rebuilding). */
clear(): void {
this.cpuTrailState.fill(0);
this.trailsDirty = true;
}
/** Flush trail texture to GPU. Called once per render frame in uploadTextures. */
flushTexture(): void {
if (!this.trailsDirty) return;
@@ -1,132 +0,0 @@
#version 300 es
precision highp float;
in vec2 vLocal; // [-1, +1], distance 1.0 = outerR
uniform float uInnerR; // inner radius as fraction of outerR [0,1]
uniform int uSegCount; // number of segments (1..8)
uniform int uHoveredSeg; // hovered segment index (-1 = none)
uniform vec4 uSegColors[8]; // per-segment: rgb + enabled (a: 1 = enabled, 0 = disabled)
// Center button
uniform int uHasCenterBtn; // 1 = show center button
uniform vec3 uCenterColor; // center button RGB
uniform int uCenterHovered; // 1 = center button hovered
out vec4 fragColor;
const float GAP = 0.03; // radians gap between segments (game: padAngle 0.03)
const float AA = 0.010; // anti-alias width (normalized coords)
const float BORDER_W = 0.024; // border width, non-hovered
const float BORDER_W_HOV = 0.034; // border width, hovered (thicker)
const float PI = 3.14159265359;
const float TWO_PI = 6.28318530718;
void main() {
float dist = length(vLocal);
// --- Center button zone ---
if (dist < uInnerR - AA) {
if (uHasCenterBtn == 0) discard;
// Solid center fill — fade alpha only at outer edge
float centerAlpha = 1.0 - smoothstep(uInnerR - AA * 3.0, uInnerR - AA, dist);
bool cHov = uCenterHovered > 0;
float cbw = cHov ? BORDER_W_HOV : BORDER_W;
vec3 cbCol = cHov ? vec3(1.0) : vec3(0.88);
// Crisp border at outer edge of center circle
float borderDist = uInnerR - AA - dist;
float border = 1.0 - smoothstep(cbw - AA, cbw + AA, borderDist);
vec3 color = uCenterColor;
if (cHov) color = mix(color, vec3(1.0), 0.2);
color = mix(color, cbCol, border);
float cAlpha = cHov ? 0.92 : 0.6;
fragColor = vec4(color, cAlpha * centerAlpha);
return;
}
// --- Ring zone ---
if (uSegCount == 0) discard; // center-only mode
// Annulus mask
float outer = 1.0 - smoothstep(1.0 - AA, 1.0, dist);
float inner = smoothstep(uInnerR - AA, uInnerR + AA, dist);
float ring = outer * inner;
if (ring < 0.01) discard;
// Angle: 0 at top, increasing clockwise [0, 2π]
float angle = atan(vLocal.x, -vLocal.y);
if (angle < 0.0) angle += TWO_PI;
// Rotate so first segment is centered at top (game: startAngle = -π/n)
float segArc = TWO_PI / float(uSegCount);
float offset = PI / float(uSegCount);
float shifted = mod(angle + offset, TWO_PI);
// Segment index (in rotated space)
int segIdx = int(floor(shifted / segArc));
segIdx = min(segIdx, uSegCount - 1);
// Gap mask between segments
float segStart = float(segIdx) * segArc;
float segEnd = segStart + segArc;
float halfGap = GAP * 0.5;
float gap = 1.0;
if (uSegCount > 1) {
gap = smoothstep(segStart + halfGap - AA, segStart + halfGap + AA, shifted)
* (1.0 - smoothstep(segEnd - halfGap - AA, segEnd - halfGap + AA, shifted));
}
float alpha = ring * gap;
if (alpha < 0.01) discard;
// Segment color + hover state
vec4 seg = uSegColors[segIdx];
vec3 color = seg.rgb;
bool enabled = seg.a > 0.5;
bool hovered = (segIdx == uHoveredSeg && enabled);
// Pick border width & color based on hover
float bw = hovered ? BORDER_W_HOV : BORDER_W;
vec3 borderCol = hovered ? vec3(1.0) : vec3(0.88);
// --- Borders ---
// Outer edge
float outerBorder = 1.0 - smoothstep(bw - AA, bw + AA, 1.0 - dist);
// Inner edge
float innerBorder = 1.0 - smoothstep(bw - AA, bw + AA, dist - uInnerR);
// Radial lines at gap edges
float angBorder = 0.0;
if (uSegCount > 1) {
float angleInSeg = shifted - segStart;
float distToStart = angleInSeg - halfGap;
float distToEnd = (segArc - halfGap) - angleInSeg;
// Convert angular distance to approximate normalized arc-length
float nearestAng = min(distToStart, distToEnd) * dist;
angBorder = 1.0 - smoothstep(bw - AA, bw + AA, nearestAng);
}
float border = max(max(outerBorder, innerBorder), angBorder);
// Disabled segments: desaturate + darken
if (!enabled) {
float lum = dot(color, vec3(0.3, 0.6, 0.1));
color = vec3(lum) * 0.4;
}
// Hover highlight: brighten fill
if (hovered) {
color = mix(color, vec3(1.0), 0.2);
}
// Blend border on top
color = mix(color, borderCol, border);
// Opacity: hovered → nearly opaque, default → slightly transparent, disabled → dim
float segAlpha = enabled ? (hovered ? 0.92 : 0.6) : 0.4;
fragColor = vec4(color, alpha * segAlpha);
}
@@ -1,24 +0,0 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos; // [0,1] quad
uniform vec2 uAnchor; // anchor in device pixels
uniform float uOuterR; // outer radius in device pixels
uniform vec2 uViewport; // drawingBuffer width, height
out vec2 vLocal; // [-1, +1] square pixel-space
void main() {
vLocal = aPos * 2.0 - 1.0;
// Expand quad to [-outerR, +outerR] in device pixels around anchor
vec2 pos = uAnchor + vLocal * uOuterR;
// Device pixels → NDC
gl_Position = vec4(
pos.x / uViewport.x * 2.0 - 1.0,
1.0 - pos.y / uViewport.y * 2.0,
0.0, 1.0
);
}
@@ -1,27 +0,0 @@
#version 300 es
precision highp float;
in vec2 vUV;
flat in float vAtlasIdx;
flat in float vOpacity;
uniform sampler2D uEmojiAtlas;
uniform float uEmojiCell;
uniform float uEmojiCols;
uniform float uEmojiAtlasW;
uniform float uEmojiAtlasH;
out vec4 fragColor;
void main() {
if (vAtlasIdx < 0.0) discard;
float col = mod(vAtlasIdx, uEmojiCols);
float row = floor(vAtlasIdx / uEmojiCols);
vec2 cellOrigin = vec2(col * uEmojiCell / uEmojiAtlasW, row * uEmojiCell / uEmojiAtlasH);
vec2 cellSize = vec2(uEmojiCell / uEmojiAtlasW, uEmojiCell / uEmojiAtlasH);
vec4 texel = texture(uEmojiAtlas, cellOrigin + vUV * cellSize);
fragColor = vec4(texel.rgb, texel.a * vOpacity);
}
@@ -1,74 +0,0 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos; // [0,1] quad
uniform vec2 uAnchor; // anchor in device pixels
uniform float uOuterR; // outer radius in device pixels
uniform float uInnerR; // inner radius as fraction of outerR [0,1]
uniform vec2 uViewport; // drawingBuffer width, height
uniform int uSegCount; // number of segments
uniform float uIconHalf; // icon half-size in device pixels
uniform float uEmojiIndices[8]; // atlas index per segment (-1 = none)
uniform float uCenterEmojiIdx; // atlas index for center icon (-1 = none)
uniform float uSegOpacity[8]; // per-segment opacity (0..1)
out vec2 vUV;
flat out float vAtlasIdx;
flat out float vOpacity;
const float PI = 3.14159265359;
const float TWO_PI = 6.28318530718;
void main() {
int segIdx = gl_InstanceID;
// Center icon: last instance (index == uSegCount)
if (segIdx == uSegCount) {
vAtlasIdx = uCenterEmojiIdx;
vOpacity = 1.0; // center icon always full opacity
if (vAtlasIdx < 0.0) {
gl_Position = vec4(2.0, 2.0, 0.0, 1.0);
vUV = vec2(0.0);
return;
}
// Position at anchor center — always upright
vec2 local = aPos * 2.0 - 1.0;
vec2 pos = uAnchor + local * uIconHalf;
gl_Position = vec4(
pos.x / uViewport.x * 2.0 - 1.0,
1.0 - pos.y / uViewport.y * 2.0,
0.0, 1.0
);
vUV = aPos;
return;
}
vAtlasIdx = uEmojiIndices[segIdx];
vOpacity = uSegOpacity[segIdx];
if (vAtlasIdx < 0.0 || segIdx >= uSegCount) {
gl_Position = vec4(2.0, 2.0, 0.0, 1.0);
vUV = vec2(0.0);
return;
}
// Arc center position — rotated so first segment is centered at top
float segArc = TWO_PI / float(uSegCount);
float offset = PI / float(uSegCount);
float angle = (float(segIdx) + 0.5) * segArc - offset;
float midR = (uInnerR + 1.0) * 0.5 * uOuterR;
vec2 center = uAnchor + vec2(sin(angle), -cos(angle)) * midR;
// Quad corners — always axis-aligned (upright icons)
vec2 local = aPos * 2.0 - 1.0;
vec2 pos = center + local * uIconHalf;
gl_Position = vec4(
pos.x / uViewport.x * 2.0 - 1.0,
1.0 - pos.y / uViewport.y * 2.0,
0.0, 1.0
);
vUV = aPos;
}
+5 -74
View File
@@ -15,7 +15,7 @@ import {
createTexture2D,
shaderSrc,
} from "./GlUtils";
import { FALLOUT_BIT, TILE_DEFINES } from "./TileCodec";
import { TILE_DEFINES } from "./TileCodec";
import heatDecayFragSrc from "../shaders/fallout-bloom/heat-decay.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
@@ -39,12 +39,11 @@ export class HeatManager {
private prevTileTex: WebGLTexture;
private prevTileFbo: WebGLFramebuffer;
private tileTexReadFbo: WebGLFramebuffer;
/** True on first frame and after seek — blit tileTex→prevTileTex without transitions. */
/** True on first frame — blit tileTex→prevTileTex without transitions. */
private needsPrevTileCopy = true;
// Pending CPU → GPU writes
private pendingDecay = 0;
private pendingFullHeat: Uint8Array | null = null;
/**
* True when heat may be non-zero anywhere — gates the decay pass.
* Set true on each game tick (shader may detect new fallout transitions).
@@ -157,25 +156,7 @@ export class HeatManager {
const mw = this.mapW;
const mh = this.mapH;
// 1. Upload reconstructed heat on seek
if (this.pendingFullHeat) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.heatReadTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
mw,
mh,
gl.RED,
gl.UNSIGNED_BYTE,
this.pendingFullHeat,
);
this.pendingFullHeat = null;
}
// 2. First frame / seek: copy tileTex → prevTileTex, skip transitions
// 1. First frame: copy tileTex → prevTileTex, skip transitions
if (this.needsPrevTileCopy) {
this.blitTileToPrev();
this.needsPrevTileCopy = false;
@@ -183,7 +164,7 @@ export class HeatManager {
return;
}
// 3. Skip decay pass when nothing to do — no pending decay and heat already settled.
// 2. Skip decay pass when nothing to do — no pending decay and heat already settled.
// Still blit tileTex→prevTileTex when a tick fired (pendingDecay > 0) so transition
// detection stays accurate if heat activates later.
if (!this.heatActive && this.pendingDecay === 0) return;
@@ -195,7 +176,7 @@ export class HeatManager {
return;
}
// 4. Combined transition detection + decay (GPU ping-pong)
// 3. Combined transition detection + decay (GPU ping-pong)
gl.bindFramebuffer(gl.FRAMEBUFFER, this.heatWriteFbo);
gl.viewport(0, 0, mw, mh);
gl.disable(gl.BLEND);
@@ -242,30 +223,6 @@ export class HeatManager {
);
}
/**
* Reset heat state on seek. Reconstructs heat from nuke history and
* masks out recaptured tiles.
*/
resetForSeek(
tileState: Uint16Array,
nukeEvents?: Array<{ tick: number; tiles: number[] }>,
currentTick?: number,
): void {
let hasHeat = false;
if (nukeEvents && nukeEvents.length > 0 && currentTick !== undefined) {
const heat = this.reconstructHeat(nukeEvents, currentTick);
this.maskHeat(heat, tileState);
this.pendingFullHeat = heat;
hasHeat = heat.some((v) => v > 0);
} else {
this.pendingFullHeat = new Uint8Array(this.mapW * this.mapH);
}
this.pendingDecay = 0;
this.decayAccumulated = 0;
this.heatActive = hasHeat;
this.needsPrevTileCopy = true;
}
/** Accumulate heat decay for one game tick. */
decayHeat(): void {
this.pendingDecay += this.settings.falloutBloom.heatDecayPerTick;
@@ -280,32 +237,6 @@ export class HeatManager {
// Internals
// ---------------------------------------------------------------------------
private reconstructHeat(
nukeEvents: Array<{ tick: number; tiles: number[] }>,
currentTick: number,
): Uint8Array {
const heat = new Uint8Array(this.mapW * this.mapH);
const decay = this.settings.falloutBloom.heatDecayPerTick;
for (const evt of nukeEvents) {
if (evt.tick > currentTick) continue;
const elapsed = currentTick - evt.tick;
const h = Math.round(255 - elapsed * decay);
if (h <= 0) continue;
for (const ref of evt.tiles) {
if (heat[ref] < h) heat[ref] = h;
}
}
return heat;
}
private maskHeat(heat: Uint8Array, tileState: Uint16Array): void {
for (let i = 0; i < heat.length; i++) {
if (heat[i] > 0 && (tileState[i] & FALLOUT_BIT) === 0) {
heat[i] = 0;
}
}
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.decayProg);