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
+7 -7
View File
@@ -27,7 +27,6 @@ import {
HashUpdate,
WinUpdate,
} from "../core/game/GameUpdates";
import { GameView, PlayerView } from "../core/game/GameView";
import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
import {
DARK_MODE_KEY,
@@ -73,12 +72,13 @@ import {
applyGraphicsOverrides,
createRenderSettings,
deepAssign,
MapRenderer,
preloadAtlasData,
GameView as WebGLGameView,
} from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
import { SoundManager } from "./sound/SoundManager";
import { themeProvider } from "./theme/ThemeProvider";
import { GameView, PlayerView } from "./view";
export interface LobbyConfig {
cosmetics: PlayerCosmeticRefs;
@@ -257,7 +257,7 @@ function createWebGLView(
terrainMap: TerrainMapData,
config: Config,
): {
view: WebGLGameView;
view: MapRenderer;
glCanvas: HTMLCanvasElement;
cachedWebGLFrameCallback: { current: FrameRequestCallback | null };
} {
@@ -295,7 +295,7 @@ function createWebGLView(
};
const palette = new Float32Array(4096 * 2 * 4);
const view = new WebGLGameView(
const view = new MapRenderer(
glCanvas,
{
mapWidth,
@@ -322,7 +322,7 @@ function createWebGLView(
function mountWebGLFrameLoop(
terrainMap: TerrainMapData,
view: WebGLGameView,
view: MapRenderer,
glCanvas: HTMLCanvasElement,
cachedWebGLFrameCallback: { current: FrameRequestCallback | null },
transformHandler: import("./TransformHandler").TransformHandler,
@@ -397,7 +397,7 @@ function mountWebGLFrameLoop(
// When context is lost and restored, WebGL loses all textures and geometry.
// Force a full re-upload of the simulation state.
view.on("contextrestored", () => {
view.onContextRestored = () => {
builder.clearCaches();
// Full upload of terrain, territory & trail state
@@ -418,7 +418,7 @@ function mountWebGLFrameLoop(
view.uploadRailroadState(frameData.railroadState);
builder.update(gameView);
});
};
return { builder };
}
+1 -1
View File
@@ -1,10 +1,10 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { PlayerBuildableUnitType, UnitType } from "../core/game/Game";
import { GameView, UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { Platform } from "./Platform";
import { UIState } from "./UIState";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
import { GameView, UnitView } from "./view";
export class MouseUpEvent implements GameEvent {
constructor(
+1 -1
View File
@@ -1,7 +1,7 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { Cell } from "../core/game/Game";
import { GameView, PlayerView, UnitView } from "../core/game/GameView";
import { CenterCameraEvent, DragEvent, ZoomEvent } from "./InputHandler";
import { GameView, PlayerView, UnitView } from "./view";
export class GoToPlayerEvent implements GameEvent {
constructor(
+1 -1
View File
@@ -10,7 +10,6 @@ import {
UnitType,
} from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { PlayerView } from "../core/game/GameView";
import {
AllPlayersStats,
ClientHashMessage,
@@ -30,6 +29,7 @@ import { replacer } from "../core/Util";
import { getPlayToken } from "./Auth";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
import { PlayerView } from "./view";
export class PauseGameIntentEvent implements GameEvent {
constructor(public readonly paused: boolean) {}
+3 -7
View File
@@ -3,15 +3,11 @@ import { base64url } from "jose";
import { assetUrl } from "../core/AssetUrls";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { GameView } from "../core/game/GameView";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
// imports into any non-Vite consumer (e.g. the Node perf harness).
import type {
PlayerStatic,
SpawnCenter,
GameView as WebGLGameView,
} from "./render/gl";
import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl";
import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
@@ -47,7 +43,7 @@ export class WebGLFrameBuilder {
// Scratch buffer for terrain-delta uploads (parallel to the refs list).
private terrainDeltaBytes: Uint8Array = new Uint8Array(0);
constructor(private readonly view: WebGLGameView) {
constructor(private readonly view: MapRenderer) {
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
@@ -10,13 +10,13 @@
*/
import { EventBus } from "../../core/EventBus";
import { Cell, PlayerType } from "../../core/game/Game";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { Controller } from "../Controller";
import { AlternateViewEvent } from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { MapRenderer } from "../render/gl";
import type { AttackTroopLabel } from "../render/gl/passes/WorldTextPass";
import { renderTroops } from "../Utils";
import { GameView } from "../view";
// Aquarius (#3fa9f5) for outgoing, red-400 (#f87171) for incoming.
const OUTGOING_R = 0x3f / 255;
@@ -75,7 +75,7 @@ export class AttackingTroopsController implements Controller {
private readonly game: GameView,
private readonly eventBus: EventBus,
private readonly userSettings: UserSettings,
private readonly view: WebGLGameView,
private readonly view: MapRenderer,
) {}
init() {
@@ -18,7 +18,6 @@ import {
UnitType,
} from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { Controller } from "../Controller";
import {
@@ -26,7 +25,7 @@ import {
MouseMoveEvent,
MouseUpEvent,
} from "../InputHandler";
import { GameView as WebGLGameView, buildNukeTrajectory } from "../render/gl";
import { MapRenderer, buildNukeTrajectory } from "../render/gl";
import type { SAMInfo } from "../render/gl/utils/NukeTrajectory";
import type { GhostPreviewData } from "../render/types";
import { TransformHandler } from "../TransformHandler";
@@ -35,6 +34,7 @@ import {
SendUpgradeStructureIntentEvent,
} from "../Transport";
import { UIState } from "../UIState";
import { GameView } from "../view";
/** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */
export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean {
@@ -86,7 +86,7 @@ export class BuildPreviewController implements Controller {
private eventBus: EventBus,
public uiState: UIState,
private transformHandler: TransformHandler,
private view: WebGLGameView,
private view: MapRenderer,
private userSettings: UserSettings,
) {}
@@ -9,12 +9,12 @@
*/
import { EventBus } from "../../core/EventBus";
import { GameView } from "../../core/game/GameView";
import { Controller } from "../Controller";
import { MouseMoveEvent } from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { MapRenderer } from "../render/gl";
import { OWNER_MASK } from "../render/gl/utils/TileCodec";
import { TransformHandler } from "../TransformHandler";
import { GameView } from "../view";
export class HoverHighlightController implements Controller {
private lastOwnerID = 0;
@@ -23,7 +23,7 @@ export class HoverHighlightController implements Controller {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private view: WebGLGameView,
private view: MapRenderer,
) {}
init() {
@@ -1,9 +1,9 @@
import { EventBus } from "../../core/EventBus";
import { UnitType } from "../../core/game/Game";
import { GameUpdateType } from "../../core/game/GameUpdates";
import { GameView, UnitView } from "../../core/game/GameView";
import { Controller } from "../Controller";
import { PlaySoundEffectEvent, SoundEffect } from "../sound/Sounds";
import { GameView, UnitView } from "../view";
export class SoundEffectController implements Controller {
constructor(
@@ -12,12 +12,12 @@
import { EventBus } from "../../core/EventBus";
import { Controller } from "../Controller";
import { ToggleStructureEvent } from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { MapRenderer } from "../render/gl";
export class StructureHighlightController implements Controller {
constructor(
private eventBus: EventBus,
private view: WebGLGameView,
private view: MapRenderer,
) {}
init() {
+2 -2
View File
@@ -10,12 +10,12 @@
import { EventBus } from "../../core/EventBus";
import { Controller } from "../Controller";
import { AlternateViewEvent, ToggleCoordinateGridEvent } from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { MapRenderer } from "../render/gl";
export class ViewModeController implements Controller {
constructor(
private eventBus: EventBus,
private view: WebGLGameView,
private view: MapRenderer,
) {}
init() {
@@ -2,7 +2,6 @@ import { Cell } from "src/core/game/Game";
import { EventBus } from "../../core/EventBus";
import { UnitType } from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { GameView, UnitView } from "../../core/game/GameView";
import { Controller } from "../Controller";
import {
CloseViewEvent,
@@ -15,9 +14,10 @@ import {
WarshipSelectionBoxCompleteEvent,
WarshipSelectionBoxUpdateEvent,
} from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { MapRenderer } from "../render/gl";
import { TransformHandler } from "../TransformHandler";
import { MoveWarshipIntentEvent } from "../Transport";
import { GameView, UnitView } from "../view";
const WARSHIP_SELECTION_RADIUS = 10;
@@ -48,7 +48,7 @@ export class WarshipSelectionController implements Controller {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private view: WebGLGameView,
private view: MapRenderer,
) {}
tick() {
+3 -3
View File
@@ -1,5 +1,4 @@
import { EventBus } from "../../core/EventBus";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { Controller } from "../Controller";
import { AttackingTroopsController } from "../controllers/AttackingTroopsController";
@@ -10,9 +9,10 @@ import { StructureHighlightController } from "../controllers/StructureHighlightC
import { ViewModeController } from "../controllers/ViewModeController";
import { WarshipSelectionController } from "../controllers/WarshipSelectionController";
import { GameStartingModal } from "../GameStartingModal";
import { GameView as WebGLGameView } from "../render/gl";
import { MapRenderer } from "../render/gl";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { GameView } from "../view";
import { FrameProfiler } from "./FrameProfiler";
import { ActionableEvents } from "./layers/ActionableEvents";
import { AlertFrame } from "./layers/AlertFrame";
@@ -48,7 +48,7 @@ export function createRenderer(
game: GameView,
eventBus: EventBus,
playerRole: string | null,
view: WebGLGameView,
view: MapRenderer,
): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, inputEl);
const userSettings = new UserSettings();
+1 -1
View File
@@ -1,6 +1,6 @@
import { assetUrl } from "../../core/AssetUrls";
import { AllPlayers, Nukes } from "../../core/game/Game";
import { GameView, PlayerView } from "../../core/game/GameView";
import { GameView, PlayerView } from "../view";
const allianceIcon = assetUrl("images/AllianceIcon.svg");
const allianceIconFaded = assetUrl("images/AllianceIconFaded.svg");
const allianceRequestBlackIcon = assetUrl(
+1 -1
View File
@@ -1,8 +1,8 @@
import { Colord } from "colord";
import { assetUrl } from "../../core/AssetUrls";
import { TrainType, UnitType } from "../../core/game/Game";
import { UnitView } from "../../core/game/GameView";
import { Theme } from "../theme/ThemeProvider";
import { UnitView } from "../view";
const atomBombSprite = assetUrl("sprites/atombomb.png");
const hydrogenBombSprite = assetUrl("sprites/hydrogenbomb.png");
const mirvSprite = assetUrl("sprites/mirv2.png");
+1 -1
View File
@@ -9,7 +9,6 @@ import {
BrokeAllianceUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { GoToPlayerEvent } from "../../TransformHandler";
@@ -20,6 +19,7 @@ import {
} from "../../Transport";
import { UIState } from "../../UIState";
import { getMessageTypeClasses, translateText } from "../../Utils";
import { GameView, PlayerView } from "../../view";
interface ActionableEvent {
description: string;
+1 -1
View File
@@ -5,9 +5,9 @@ import {
BrokeAllianceUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { GameView, PlayerView } from "../../view";
// Parameters for the alert animation
const ALERT_SPEED = 1.6;
+1 -1
View File
@@ -8,7 +8,6 @@ import {
GameUpdateType,
UnitIncomingUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { themeProvider } from "../../theme/ThemeProvider";
import {
@@ -23,6 +22,7 @@ import {
} from "../../Transport";
import { UIState } from "../../UIState";
import { renderTroops, translateText } from "../../Utils";
import { GameView, PlayerView, UnitView } from "../../view";
import { getColoredSprite } from "../SpriteLoader";
const soldierIcon = assetUrl("images/SoldierIcon.svg");
const swordIcon = assetUrl("images/SwordIcon.svg");
+1 -1
View File
@@ -11,7 +11,6 @@ import {
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import {
CloseViewEvent,
@@ -26,6 +25,7 @@ import {
} from "../../Transport";
import { UIState } from "../../UIState";
import { renderNumber } from "../../Utils";
import { GameView } from "../../view";
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
+1 -1
View File
@@ -8,9 +8,9 @@ import {
DisplayMessageUpdate,
GameUpdateType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { Controller } from "../../Controller";
import { GameView } from "../../view";
interface ChatEvent {
description: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { GameView, PlayerView } from "../../view";
import { ChatModal, QuickChatPhrase, quickChatPhrases } from "./ChatModal";
import { COLORS, MenuElement, MenuElementParams } from "./RadialMenuElements";
+1 -1
View File
@@ -2,7 +2,7 @@ import { LitElement, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { GameView, PlayerView } from "../../view";
import quickChatData from "resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
+1 -1
View File
@@ -8,7 +8,6 @@ import { Config } from "../../../core/configuration/Config";
import { GameMode, GameType, Gold } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { AttackRatioEvent } from "../../InputHandler";
@@ -19,6 +18,7 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { GameView } from "../../view";
import { PlayerView } from "../../view/PlayerView";
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
+1 -1
View File
@@ -2,12 +2,12 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler";
import { TransformHandler } from "../../TransformHandler";
import { SendEmojiIntentEvent } from "../../Transport";
import { GameView, PlayerView } from "../../view";
@customElement("emoji-table")
export class EmojiTable extends LitElement {
+1 -1
View File
@@ -19,9 +19,9 @@ import {
import { Controller } from "../../Controller";
import { SendAllianceRequestIntentEvent } from "../../Transport";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { GoToPlayerEvent, GoToUnitEvent } from "../../TransformHandler";
import { GameView, PlayerView, UnitView } from "../../view";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { UIState } from "../../UIState";
+1 -1
View File
@@ -4,11 +4,11 @@ import { customElement, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { Platform } from "../../Platform";
import { themeProvider } from "../../theme/ThemeProvider";
import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils";
import { GameView } from "../../view";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
const leaderboardRegularIcon = assetUrl(
+1 -1
View File
@@ -3,12 +3,12 @@ import { customElement, state } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { TogglePauseIntentEvent } from "../../InputHandler";
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { GameView } from "../../view";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { ShowReplayPanelEvent } from "./ReplayPanel";
import { ShowSettingsModalEvent } from "./SettingsModal";
+1 -1
View File
@@ -2,9 +2,9 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { GameMode, GameType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { translateText } from "../../Utils";
import { GameView } from "../../view";
const COLLUSION_WARNING_CLOSED_KEY = "hasClosedCollusionWarning";
+1 -1
View File
@@ -2,8 +2,8 @@ import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { GameView } from "../../view";
export class ImmunityBarVisibleEvent implements GameEvent {
constructor(public readonly visible: boolean) {}
+1 -1
View File
@@ -1,8 +1,8 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { GameView } from "../../view";
const AD_TYPES = [
{ type: "standard_iab_left1", selectorId: "in-game-bottom-left-ad" },
+1 -1
View File
@@ -3,10 +3,10 @@ import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderTroops, translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { GoToPlayerEvent } from "../../TransformHandler";
import { formatPercentage, renderNumber } from "../../Utils";
import { GameView, PlayerView } from "../../view";
interface Entry {
name: string;
+1 -1
View File
@@ -4,10 +4,10 @@ import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { PlayerActions } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { TransformHandler } from "../../TransformHandler";
import { UIState } from "../../UIState";
import { GameView, PlayerView } from "../../view";
import { BuildMenu } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
import { EmojiTable } from "./EmojiTable";
+1 -1
View File
@@ -3,10 +3,10 @@ import { customElement, property, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { GameEnv } from "../../../core/configuration/Config";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { MultiTabDetector } from "../../MultiTabDetector";
import { translateText } from "../../Utils";
import { GameView } from "../../view";
@customElement("multi-tab-modal")
export class MultiTabModal extends LitElement implements Controller {
+1 -1
View File
@@ -1,6 +1,5 @@
import { EventBus } from "../../../core/EventBus";
import { TileRef } from "../../../core/game/GameMap";
import { PlayerView } from "../../../core/game/GameView";
import {
SendAllianceExtensionIntentEvent,
SendAllianceRequestIntentEvent,
@@ -16,6 +15,7 @@ import {
SendTargetPlayerIntentEvent,
} from "../../Transport";
import { UIState } from "../../UIState";
import { PlayerView } from "../../view";
export class PlayerActionHandler {
constructor(
+1 -1
View File
@@ -11,7 +11,6 @@ import {
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { AllianceView } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import {
ContextMenuEvent,
@@ -27,6 +26,7 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { GameView, PlayerView, UnitView } from "../../view";
import {
EMOJI_ICON_KIND,
getFirstPlacePlayer,
@@ -3,10 +3,10 @@ import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { PlayerType } from "../../../core/game/Game";
import { PlayerView } from "../../../core/game/GameView";
import { actionButton } from "../../components/ui/ActionButton";
import { SendKickPlayerIntentEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { PlayerView } from "../../view";
const kickIcon = assetUrl("images/ExitIconWhite.svg");
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
+1 -1
View File
@@ -11,7 +11,6 @@ import {
Relation,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { actionButton } from "../../components/ui/ActionButton";
import "../../components/ui/Divider";
@@ -36,6 +35,7 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { GameView, PlayerView } from "../../view";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import "./PlayerModerationModal";
+1 -1
View File
@@ -9,10 +9,10 @@ import {
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, findClosestBy, flattenedEmojiTable } from "../../../core/Util";
import { UIState } from "../../UIState";
import { renderNumber, translateText } from "../../Utils";
import { GameView, PlayerView } from "../../view";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
import { EmojiTable } from "./EmojiTable";
+1 -1
View File
@@ -1,7 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { ReplaySpeedChangeEvent } from "../../InputHandler";
import {
@@ -9,6 +8,7 @@ import {
ReplaySpeedMultiplier,
} from "../../utilities/ReplaySpeedMultiplier";
import { translateText } from "../../Utils";
import { GameView } from "../../view";
export class ShowReplayPanelEvent {
constructor(
+1 -1
View File
@@ -1,7 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { within } from "../../../core/Util";
import {
SendDonateGoldIntentEvent,
@@ -9,6 +8,7 @@ import {
} from "../../Transport";
import { UIState } from "../../UIState";
import { renderTroops, translateText } from "../../Utils";
import { GameView, PlayerView } from "../../view";
@customElement("send-resource-modal")
export class SendResourceModal extends LitElement {
+1 -1
View File
@@ -2,10 +2,10 @@ import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode, GameType, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import { themeProvider } from "../../theme/ThemeProvider";
import { TransformHandler } from "../../TransformHandler";
import { GameView } from "../../view";
export class SpawnBarVisibleEvent implements GameEvent {
constructor(public readonly visible: boolean) {}
+1 -1
View File
@@ -2,7 +2,6 @@ import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode, Team, UnitType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Controller } from "../../Controller";
import {
formatPercentage,
@@ -10,6 +9,7 @@ import {
renderTroops,
translateText,
} from "../../Utils";
import { GameView, PlayerView } from "../../view";
interface TeamEntry {
teamName: string;
+1 -1
View File
@@ -9,12 +9,12 @@ import {
PlayerBuildableUnitType,
UnitType,
} from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { Controller } from "../../Controller";
import { ToggleStructureEvent } from "../../InputHandler";
import { UIState } from "../../UIState";
import { renderNumber, translateText } from "../../Utils";
import { GameView } from "../../view";
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
+1 -1
View File
@@ -9,7 +9,6 @@ import {
import { EventBus } from "../../../core/EventBus";
import { RankedType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { getUserMe } from "../../Api";
import "../../components/CosmeticButton";
import { Controller } from "../../Controller";
@@ -21,6 +20,7 @@ import {
import { crazyGamesSDK } from "../../CrazyGamesSDK";
import { Platform } from "../../Platform";
import { SendWinnerEvent } from "../../Transport";
import { GameView } from "../../view";
@customElement("win-modal")
export class WinModal extends LitElement implements Controller {
+3 -3
View File
@@ -19,7 +19,7 @@ WebGLFrameBuilder.update ← syncs palette, local-player ID, spawn
uploadFrameData(view, frame) ← frame/Upload.ts — dispatches to view.update*()
GameView.update*() methods ← gl/GameView.ts — public facade
MapRenderer.update*() methods ← gl/MapRenderer.ts — public facade
GPURenderer (gl/Renderer.ts) ← owns all passes
@@ -44,7 +44,7 @@ each frame (and animate from local time, e.g. the spawn-overlay breath).
| `frame/TrailManager.ts` | Mutates the per-tile trail texture; emits dirty row range |
| `frame/RailroadCache.ts` | Maintains the railroad tile state buffer |
| `gl/` | WebGL2 renderer internals |
| `gl/GameView.ts` | Public facade — what `WebGLFrameBuilder` and the client talk to |
| `gl/MapRenderer.ts` | Public facade — what `WebGLFrameBuilder` and the client talk to |
| `gl/Renderer.ts` | Owns all passes, runs them in order each frame, manages FBOs |
| `gl/Camera.ts` | World↔screen math; mutated externally each frame via `setCameraState` |
| `gl/RenderSettings.ts` | Typed view of `render-settings.json` (tuning knobs) |
@@ -163,6 +163,6 @@ builds its allocators and color derivations from the same theme JSONs — see
defaults to `render-settings.json`.
4. Instantiate it in `GPURenderer`'s constructor and call its `draw` from the
appropriate phase of `Renderer.render`.
5. Expose any needed setters on `GameView` (gl/GameView.ts).
5. Expose any needed setters on `MapRenderer` (gl/MapRenderer.ts).
6. Wire the data push from `WebGLFrameBuilder` or a controller — without
this step the pass is dead code.
+18 -41
View File
@@ -24,8 +24,6 @@ export interface FrameUploadTarget {
dirtyRowMin: number,
dirtyRowMax: number,
): void;
applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void;
applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void;
uploadRailroadState(data: Uint8Array): void;
applyRailroadDust(tileRefs: number[]): void;
updateUnits(units: ReadonlyMap<number, UnitState>, gameTick: number): void;
@@ -45,54 +43,33 @@ export interface FrameUploadTarget {
setSAMAllianceClusters(clusters: ReadonlyMap<number, number>): void;
}
export interface UploadOptions {
/** Snap name positions instantly (seek mode). Default: false. */
snap?: boolean;
/** Skip tile upload — caller already handled tiles (e.g. seek with bloom reset). */
skipTileUpload?: boolean;
}
/**
* Upload a FrameData snapshot to the GPU view.
*
* Handles tile upload mode switching, all view update calls, and conditional
* railroad/ephemeral uploads. The FrameData itself carries semantic differences
* (seek sets deadUnits=[], conquestEvents=[] etc.) this function is a
* straightforward dispatch loop.
* A straightforward dispatch loop: pushes tile/trail deltas, then all the
* conditional railroad/ephemeral uploads, to the view's update*() methods.
*/
export function uploadFrameData(
view: FrameUploadTarget,
frame: FrameData,
opts?: UploadOptions,
): void {
const snap = opts?.snap ?? false;
const skipTileUpload = opts?.skipTileUpload ?? false;
// --- Tiles + Trails ---
// Live mode: changedTiles[] means "only these tiles changed" (empty = nothing changed, skip upload).
// changedTiles null/undefined means "no delta info" (first tick — full upload needed).
// Copy mode: changedTiles[] = delta playback, null = full seek.
if (!skipTileUpload) {
if (frame.tileMode === "live" && frame.changedTiles) {
// Live delta path — tiles and trails uploaded independently
if (frame.changedTiles.length > 0) {
view.uploadLiveDelta(frame.tileState, frame.changedTiles);
}
// Trail dirty rows come from TrailManager, independent of tile deltas
if (frame.trailDirtyRowMax >= 0) {
view.uploadLiveTrailDelta(
frame.trailState,
frame.trailDirtyRowMin,
frame.trailDirtyRowMax,
);
}
} else if (frame.tileMode === "live") {
view.uploadTileAndTrailState(frame.tileState, frame.trailState);
} else if (!frame.changedTiles) {
view.applyFullTiles(frame.tileState, frame.trailState);
} else {
view.applyDelta(frame.changedTiles, frame.trailState);
// changedTiles[] means "only these tiles changed" (empty = nothing changed,
// skip upload). null means "no delta info" (first tick — full upload needed).
if (frame.changedTiles) {
if (frame.changedTiles.length > 0) {
view.uploadLiveDelta(frame.tileState, frame.changedTiles);
}
// Trail dirty rows come from TrailManager, independent of tile deltas
if (frame.trailDirtyRowMax >= 0) {
view.uploadLiveTrailDelta(
frame.trailState,
frame.trailDirtyRowMin,
frame.trailDirtyRowMax,
);
}
} else {
view.uploadTileAndTrailState(frame.tileState, frame.trailState);
}
// --- Railroads ---
@@ -125,7 +102,7 @@ export function uploadFrameData(
view.updateNukeTelegraphs(frame.nukeTelegraphs);
// --- Names + player status ---
view.updateNames(frame.names, frame.players, snap, frame.playerStatus);
view.updateNames(frame.names, frame.players, false, frame.playerStatus);
// --- Relations ---
view.updateRelations(frame.relationMatrix, frame.relationSize);
+1 -1
View File
@@ -4,4 +4,4 @@ export type { FrameData } from "../types";
// Upload
export type { RelationMatrixResult } from "./derive/RelationMatrix";
export { uploadFrameData } from "./Upload";
export type { FrameUploadTarget, UploadOptions } from "./Upload";
export type { FrameUploadTarget } from "./Upload";
-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);
+8 -18
View File
@@ -10,17 +10,17 @@ import type {
} from "./Renderer";
/**
* FrameData the boundary contract between game integration and features.
* FrameData the boundary contract between game integration and the
* renderer. Built once per tick by GameView; the renderer reads from this
* interface and never touches game internals directly.
*
* Produced once per frame by a driver (shim for live, codec for replay).
* All feature consumers (renderer, minimap, stats) read from this interface.
* They never touch game internals directly.
* Arrays are long-lived and mutated in place each tick (zero-copy refs).
*/
export interface FrameData {
// ── Core accumulated state ────────────────────────────────────────────
readonly tick: number;
/** True during spawn phase (before gameplay begins). Always false for replay. */
/** True during spawn phase (before gameplay begins). */
readonly inSpawnPhase: boolean;
readonly tileState: Uint16Array;
readonly trailState: Uint8Array;
@@ -38,10 +38,10 @@ export interface FrameData {
/**
* Changed tiles this frame for delta uploads.
* - `null` or `undefined` full upload needed (live mode or keyframe seek)
* - array delta upload (replay sequential advance)
* - `null` no delta info; full upload needed (first tick)
* - array only these tiles changed (empty = skip upload)
*/
readonly changedTiles?: TilePair[] | null;
readonly changedTiles: TilePair[] | null;
readonly railroadDirty: boolean;
readonly revealedRailTiles: number[];
@@ -49,7 +49,6 @@ export interface FrameData {
* Trail dirty row range for partial GPU upload.
* - `dirtyRowMin > dirtyRowMax` no trail changes (skip upload)
* - Otherwise upload rows [min, max] from trailState
* Only meaningful in `tileMode: "live"`.
*/
readonly trailDirtyRowMin: number;
readonly trailDirtyRowMax: number;
@@ -64,13 +63,4 @@ export interface FrameData {
readonly attackRings: AttackRingInput[];
/** True when structures changed this tick (added/removed/level change). */
readonly structuresDirty: boolean;
// ── Upload semantics ──────────────────────────────────────────────────
/**
* How tile data should reach the GPU:
* - `"live"` arrays are mutated in-place by shim each tick (zero-copy refs)
* - `"copy"` arrays may be swapped/reconstructed (renderer must copy)
*/
readonly tileMode: "live" | "copy";
}
+1 -89
View File
@@ -1,38 +1,7 @@
import type {
ConquestFx,
DeadUnitFx,
PlayerState,
UnitState,
} from "./Renderer";
import type { ConquestFx, DeadUnitFx } from "./Renderer";
// ── Supporting event types ──────────────────────────────────────────────
export interface AllianceFormedEvent {
requestorID: number;
recipientID: number;
}
export interface AllianceBrokenEvent {
traitorID: number;
betrayedID: number;
}
export interface AllianceExpiredEvent {
player1ID: number;
player2ID: number;
}
export interface EmbargoEvent {
type: "start" | "stop";
playerID: number;
embargoedID: number;
}
export interface TargetEvent {
playerID: number;
targetID: number;
}
export interface BonusEvent {
playerID: string;
smallID: number;
@@ -41,48 +10,6 @@ export interface BonusEvent {
troops: number;
}
export interface NukeIncomingEvent {
playerID: number;
}
export interface EmojiEvent {
senderID: number;
message: string;
}
export interface DisplayMessageEvent {
messageType: number;
playerID: number | null;
goldAmount?: number;
params?: Record<string, string | number>;
}
export interface WinEvent {
/** Tuple: ["player", ...playerIds] or ["team"|"nation", name, ...playerIds] */
winner: string[];
}
// ── Empty events constant ───────────────────────────────────────────────
/** Shared empty-events object. Safe to reuse — all arrays are empty and never mutated. */
export const EMPTY_FRAME_EVENTS: FrameEvents = {
deadUnits: [],
conquestEvents: [],
unitUpdates: [],
playerUpdates: [],
allianceFormed: [],
allianceBroken: [],
allianceExpired: [],
embargoEvents: [],
targetEvents: [],
bonusEvents: [],
nukeIncoming: [],
emojis: [],
displayMessages: [],
wins: [],
gamePaused: null,
};
// ── FrameEvents ─────────────────────────────────────────────────────────
/**
@@ -93,22 +20,7 @@ export const EMPTY_FRAME_EVENTS: FrameEvents = {
* field (no undefined consumers shouldn't need null checks).
*/
export interface FrameEvents {
// Rendering events
readonly deadUnits: DeadUnitFx[];
readonly conquestEvents: ConquestFx[];
// Stats events
readonly unitUpdates: UnitState[];
readonly playerUpdates: PlayerState[];
readonly allianceFormed: AllianceFormedEvent[];
readonly allianceBroken: AllianceBrokenEvent[];
readonly allianceExpired: AllianceExpiredEvent[];
readonly embargoEvents: EmbargoEvent[];
readonly targetEvents: TargetEvent[];
readonly bonusEvents: BonusEvent[];
readonly nukeIncoming: NukeIncomingEvent[];
readonly emojis: EmojiEvent[];
readonly displayMessages: DisplayMessageEvent[];
readonly wins: WinEvent[];
readonly gamePaused: boolean | null;
}
-38
View File
@@ -1,38 +0,0 @@
import type { FrameData } from "./FrameData";
import type { PlayerStatic } from "./Renderer";
/**
* Static per-session metadata. Set once at game-start, never changes.
*/
export interface GameStartConfig {
gameID: string;
mapWidth: number;
mapHeight: number;
/** 0 for spectator/replay. */
localPlayerSmallID: number;
players: PlayerStatic[];
gameMode?: string;
difficulty?: string;
numLandTiles?: number;
}
/**
* Mode-agnostic frame source. Features subscribe here and don't care
* whether data comes from a live game or a replay file.
*
* All subscription methods return an unsubscribe function.
*
* Late-join: `onGameStart` fires immediately with cached config if
* subscribed after game-start. `onFrame` does NOT late-fire subscriber
* waits for the next real tick.
*
* Game-end: `onGameEnd` fires on win detection. `onFrame` continues
* emitting the simulation runs past game-end.
*/
export interface FrameSource {
onFrame(handler: (frame: FrameData) => void): () => void;
onGameStart(handler: (config: GameStartConfig) => void): () => void;
onGameEnd(handler: () => void): () => void;
/** null before game-start. Stays valid after game-end (same session). */
readonly config: GameStartConfig | null;
}
-17
View File
@@ -1,17 +0,0 @@
/**
* The frame data type that both the live game and encoder consume.
* This matches the GameUpdateViewData from the live game's update loop.
*/
export interface GameUpdateViewData {
tick: number;
updates: Record<string, unknown[]>;
packedTileUpdates: unknown;
packedMotionPlans?: Uint32Array;
playerNameViewData: Record<string, { x: number; y: number; size: number }>;
}
/**
* Minimal GameStartInfo for the encoder's finish() call.
* The actual object is opaque JSON we just need it to be serializable.
*/
export type GameStartInfo = Record<string, unknown>;
-161
View File
@@ -1,161 +0,0 @@
/**
* Game update type constants and typed event payloads.
*
* Shared contract between shim (live game) and codec (replay).
* Values must match the LIVE deployed game's GameUpdates.ts.
*/
// ---------------------------------------------------------------------------
// GameUpdateType constants
// ---------------------------------------------------------------------------
export const GameUpdateType = {
Tile: 0,
Unit: 1,
Player: 2,
DisplayEvent: 3,
DisplayChatEvent: 4,
AllianceRequest: 5,
AllianceRequestReply: 6,
BrokeAlliance: 7,
AllianceExpired: 8,
AllianceExtension: 9,
TargetPlayer: 10,
Emoji: 11,
Win: 12,
Hash: 13,
UnitIncoming: 14,
BonusEvent: 15,
RailroadDestructionEvent: 16,
RailroadConstructionEvent: 17,
RailroadSnapEvent: 18,
ConquestEvent: 19,
EmbargoEvent: 20,
GamePaused: 21,
NukeDetonation: 22,
} as const;
// ---------------------------------------------------------------------------
// Typed update payloads (keyed by GameUpdateType values)
// ---------------------------------------------------------------------------
export type PlayerType = "HUMAN" | "NATION" | "BOT";
export interface UnitEventUpdate {
id: number;
unitType: string;
ownerID: number;
pos: number;
lastPos?: number;
isActive: boolean;
level: number;
underConstruction?: boolean;
markedForDeletion: number | false;
lastOwnerID?: number;
trainType?: string;
loaded?: boolean;
targetUnitId?: number;
targetTile?: number;
health?: number;
troops?: number;
reachedTarget?: boolean;
retreating?: boolean;
targetable?: boolean;
hasTrainStation?: boolean;
missileTimerQueue?: number[];
}
export interface PlayerEventUpdate {
id: string;
clientID?: string | null;
smallID: number;
displayName: string;
playerType: PlayerType;
team?: string | null;
isAlive: boolean;
troops: number;
gold: bigint;
tilesOwned: number;
outgoingAttacks?: AttackEventUpdate[];
incomingAttacks?: AttackEventUpdate[];
allies?: number[];
betrayals?: number;
}
export interface AttackEventUpdate {
troops: number;
}
export interface WinUpdate {
/** Winner tuple: ["player", ...playerIds] or ["team"|"nation", name, ...playerIds] */
winner?: [string, ...string[]];
}
export interface AllianceReplyUpdate {
accepted: boolean;
request?: { requestorID: number; recipientID: number };
}
export interface BrokeAllianceUpdate {
traitorID: number;
betrayedID: number;
}
export interface AllianceExpiredUpdate {
player1ID: number;
player2ID: number;
}
export interface EmbargoUpdate {
event: "start" | "stop";
playerID: number;
embargoedID: number;
}
export interface TargetPlayerUpdate {
playerID: number;
targetID: number;
}
export interface BonusUpdate {
player: string;
tile?: number;
gold: number;
troops: number;
}
export interface UnitIncomingUpdate {
playerID: number;
}
export interface EmojiUpdate {
emoji?: { senderID: number; message: string };
}
export interface DisplayMessageUpdate {
messageType: number;
playerID: number | null;
goldAmount?: bigint | number;
params?: Record<string, string | number>;
}
export interface GamePausedUpdate {
paused: boolean;
}
export interface RailroadConstructionUpdate {
id: number;
tiles: number[];
}
export interface RailroadDestructionUpdate {
id: number;
}
export interface RailroadSnapUpdate {
originalId: number;
newId1: number;
newId2: number;
tiles1: number[];
tiles2: number[];
}
-144
View File
@@ -1,144 +0,0 @@
import type {
ConquestFx,
DeadUnitFx,
NameEntry,
PlayerState,
PlayerStatic,
RendererConfig,
TilePair,
UnitState,
} from "./Renderer";
/** Chunk index entry — one per chunk in the file */
export interface ChunkIndexEntry {
compressedOffset: number;
compressedSize: number;
decompressedSize: number;
frameCount: number;
}
/** Subset of header available after streaming preamble (before full file download). */
export interface StreamableReplayInfo extends RendererConfig {
totalFrames: number;
keyframeInterval: number;
numLandTiles: number;
gameStartInfo: unknown;
chunks: ChunkIndexEntry[];
}
/** Parsed v6 file header + dictionaries + chunk index + trailer sections */
export interface ReplayHeader extends StreamableReplayInfo {
magic: number;
version: number;
gameID: string;
totalFrames: number;
keyframeInterval: number;
numLandTiles: number;
processedAt: number;
processingDurationMs: number;
gameStartInfo: unknown;
players: PlayerStatic[];
/** Chunk index — per-chunk offsets and sizes */
chunks: ChunkIndexEntry[];
/** Nuke detonation events — top-level index for seek-time heat reconstruction */
nukeEvents: Array<{ tick: number; tiles: number[] }>;
/** Railroad events — top-level index for seek-time railroad reconstruction */
railroadEvents: Array<{ tick: number; type: number; data: unknown }>;
/** Motion plan events — top-level index for plan-driven unit positions and trails */
motionPlanEvents: MotionPlanRecord[];
/** Construction start events — top-level index for seek-time construction progress */
constructionStarts: Array<{ unitId: number; startTick: number }>;
/** Conquest events — top-level index for seek-time gold popup + sword sprite */
conquestEvents: Array<{ tick: number; x: number; y: number; gold: number }>;
/** Dead unit events — top-level index for seek-time explosion/death FX */
deadUnitEvents: Array<{
tick: number;
unitType: string;
pos: number;
reachedTarget: boolean;
}>;
/** Player elimination events — tick when each player's isAlive transitioned to false */
eliminationEvents: Array<{ tick: number; smallID: number }>;
}
/** Raw decoded v4 keyframe data — tile data is a raw Uint16Array blob */
export interface RawKeyframe {
type: 0;
tick: number;
/** Raw tile blob: Uint16Array[mapWidth x mapHeight]. Direct GPU upload. */
tileBlob: Uint16Array;
players: Map<number, PlayerState>;
units: Map<number, UnitState>;
names: Map<string, NameEntry>;
miscUpdates: Record<string, unknown[]> | null;
}
/** Raw decoded delta frame data */
export interface RawDelta {
type: 1;
tick: number;
tiles: TilePair[];
playerDeltas: Map<number, PlayerState>; // new or changed players (full state after applying delta)
playersRemoved: number[];
unitDeltas: Map<number, UnitState>;
unitsRemoved: number[];
nameChanges: Map<string, NameEntry>;
miscUpdates: Record<string, unknown[]> | null;
}
export type RawFrame = RawKeyframe | RawDelta;
/** Full accumulated game state at a given tick */
export interface FrameSnapshot {
tick: number;
players: Map<number, PlayerState>;
units: Map<number, UnitState>;
names: Map<string, NameEntry>;
/** Tiles changed in this frame only (for incremental rendering). null = full upload needed. */
changedTiles: TilePair[] | null;
/** Units that died this frame (FX-only data). Empty on keyframes. */
deadUnits: DeadUnitFx[];
/** Conquest events active at this tick (from global index). */
conquestEvents: ConquestFx[];
/** Per-frame misc updates (alliances, donations, trades, etc.). null = none. */
miscUpdates: Record<string, unknown[]> | null;
}
/**
* Inflate function type platform provides its implementation.
* Node: zlib.inflateSync, Browser: pako.inflate
*/
export type InflateFn = (data: Uint8Array) => Uint8Array;
/**
* Gzip function type platform provides its implementation.
* Node: zlib.gzipSync, Browser: pako.gzip
*/
export type GzipFn = (data: Uint8Array) => Uint8Array | Promise<Uint8Array>;
// ---------------------------------------------------------------------------
// Motion plan records — stored as a file-level index for plan-driven units
// (transport ships, trade ships, trains).
// ---------------------------------------------------------------------------
export interface GridPlanRecord {
kind: "grid";
unitId: number;
planId: number;
startTick: number;
ticksPerStep: number;
path: Uint32Array;
}
export interface TrainPlanRecord {
kind: "train";
engineUnitId: number;
carUnitIds: Uint32Array;
planId: number;
startTick: number;
speed: number;
spacing: number;
path: Uint32Array;
}
export type MotionPlanRecord = GridPlanRecord | TrainPlanRecord;
+2 -60
View File
@@ -22,66 +22,8 @@ export type {
// Frame data — boundary contract between game integration and features
export type { FrameData } from "./FrameData";
// Frame events — per-frame ephemeral events (rendering FX + stats events)
export { EMPTY_FRAME_EVENTS } from "./FrameEvents";
export type {
AllianceBrokenEvent,
AllianceExpiredEvent,
AllianceFormedEvent,
BonusEvent,
DisplayMessageEvent,
EmbargoEvent,
EmojiEvent,
FrameEvents,
NukeIncomingEvent,
TargetEvent,
WinEvent,
} from "./FrameEvents";
// Frame source — mode-agnostic subscription interface
export type { FrameSource, GameStartConfig } from "./FrameSource";
// Game update types
export type { GameStartInfo, GameUpdateViewData } from "./Game";
// Replay types (header, frames, codec helpers)
export type {
ChunkIndexEntry,
FrameSnapshot,
GridPlanRecord,
GzipFn,
InflateFn,
MotionPlanRecord,
RawDelta,
RawFrame,
RawKeyframe,
ReplayHeader,
StreamableReplayInfo,
TrainPlanRecord,
} from "./Replay";
// Game update type constants and event payloads (shared between shim + codec)
export { GameUpdateType } from "./GameUpdates";
export type {
AllianceExpiredUpdate,
AllianceReplyUpdate,
AttackEventUpdate,
BonusUpdate,
BrokeAllianceUpdate,
DisplayMessageUpdate,
EmbargoUpdate,
EmojiUpdate,
GamePausedUpdate,
PlayerEventUpdate,
PlayerType,
RailroadConstructionUpdate,
RailroadDestructionUpdate,
RailroadSnapUpdate,
TargetPlayerUpdate,
UnitEventUpdate,
UnitIncomingUpdate,
WinUpdate,
} from "./GameUpdates";
// Frame events — per-frame ephemeral events (rendering FX)
export type { BonusEvent, FrameEvents } from "./FrameEvents";
// Unit type string constants and derived sets
export {
+1 -1
View File
@@ -1,12 +1,12 @@
import { Colord, colord, LabaColor } from "colord";
import { PlayerType, Team } from "../../core/game/Game";
import { PlayerView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { simpleHash } from "../../core/Util";
import {
createThemeSettings,
ThemeSettings,
} from "../render/gl/RenderSettings";
import { PlayerView } from "../view";
import { ColorAllocator } from "./ColorAllocator";
/**
+1 -15
View File
@@ -156,8 +156,7 @@ export class GameView implements GameMap {
// buffers (tileState, trailState, etc.); some (_changedTilesScratch,
// derived arrays) are reused each tick. Properties marked `readonly` on
// FrameData only prevent reassignment, not mutation through the reference.
// events: fresh arrays we own; cleared and repopulated each tick. (Don't
// spread EMPTY_FRAME_EVENTS — that would share the module-level arrays.)
// events: fresh arrays we own; cleared and repopulated each tick.
this._frame = {
tick: 0,
inSpawnPhase: true,
@@ -170,19 +169,7 @@ export class GameView implements GameMap {
events: {
deadUnits: [],
conquestEvents: [],
unitUpdates: [],
playerUpdates: [],
allianceFormed: [],
allianceBroken: [],
allianceExpired: [],
embargoEvents: [],
targetEvents: [],
bonusEvents: [],
nukeIncoming: [],
emojis: [],
displayMessages: [],
wins: [],
gamePaused: null,
},
changedTiles: this._changedTilesScratch,
railroadDirty: false,
@@ -198,7 +185,6 @@ export class GameView implements GameMap {
nukeTelegraphs: [],
attackRings: [],
structuresDirty: false,
tileMode: "live",
};
}
+3
View File
@@ -0,0 +1,3 @@
export { GameView } from "./GameView";
export { PlayerView } from "./PlayerView";
export { UnitView } from "./UnitView";
+1 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { PlayerView } from "../../client/view";
import { AssetManifest } from "../AssetUrls";
import {
Difficulty,
@@ -16,7 +17,6 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import { UserSettings } from "../game/UserSettings";
import { GameConfig, TeamCountConfig } from "../Schemas";
import { NukeType } from "../StatsSchemas";
+1 -1
View File
@@ -1,7 +1,7 @@
import { GameView } from "../../client/view";
import { NukeMagnitude } from "../configuration/Config";
import { Game, Player, Structures } from "../game/Game";
import { euclDistFN, GameMap, TileRef } from "../game/GameMap";
import { GameView } from "../game/GameView";
export interface NukeBlastParams {
gm: GameMap;
+1 -1
View File
@@ -1,4 +1,5 @@
import { renderNumber } from "../../client/Utils";
import { UnitView } from "../../client/view";
import { Config } from "../configuration/Config";
import { SharedWaterCache } from "../execution/nation/SharedWaterCache";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
@@ -40,7 +41,6 @@ import {
} from "./Game";
import { GameMap, TileRef } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { UnitView } from "./GameView";
import { MotionPlanRecord, packMotionPlans } from "./MotionPlans";
import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
-12
View File
@@ -1,12 +0,0 @@
// Back-compat re-export shim.
// The view classes physically live in src/client/view/ — this re-export keeps
// the older `import { GameView } from "src/core/game/GameView"` path working.
//
// TODO: remove this shim once all 50+ importers have been updated to point at
// src/client/view/ directly, and the 6 core files that reference PlayerView /
// UnitView / GameView as union types (Player | PlayerView etc.) are refactored
// to use Player / Unit / Game interfaces instead.
export { GameView } from "../../client/view/GameView";
export { PlayerView } from "../../client/view/PlayerView";
export { UnitView } from "../../client/view/UnitView";
+1 -1
View File
@@ -1,6 +1,6 @@
import { UnitView } from "../../client/view";
import { PlayerID, Unit, UnitType } from "./Game";
import { GameMap, TileRef } from "./GameMap";
import { UnitView } from "./GameView";
export type UnitPredicate = (value: {
unit: Unit | UnitView;
+1 -1
View File
@@ -7,9 +7,9 @@ import {
WarshipSelectionBoxUpdateEvent,
} from "../src/client/InputHandler";
import { UIState } from "../src/client/UIState";
import { GameView, PlayerView } from "../src/client/view";
import { EventBus } from "../src/core/EventBus";
import { UnitType } from "../src/core/game/Game";
import { GameView, PlayerView } from "../src/core/game/GameView";
import { KEYBINDS_KEY, UserSettings } from "../src/core/game/UserSettings";
class MockPointerEvent {
@@ -7,9 +7,9 @@ import {
rootMenuElement,
Slot,
} from "../../../src/client/hud/layers/RadialMenuElements";
import { GameView, PlayerView } from "../../../src/client/view";
import { UnitType } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { GameView, PlayerView } from "../../../src/core/game/GameView";
vi.mock("../../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => key),
@@ -30,8 +30,8 @@ import { actionButton } from "../../../../src/client/components/ui/ActionButton"
import { PlayerModerationModal } from "../../../../src/client/hud/layers/PlayerModerationModal";
import { PlayerPanel } from "../../../../src/client/hud/layers/PlayerPanel";
import { SendKickPlayerIntentEvent } from "../../../../src/client/Transport";
import { PlayerView } from "../../../../src/client/view";
import { PlayerType } from "../../../../src/core/game/Game";
import { PlayerView } from "../../../../src/core/game/GameView";
describe("PlayerPanel - kick player moderation", () => {
let panel: PlayerPanel;
-5
View File
@@ -454,11 +454,6 @@ describe("GameView.frameData() — renderer contract", () => {
expect(game.frameData().events.deadUnits).toBe(a1);
});
it("frame.tileMode is 'live'", () => {
const game = makeGameView();
expect(game.frameData().tileMode).toBe("live");
});
it("frame.structuresDirty is true on first populate (force initial upload)", () => {
const game = makeGameView();
game.update(makeEmptyGu(1));
+1 -1
View File
@@ -31,7 +31,7 @@ const makePlayer = (
: true,
isTraitor: () => opts?.isTraitor ?? false,
isDisconnected: () => opts?.isDisconnected ?? false,
}) as unknown as import("../src/core/game/GameView").PlayerView;
}) as unknown as import("../src/client/view").PlayerView;
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
const myPlayer = (opts?.myPlayer as any) ?? makePlayer("p1");