mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
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:
@@ -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,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,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(
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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,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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 [0–1]. */
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GameView } from "./GameView";
|
||||
export { PlayerView } from "./PlayerView";
|
||||
export { UnitView } from "./UnitView";
|
||||
@@ -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,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,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";
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user