mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 16:42:14 +00:00
territory
This commit is contained in:
@@ -6,6 +6,7 @@ coverage/
|
||||
TODO.txt
|
||||
resources/images/.DS_Store
|
||||
resources/.DS_Store
|
||||
resources/certs/
|
||||
.env*
|
||||
.DS_Store
|
||||
.clinic/
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
|
||||
"start:server-dev": "cross-env GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
|
||||
"dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"dev:secure": "cross-env GAME_ENV=dev DEV_HTTPS=1 DEV_CERT=resources/certs/dev.crt DEV_KEY=resources/certs/dev.key concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"tunnel": "npm run build-prod && npm run start:server",
|
||||
|
||||
@@ -81,6 +81,21 @@ export class RefreshGraphicsEvent implements GameEvent {}
|
||||
|
||||
export class TogglePerformanceOverlayEvent implements GameEvent {}
|
||||
|
||||
export class ToggleTerritoryWebGLEvent implements GameEvent {}
|
||||
|
||||
export class TerritoryWebGLStatusEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly enabled: boolean,
|
||||
public readonly active: boolean,
|
||||
public readonly supported: boolean,
|
||||
public readonly message?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ToggleTerritoryWebGLDebugBordersEvent implements GameEvent {
|
||||
constructor(public readonly enabled: boolean) {}
|
||||
}
|
||||
|
||||
export class ToggleStructureEvent implements GameEvent {
|
||||
constructor(public readonly structureTypes: UnitType[] | null) {}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { StructureLayer } from "./layers/StructureLayer";
|
||||
import { TeamStats } from "./layers/TeamStats";
|
||||
import { TerrainLayer } from "./layers/TerrainLayer";
|
||||
import { TerritoryLayer } from "./layers/TerritoryLayer";
|
||||
import { TerritoryWebGLStatus } from "./layers/TerritoryWebGLStatus";
|
||||
import { UILayer } from "./layers/UILayer";
|
||||
import { UnitDisplay } from "./layers/UnitDisplay";
|
||||
import { UnitLayer } from "./layers/UnitLayer";
|
||||
@@ -223,6 +224,18 @@ export function createRenderer(
|
||||
performanceOverlay.eventBus = eventBus;
|
||||
performanceOverlay.userSettings = userSettings;
|
||||
|
||||
let territoryWebGLStatus = document.querySelector(
|
||||
"territory-webgl-status",
|
||||
) as TerritoryWebGLStatus;
|
||||
if (!(territoryWebGLStatus instanceof TerritoryWebGLStatus)) {
|
||||
territoryWebGLStatus = document.createElement(
|
||||
"territory-webgl-status",
|
||||
) as TerritoryWebGLStatus;
|
||||
document.body.appendChild(territoryWebGLStatus);
|
||||
}
|
||||
territoryWebGLStatus.eventBus = eventBus;
|
||||
territoryWebGLStatus.userSettings = userSettings;
|
||||
|
||||
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
|
||||
if (!(alertFrame instanceof AlertFrame)) {
|
||||
console.error("alert frame not found");
|
||||
@@ -240,6 +253,7 @@ export function createRenderer(
|
||||
// Try to group layers by the return value of shouldTransform.
|
||||
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
|
||||
const layers: Layer[] = [
|
||||
territoryWebGLStatus,
|
||||
new TerrainLayer(game, transformHandler),
|
||||
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
||||
new RailroadLayer(game, transformHandler),
|
||||
@@ -412,7 +426,8 @@ export class GameRenderer {
|
||||
|
||||
const layerStart = FrameProfiler.start();
|
||||
layer.renderLayer?.(this.context);
|
||||
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
|
||||
const name = layer.constructor?.name ?? "UnknownLayer";
|
||||
FrameProfiler.end(name, layerStart);
|
||||
}
|
||||
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
|
||||
this.transformHandler.resetChanged();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
import { TileRef } from "../../core/game/GameMap";
|
||||
import { GameView, PlayerView, UnitView } from "../../core/game/GameView";
|
||||
|
||||
const HOVER_UNIT_TYPES: UnitType[] = [
|
||||
UnitType.Warship,
|
||||
UnitType.TradeShip,
|
||||
UnitType.TransportShip,
|
||||
];
|
||||
const HOVER_DISTANCE_PX = 5;
|
||||
|
||||
function euclideanDistWorld(
|
||||
coord: { x: number; y: number },
|
||||
tileRef: TileRef,
|
||||
game: GameView,
|
||||
): number {
|
||||
const x = game.x(tileRef);
|
||||
const y = game.y(tileRef);
|
||||
const dx = coord.x - x;
|
||||
const dy = coord.y - y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function distSortUnitWorld(
|
||||
coord: { x: number; y: number },
|
||||
game: GameView,
|
||||
): (a: UnitView, b: UnitView) => number {
|
||||
return (a, b) => {
|
||||
const distA = euclideanDistWorld(coord, a.tile(), game);
|
||||
const distB = euclideanDistWorld(coord, b.tile(), game);
|
||||
return distA - distB;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HoverTargetResolution {
|
||||
player: PlayerView | null;
|
||||
unit: UnitView | null;
|
||||
}
|
||||
|
||||
export function resolveHoverTarget(
|
||||
game: GameView,
|
||||
worldCoord: { x: number; y: number },
|
||||
): HoverTargetResolution {
|
||||
if (!game.isValidCoord(worldCoord.x, worldCoord.y)) {
|
||||
return { player: null, unit: null };
|
||||
}
|
||||
const tile = game.ref(worldCoord.x, worldCoord.y);
|
||||
|
||||
const owner = game.owner(tile);
|
||||
if (owner && owner.isPlayer()) {
|
||||
return { player: owner as PlayerView, unit: null };
|
||||
}
|
||||
|
||||
if (game.isLand(tile)) {
|
||||
return { player: null, unit: null };
|
||||
}
|
||||
|
||||
const units = game
|
||||
.units(...HOVER_UNIT_TYPES)
|
||||
.filter(
|
||||
(u) => euclideanDistWorld(worldCoord, u.tile(), game) < HOVER_DISTANCE_PX,
|
||||
)
|
||||
.sort(distSortUnitWorld(worldCoord, game));
|
||||
|
||||
if (units.length > 0) {
|
||||
return { player: units[0].owner(), unit: units[0] };
|
||||
}
|
||||
|
||||
return { player: null, unit: null };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { PlayerView } from "../../../core/game/GameView";
|
||||
|
||||
export interface BorderRenderer {
|
||||
setAlternativeView(enabled: boolean): void;
|
||||
setHoveredPlayerId(playerSmallId: number | null): void;
|
||||
drawsOwnBorders(): boolean;
|
||||
|
||||
updateBorder(
|
||||
tile: TileRef,
|
||||
owner: PlayerView | null,
|
||||
isBorder: boolean,
|
||||
isDefended: boolean,
|
||||
hasFallout: boolean,
|
||||
): void;
|
||||
|
||||
clearTile(tile: TileRef): void;
|
||||
|
||||
render(context: CanvasRenderingContext2D): void;
|
||||
}
|
||||
|
||||
export class NullBorderRenderer implements BorderRenderer {
|
||||
drawsOwnBorders(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAlternativeView() {}
|
||||
|
||||
setHoveredPlayerId() {}
|
||||
|
||||
updateBorder() {}
|
||||
|
||||
clearTile() {}
|
||||
|
||||
render() {}
|
||||
}
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
PlayerProfile,
|
||||
PlayerType,
|
||||
Relation,
|
||||
Unit,
|
||||
UnitType,
|
||||
} 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 { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler";
|
||||
@@ -28,31 +26,12 @@ import {
|
||||
renderTroops,
|
||||
translateText,
|
||||
} from "../../Utils";
|
||||
import { resolveHoverTarget } from "../HoverTargetResolver";
|
||||
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { CloseRadialMenuEvent } from "./RadialMenu";
|
||||
|
||||
function euclideanDistWorld(
|
||||
coord: { x: number; y: number },
|
||||
tileRef: TileRef,
|
||||
game: GameView,
|
||||
): number {
|
||||
const x = game.x(tileRef);
|
||||
const y = game.y(tileRef);
|
||||
const dx = coord.x - x;
|
||||
const dy = coord.y - y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
|
||||
return (a: Unit | UnitView, b: Unit | UnitView) => {
|
||||
const distA = euclideanDistWorld(coord, a.tile(), game);
|
||||
const distB = euclideanDistWorld(coord, b.tile(), game);
|
||||
return distA - distB;
|
||||
};
|
||||
}
|
||||
|
||||
@customElement("player-info-overlay")
|
||||
export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
@property({ type: Object })
|
||||
@@ -115,27 +94,16 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = this.game.ref(worldCoord.x, worldCoord.y);
|
||||
if (!tile) return;
|
||||
|
||||
const owner = this.game.owner(tile);
|
||||
|
||||
if (owner && owner.isPlayer()) {
|
||||
this.player = owner as PlayerView;
|
||||
const target = resolveHoverTarget(this.game, worldCoord);
|
||||
if (target.player) {
|
||||
this.player = target.player;
|
||||
this.player.profile().then((p) => {
|
||||
this.playerProfile = p;
|
||||
});
|
||||
this.setVisible(true);
|
||||
} else if (!this.game.isLand(tile)) {
|
||||
const units = this.game
|
||||
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
|
||||
.filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50)
|
||||
.sort(distSortUnitWorld(worldCoord, this.game));
|
||||
|
||||
if (units.length > 0) {
|
||||
this.unit = units[0];
|
||||
this.setVisible(true);
|
||||
}
|
||||
} else if (target.unit) {
|
||||
this.unit = target.unit;
|
||||
this.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
import { Colord } from "colord";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
|
||||
export enum TileRelation {
|
||||
Unknown = 0,
|
||||
Self = 1,
|
||||
Friendly = 2,
|
||||
Neutral = 3,
|
||||
Enemy = 4,
|
||||
}
|
||||
|
||||
export interface BorderEdge {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
color: Colord;
|
||||
ownerSmallId: number;
|
||||
relation: TileRelation;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
interface UniformLocations {
|
||||
alternativeView: WebGLUniformLocation | null;
|
||||
hoveredPlayerId: WebGLUniformLocation | null;
|
||||
highlightStrength: WebGLUniformLocation | null;
|
||||
highlightColor: WebGLUniformLocation | null;
|
||||
hoverPulseStrength: WebGLUniformLocation | null;
|
||||
hoverPulseSpeed: WebGLUniformLocation | null;
|
||||
resolution: WebGLUniformLocation | null;
|
||||
themeSelf: WebGLUniformLocation | null;
|
||||
themeFriendly: WebGLUniformLocation | null;
|
||||
themeNeutral: WebGLUniformLocation | null;
|
||||
themeEnemy: WebGLUniformLocation | null;
|
||||
time: WebGLUniformLocation | null;
|
||||
debugPulse: WebGLUniformLocation | null;
|
||||
hoverPulse: WebGLUniformLocation | null;
|
||||
}
|
||||
|
||||
export interface HoverHighlightOptions {
|
||||
color?: Colord;
|
||||
strength?: number;
|
||||
pulseStrength?: number;
|
||||
pulseSpeed?: number;
|
||||
}
|
||||
|
||||
export class TerritoryBorderWebGL {
|
||||
private static readonly INITIAL_CHUNK_CAPACITY = 65536; // 256;
|
||||
private static readonly MAX_EDGES_PER_TILE = 4;
|
||||
private static readonly VERTICES_PER_EDGE = 2;
|
||||
private static readonly MAX_VERTICES_PER_TILE =
|
||||
TerritoryBorderWebGL.MAX_EDGES_PER_TILE *
|
||||
TerritoryBorderWebGL.VERTICES_PER_EDGE;
|
||||
private static readonly FLOATS_PER_VERTEX = 9;
|
||||
private static readonly FLOATS_PER_TILE =
|
||||
TerritoryBorderWebGL.MAX_VERTICES_PER_TILE *
|
||||
TerritoryBorderWebGL.FLOATS_PER_VERTEX;
|
||||
private static readonly STRIDE_BYTES =
|
||||
TerritoryBorderWebGL.FLOATS_PER_VERTEX * 4;
|
||||
|
||||
static create(
|
||||
width: number,
|
||||
height: number,
|
||||
theme: Theme,
|
||||
): TerritoryBorderWebGL | null {
|
||||
const span = FrameProfiler.start();
|
||||
const renderer = new TerritoryBorderWebGL(width, height, theme);
|
||||
const result = renderer.isValid() ? renderer : null;
|
||||
FrameProfiler.end("TerritoryBorderWebGL:create", span);
|
||||
return result;
|
||||
}
|
||||
|
||||
public readonly canvas: HTMLCanvasElement;
|
||||
|
||||
private readonly gl: WebGLRenderingContext | null;
|
||||
private readonly program: WebGLProgram | null;
|
||||
private readonly vertexBuffer: WebGLBuffer | null;
|
||||
private vertexData: Float32Array;
|
||||
private capacityChunks = TerritoryBorderWebGL.INITIAL_CHUNK_CAPACITY;
|
||||
private usedChunks = 0;
|
||||
private vertexCount = 0;
|
||||
|
||||
private readonly tileToChunk = new Map<number, number>();
|
||||
private readonly chunkToTile: number[] = [];
|
||||
private readonly dirtyChunks: Set<number> = new Set();
|
||||
private readonly uniforms: UniformLocations;
|
||||
|
||||
private hoveredPlayerId = -1;
|
||||
private alternativeView = false;
|
||||
private needsRedraw = true;
|
||||
private animationStartTime = Date.now();
|
||||
private debugPulseEnabled = false;
|
||||
private hoverPulseEnabled = false;
|
||||
private hoverHighlightStrength = 0.7;
|
||||
private hoverHighlightColor: [number, number, number] = [1, 1, 1];
|
||||
private hoverPulseStrength = 0.25;
|
||||
private hoverPulseSpeed = 6.28318;
|
||||
|
||||
private constructor(
|
||||
private readonly width: number,
|
||||
private readonly height: number,
|
||||
private readonly theme: Theme,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
|
||||
this.gl =
|
||||
(this.canvas.getContext("webgl", {
|
||||
premultipliedAlpha: true,
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: true,
|
||||
}) as WebGLRenderingContext | null) ??
|
||||
(this.canvas.getContext("experimental-webgl", {
|
||||
premultipliedAlpha: true,
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: true,
|
||||
}) as WebGLRenderingContext | null);
|
||||
|
||||
this.vertexData = new Float32Array(
|
||||
TerritoryBorderWebGL.INITIAL_CHUNK_CAPACITY *
|
||||
TerritoryBorderWebGL.FLOATS_PER_TILE,
|
||||
);
|
||||
// Debug: log initial capacity so we can tune INITIAL_CHUNK_CAPACITY.
|
||||
// This will show up once per renderer creation.
|
||||
|
||||
console.log(
|
||||
"[TerritoryBorderWebGL] initial capacityChunks=",
|
||||
this.capacityChunks,
|
||||
"for map size",
|
||||
`${this.width}x${this.height}`,
|
||||
);
|
||||
|
||||
if (!this.gl) {
|
||||
this.program = null;
|
||||
this.vertexBuffer = null;
|
||||
this.uniforms = {
|
||||
alternativeView: null,
|
||||
hoveredPlayerId: null,
|
||||
highlightStrength: null,
|
||||
highlightColor: null,
|
||||
hoverPulseStrength: null,
|
||||
hoverPulseSpeed: null,
|
||||
resolution: null,
|
||||
themeSelf: null,
|
||||
themeFriendly: null,
|
||||
themeNeutral: null,
|
||||
themeEnemy: null,
|
||||
time: null,
|
||||
debugPulse: null,
|
||||
hoverPulse: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const gl = this.gl;
|
||||
const vertexShaderSource = `
|
||||
precision mediump float;
|
||||
attribute vec2 a_position;
|
||||
attribute vec4 a_color;
|
||||
attribute float a_owner;
|
||||
attribute float a_relation;
|
||||
attribute float a_flags;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
|
||||
varying vec4 v_color;
|
||||
varying float v_owner;
|
||||
varying float v_relation;
|
||||
varying float v_flags;
|
||||
|
||||
void main() {
|
||||
vec2 zeroToOne = a_position / u_resolution;
|
||||
vec2 clipSpace = zeroToOne * 2.0 - 1.0;
|
||||
clipSpace.y = -clipSpace.y;
|
||||
gl_Position = vec4(clipSpace, 0.0, 1.0);
|
||||
v_color = a_color;
|
||||
v_owner = a_owner;
|
||||
v_relation = a_relation;
|
||||
v_flags = a_flags;
|
||||
}
|
||||
`;
|
||||
const fragmentShaderSource = `
|
||||
precision mediump float;
|
||||
|
||||
uniform bool u_alternativeView;
|
||||
uniform float u_hoveredPlayerId;
|
||||
uniform float u_highlightStrength;
|
||||
uniform vec3 u_highlightColor;
|
||||
uniform float u_hoverPulseStrength;
|
||||
uniform float u_hoverPulseSpeed;
|
||||
uniform vec4 u_themeSelf;
|
||||
uniform vec4 u_themeFriendly;
|
||||
uniform vec4 u_themeNeutral;
|
||||
uniform vec4 u_themeEnemy;
|
||||
uniform float u_time;
|
||||
uniform bool u_debugPulse;
|
||||
uniform bool u_hoverPulse;
|
||||
|
||||
varying vec4 v_color;
|
||||
varying float v_owner;
|
||||
varying float v_relation;
|
||||
varying float v_flags;
|
||||
|
||||
vec4 relationColor(float relation) {
|
||||
if (relation < 0.5) {
|
||||
return u_themeNeutral;
|
||||
} else if (relation < 1.5) {
|
||||
return u_themeSelf;
|
||||
} else if (relation < 2.5) {
|
||||
return u_themeFriendly;
|
||||
} else if (relation < 3.5) {
|
||||
return u_themeNeutral;
|
||||
}
|
||||
return u_themeEnemy;
|
||||
}
|
||||
|
||||
vec3 rgbToHsl(vec3 c) {
|
||||
float maxc = max(c.r, max(c.g, c.b));
|
||||
float minc = min(c.r, min(c.g, c.b));
|
||||
float h = 0.0;
|
||||
float s = 0.0;
|
||||
float l = (maxc + minc) * 0.5;
|
||||
if (maxc != minc) {
|
||||
float d = maxc - minc;
|
||||
s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc);
|
||||
if (maxc == c.r) {
|
||||
h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||
} else if (maxc == c.g) {
|
||||
h = (c.b - c.r) / d + 2.0;
|
||||
} else {
|
||||
h = (c.r - c.g) / d + 4.0;
|
||||
}
|
||||
h /= 6.0;
|
||||
}
|
||||
return vec3(h, s, l);
|
||||
}
|
||||
|
||||
float hueToRgb(float p, float q, float t) {
|
||||
if (t < 0.0) t += 1.0;
|
||||
if (t > 1.0) t -= 1.0;
|
||||
if (t < 1.0/6.0) return p + (q - p) * 6.0 * t;
|
||||
if (t < 1.0/2.0) return q;
|
||||
if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0;
|
||||
return p;
|
||||
}
|
||||
|
||||
vec3 hslToRgb(vec3 hsl) {
|
||||
float h = hsl.x;
|
||||
float s = hsl.y;
|
||||
float l = hsl.z;
|
||||
float r;
|
||||
float g;
|
||||
float b;
|
||||
if (s == 0.0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
||||
float p = 2.0 * l - q;
|
||||
r = hueToRgb(p, q, h + 1.0/3.0);
|
||||
g = hueToRgb(p, q, h);
|
||||
b = hueToRgb(p, q, h - 1.0/3.0);
|
||||
}
|
||||
return vec3(r, g, b);
|
||||
}
|
||||
|
||||
vec3 darken(vec3 rgb, float amount) {
|
||||
vec3 hsl = rgbToHsl(rgb);
|
||||
hsl.z = clamp(hsl.z - amount, 0.0, 1.0);
|
||||
return hslToRgb(hsl);
|
||||
}
|
||||
|
||||
void main() {
|
||||
if (v_color.a <= 0.0) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec4 color = v_color;
|
||||
float flags = v_flags;
|
||||
bool isDefended = mod(flags, 2.0) >= 1.0;
|
||||
flags = floor(flags / 2.0);
|
||||
bool hasFriendly = mod(flags, 2.0) >= 1.0;
|
||||
flags = floor(flags / 2.0);
|
||||
bool hasEmbargo = mod(flags, 2.0) >= 1.0;
|
||||
flags = floor(flags / 2.0);
|
||||
bool lightTile = mod(flags, 2.0) >= 1.0;
|
||||
|
||||
if (u_alternativeView) {
|
||||
color = relationColor(v_relation);
|
||||
color.a = 1.0;
|
||||
} else {
|
||||
// Relationship-based tinting (embargo -> red, friendly -> green)
|
||||
if (hasEmbargo) {
|
||||
color.rgb = mix(color.rgb, vec3(1.0, 0.0, 0.0), 0.35);
|
||||
} else if (hasFriendly) {
|
||||
color.rgb = mix(color.rgb, vec3(0.0, 1.0, 0.0), 0.35);
|
||||
}
|
||||
|
||||
// Defended checkerboard pattern using light/dark variants
|
||||
if (isDefended) {
|
||||
vec3 lightColor = darken(color.rgb, 0.2);
|
||||
vec3 darkColor = darken(color.rgb, 0.4);
|
||||
color.rgb = lightTile ? lightColor : darkColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
u_hoveredPlayerId >= 0.0 &&
|
||||
abs(v_owner - u_hoveredPlayerId) < 0.5
|
||||
) {
|
||||
float pulse =
|
||||
u_hoverPulse
|
||||
? (1.0 - u_hoverPulseStrength) +
|
||||
u_hoverPulseStrength *
|
||||
(0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed))
|
||||
: 1.0;
|
||||
color.rgb = mix(
|
||||
color.rgb,
|
||||
u_highlightColor,
|
||||
u_highlightStrength * pulse
|
||||
);
|
||||
}
|
||||
|
||||
// Optional blinking/pulsing effect to highlight WebGL-drawn borders.
|
||||
// Enabled only when u_debugPulse is true. Pulses between 0.5 and 1.0 opacity
|
||||
// using a smooth sine wave animation with ~1 second period.
|
||||
if (u_debugPulse) {
|
||||
float pulse = 0.75 + 0.25 * sin(u_time * 6.28318); // 2 * PI for full cycle
|
||||
color.a *= pulse;
|
||||
}
|
||||
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`;
|
||||
|
||||
const vertexShader = this.compileShader(
|
||||
gl.VERTEX_SHADER,
|
||||
vertexShaderSource,
|
||||
);
|
||||
const fragmentShader = this.compileShader(
|
||||
gl.FRAGMENT_SHADER,
|
||||
fragmentShaderSource,
|
||||
);
|
||||
|
||||
this.program = this.createProgram(vertexShader, fragmentShader);
|
||||
if (!this.program) {
|
||||
this.vertexBuffer = null;
|
||||
this.uniforms = {
|
||||
alternativeView: null,
|
||||
hoveredPlayerId: null,
|
||||
highlightStrength: null,
|
||||
highlightColor: null,
|
||||
hoverPulseStrength: null,
|
||||
hoverPulseSpeed: null,
|
||||
resolution: null,
|
||||
themeSelf: null,
|
||||
themeFriendly: null,
|
||||
themeNeutral: null,
|
||||
themeEnemy: null,
|
||||
time: null,
|
||||
debugPulse: null,
|
||||
hoverPulse: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const program = this.program;
|
||||
gl.useProgram(program);
|
||||
|
||||
this.vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this.vertexData, gl.DYNAMIC_DRAW);
|
||||
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const colorLocation = gl.getAttribLocation(program, "a_color");
|
||||
const ownerLocation = gl.getAttribLocation(program, "a_owner");
|
||||
const relationLocation = gl.getAttribLocation(program, "a_relation");
|
||||
const flagsLocation = gl.getAttribLocation(program, "a_flags");
|
||||
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(
|
||||
positionLocation,
|
||||
2,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
TerritoryBorderWebGL.STRIDE_BYTES,
|
||||
0,
|
||||
);
|
||||
|
||||
gl.enableVertexAttribArray(colorLocation);
|
||||
gl.vertexAttribPointer(
|
||||
colorLocation,
|
||||
4,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
TerritoryBorderWebGL.STRIDE_BYTES,
|
||||
2 * 4,
|
||||
);
|
||||
|
||||
gl.enableVertexAttribArray(ownerLocation);
|
||||
gl.vertexAttribPointer(
|
||||
ownerLocation,
|
||||
1,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
TerritoryBorderWebGL.STRIDE_BYTES,
|
||||
6 * 4,
|
||||
);
|
||||
|
||||
gl.enableVertexAttribArray(relationLocation);
|
||||
gl.vertexAttribPointer(
|
||||
relationLocation,
|
||||
1,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
TerritoryBorderWebGL.STRIDE_BYTES,
|
||||
7 * 4,
|
||||
);
|
||||
|
||||
gl.enableVertexAttribArray(flagsLocation);
|
||||
gl.vertexAttribPointer(
|
||||
flagsLocation,
|
||||
1,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
TerritoryBorderWebGL.STRIDE_BYTES,
|
||||
8 * 4,
|
||||
);
|
||||
|
||||
this.uniforms = {
|
||||
alternativeView: gl.getUniformLocation(program, "u_alternativeView"),
|
||||
hoveredPlayerId: gl.getUniformLocation(program, "u_hoveredPlayerId"),
|
||||
highlightStrength: gl.getUniformLocation(program, "u_highlightStrength"),
|
||||
highlightColor: gl.getUniformLocation(program, "u_highlightColor"),
|
||||
hoverPulseStrength: gl.getUniformLocation(
|
||||
program,
|
||||
"u_hoverPulseStrength",
|
||||
),
|
||||
hoverPulseSpeed: gl.getUniformLocation(program, "u_hoverPulseSpeed"),
|
||||
resolution: gl.getUniformLocation(program, "u_resolution"),
|
||||
themeSelf: gl.getUniformLocation(program, "u_themeSelf"),
|
||||
themeFriendly: gl.getUniformLocation(program, "u_themeFriendly"),
|
||||
themeNeutral: gl.getUniformLocation(program, "u_themeNeutral"),
|
||||
themeEnemy: gl.getUniformLocation(program, "u_themeEnemy"),
|
||||
time: gl.getUniformLocation(program, "u_time"),
|
||||
debugPulse: gl.getUniformLocation(program, "u_debugPulse"),
|
||||
hoverPulse: gl.getUniformLocation(program, "u_hoverPulse"),
|
||||
};
|
||||
|
||||
if (this.uniforms.hoveredPlayerId) {
|
||||
gl.uniform1f(this.uniforms.hoveredPlayerId, -1);
|
||||
}
|
||||
if (this.uniforms.highlightStrength) {
|
||||
gl.uniform1f(
|
||||
this.uniforms.highlightStrength,
|
||||
this.hoverHighlightStrength,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.highlightColor) {
|
||||
const [r, g, b] = this.hoverHighlightColor;
|
||||
gl.uniform3f(this.uniforms.highlightColor, r, g, b);
|
||||
}
|
||||
if (this.uniforms.hoverPulseStrength) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength);
|
||||
}
|
||||
if (this.uniforms.hoverPulseSpeed) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed);
|
||||
}
|
||||
if (this.uniforms.resolution) {
|
||||
gl.uniform2f(this.uniforms.resolution, this.width, this.height);
|
||||
}
|
||||
if (this.uniforms.hoverPulse) {
|
||||
gl.uniform1i(this.uniforms.hoverPulse, 0);
|
||||
}
|
||||
this.applyThemeUniforms();
|
||||
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.viewport(0, 0, width, height);
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
return !!this.gl && !!this.program && !!this.vertexBuffer;
|
||||
}
|
||||
|
||||
setAlternativeView(enabled: boolean) {
|
||||
if (this.alternativeView === enabled) {
|
||||
return;
|
||||
}
|
||||
this.alternativeView = enabled;
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
|
||||
setHoveredPlayerId(playerSmallId: number | null) {
|
||||
const encoded = playerSmallId ?? -1;
|
||||
let changed = false;
|
||||
if (this.hoveredPlayerId !== encoded) {
|
||||
this.hoveredPlayerId = encoded;
|
||||
changed = true;
|
||||
}
|
||||
const shouldPulse = playerSmallId !== null;
|
||||
if (this.hoverPulseEnabled !== shouldPulse) {
|
||||
this.hoverPulseEnabled = shouldPulse;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
setHoverHighlightOptions(options: HoverHighlightOptions) {
|
||||
if (options.strength !== undefined) {
|
||||
this.hoverHighlightStrength = Math.max(0, Math.min(1, options.strength));
|
||||
}
|
||||
if (options.color) {
|
||||
const rgba = options.color.rgba;
|
||||
this.hoverHighlightColor = [rgba.r / 255, rgba.g / 255, rgba.b / 255];
|
||||
}
|
||||
if (options.pulseStrength !== undefined) {
|
||||
this.hoverPulseStrength = Math.max(0, Math.min(1, options.pulseStrength));
|
||||
}
|
||||
if (options.pulseSpeed !== undefined) {
|
||||
this.hoverPulseSpeed = Math.max(0, options.pulseSpeed);
|
||||
}
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
|
||||
setDebugPulseEnabled(enabled: boolean) {
|
||||
if (this.debugPulseEnabled === enabled) {
|
||||
return;
|
||||
}
|
||||
this.debugPulseEnabled = enabled;
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
|
||||
clearTile(tileIndex: number) {
|
||||
this.updateEdges(tileIndex, []);
|
||||
}
|
||||
|
||||
updateEdges(tileIndex: number, edges: BorderEdge[]) {
|
||||
const span = FrameProfiler.start();
|
||||
|
||||
if (!this.gl || !this.vertexBuffer || !this.program) {
|
||||
FrameProfiler.end(
|
||||
"TerritoryBorderWebGL:updateEdges.noContextOrProgram",
|
||||
span,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (edges.length === 0) {
|
||||
const removeSpan = FrameProfiler.start();
|
||||
this.removeTileEdges(tileIndex);
|
||||
FrameProfiler.end(
|
||||
"TerritoryBorderWebGL:updateEdges.removeTileEdges",
|
||||
removeSpan,
|
||||
);
|
||||
FrameProfiler.end("TerritoryBorderWebGL:updateEdges.total", span);
|
||||
return;
|
||||
}
|
||||
|
||||
let chunk = this.tileToChunk.get(tileIndex);
|
||||
if (chunk === undefined) {
|
||||
const addChunkSpan = FrameProfiler.start();
|
||||
chunk = this.addTileChunk(tileIndex);
|
||||
FrameProfiler.end(
|
||||
"TerritoryBorderWebGL:updateEdges.addTileChunk",
|
||||
addChunkSpan,
|
||||
);
|
||||
}
|
||||
|
||||
const writeChunkSpan = FrameProfiler.start();
|
||||
this.writeChunk(chunk, edges);
|
||||
FrameProfiler.end(
|
||||
"TerritoryBorderWebGL:updateEdges.writeChunk",
|
||||
writeChunkSpan,
|
||||
);
|
||||
this.needsRedraw = true;
|
||||
|
||||
FrameProfiler.end("TerritoryBorderWebGL:updateEdges.total", span);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.gl || !this.program || !this.vertexBuffer) {
|
||||
return;
|
||||
}
|
||||
if (this.dirtyChunks.size > 0) {
|
||||
const uploadSpan = FrameProfiler.start();
|
||||
this.uploadDirtyChunks();
|
||||
FrameProfiler.end(
|
||||
"TerritoryBorderWebGL:render.uploadDirtyChunks",
|
||||
uploadSpan,
|
||||
);
|
||||
this.needsRedraw = true;
|
||||
}
|
||||
|
||||
// Always redraw for animation, but check if we have anything to draw
|
||||
if (!this.needsRedraw && this.vertexCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gl = this.gl;
|
||||
const span = FrameProfiler.start();
|
||||
gl.useProgram(this.program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
|
||||
if (this.uniforms.alternativeView) {
|
||||
gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0);
|
||||
}
|
||||
if (this.uniforms.hoveredPlayerId) {
|
||||
gl.uniform1f(this.uniforms.hoveredPlayerId, this.hoveredPlayerId);
|
||||
}
|
||||
|
||||
// Update time uniform for blinking animation
|
||||
if (this.uniforms.time) {
|
||||
const currentTime = (Date.now() - this.animationStartTime) / 1000.0; // Convert to seconds
|
||||
gl.uniform1f(this.uniforms.time, currentTime);
|
||||
}
|
||||
|
||||
if (this.uniforms.debugPulse) {
|
||||
gl.uniform1i(this.uniforms.debugPulse, this.debugPulseEnabled ? 1 : 0);
|
||||
}
|
||||
if (this.uniforms.hoverPulse) {
|
||||
gl.uniform1i(this.uniforms.hoverPulse, this.hoverPulseEnabled ? 1 : 0);
|
||||
}
|
||||
if (this.uniforms.highlightStrength) {
|
||||
gl.uniform1f(
|
||||
this.uniforms.highlightStrength,
|
||||
this.hoverHighlightStrength,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.highlightColor) {
|
||||
const [r, g, b] = this.hoverHighlightColor;
|
||||
gl.uniform3f(this.uniforms.highlightColor, r, g, b);
|
||||
}
|
||||
if (this.uniforms.hoverPulseStrength) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength);
|
||||
}
|
||||
if (this.uniforms.hoverPulseSpeed) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed);
|
||||
}
|
||||
|
||||
const drawSpan = FrameProfiler.start();
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
if (this.vertexCount > 0) {
|
||||
gl.drawArrays(gl.LINES, 0, this.vertexCount);
|
||||
}
|
||||
FrameProfiler.end("TerritoryBorderWebGL:render.draw", drawSpan);
|
||||
|
||||
// Always mark as needing redraw for continuous animation
|
||||
this.needsRedraw = true;
|
||||
|
||||
FrameProfiler.end("TerritoryBorderWebGL:render.total", span);
|
||||
}
|
||||
|
||||
private addTileChunk(tileIndex: number): number {
|
||||
const ensureSpan = FrameProfiler.start();
|
||||
this.ensureCapacity(this.usedChunks + 1);
|
||||
FrameProfiler.end(
|
||||
"TerritoryBorderWebGL:addTileChunk.ensureCapacity",
|
||||
ensureSpan,
|
||||
);
|
||||
const chunkIndex = this.usedChunks;
|
||||
this.usedChunks++;
|
||||
this.vertexCount =
|
||||
this.usedChunks * TerritoryBorderWebGL.MAX_VERTICES_PER_TILE;
|
||||
this.tileToChunk.set(tileIndex, chunkIndex);
|
||||
this.chunkToTile[chunkIndex] = tileIndex;
|
||||
return chunkIndex;
|
||||
}
|
||||
|
||||
private removeTileEdges(tileIndex: number) {
|
||||
const span = FrameProfiler.start();
|
||||
|
||||
const chunk = this.tileToChunk.get(tileIndex);
|
||||
if (chunk === undefined) {
|
||||
FrameProfiler.end("TerritoryBorderWebGL:removeTileEdges.noChunk", span);
|
||||
return;
|
||||
}
|
||||
const lastChunk = this.usedChunks - 1;
|
||||
const lastTile = this.chunkToTile[lastChunk];
|
||||
|
||||
if (chunk !== lastChunk && lastTile !== undefined) {
|
||||
const chunkFloats = TerritoryBorderWebGL.FLOATS_PER_TILE;
|
||||
const destStart = chunk * chunkFloats;
|
||||
const srcStart = lastChunk * chunkFloats;
|
||||
this.vertexData.copyWithin(destStart, srcStart, srcStart + chunkFloats);
|
||||
this.tileToChunk.set(lastTile, chunk);
|
||||
this.chunkToTile[chunk] = lastTile;
|
||||
this.dirtyChunks.add(chunk);
|
||||
}
|
||||
|
||||
this.tileToChunk.delete(tileIndex);
|
||||
this.chunkToTile.length = Math.max(0, this.usedChunks - 1);
|
||||
this.usedChunks = Math.max(0, this.usedChunks - 1);
|
||||
this.vertexCount =
|
||||
this.usedChunks * TerritoryBorderWebGL.MAX_VERTICES_PER_TILE;
|
||||
this.needsRedraw = true;
|
||||
|
||||
if (chunk === this.usedChunks) {
|
||||
// Removed last chunk; nothing further to update.
|
||||
FrameProfiler.end(
|
||||
"TerritoryBorderWebGL:removeTileEdges.removedLastChunk",
|
||||
span,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
FrameProfiler.end("TerritoryBorderWebGL:removeTileEdges.total", span);
|
||||
}
|
||||
|
||||
private writeChunk(chunk: number, edges: BorderEdge[]) {
|
||||
const span = FrameProfiler.start();
|
||||
|
||||
const maxEdges = TerritoryBorderWebGL.MAX_EDGES_PER_TILE;
|
||||
const floatsPerVertex = TerritoryBorderWebGL.FLOATS_PER_VERTEX;
|
||||
const chunkFloats = TerritoryBorderWebGL.FLOATS_PER_TILE;
|
||||
const start = chunk * chunkFloats;
|
||||
const data = this.vertexData;
|
||||
|
||||
let cursor = start;
|
||||
let writtenVertices = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(edges.length, maxEdges); i++) {
|
||||
const edge = edges[i];
|
||||
const color = edge.color.rgba;
|
||||
const r = color.r / 255;
|
||||
const g = color.g / 255;
|
||||
const b = color.b / 255;
|
||||
const a = color.a ?? 1;
|
||||
const ownerId = edge.ownerSmallId;
|
||||
const relation = edge.relation;
|
||||
const flags = edge.flags;
|
||||
|
||||
const vertices = [
|
||||
{ x: edge.startX, y: edge.startY },
|
||||
{ x: edge.endX, y: edge.endY },
|
||||
];
|
||||
|
||||
for (const vertex of vertices) {
|
||||
data[cursor] = vertex.x;
|
||||
data[cursor + 1] = vertex.y;
|
||||
data[cursor + 2] = r;
|
||||
data[cursor + 3] = g;
|
||||
data[cursor + 4] = b;
|
||||
data[cursor + 5] = a;
|
||||
data[cursor + 6] = ownerId;
|
||||
data[cursor + 7] = relation;
|
||||
data[cursor + 8] = flags;
|
||||
cursor += floatsPerVertex;
|
||||
writtenVertices++;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingVertices =
|
||||
TerritoryBorderWebGL.MAX_VERTICES_PER_TILE - writtenVertices;
|
||||
|
||||
for (let i = 0; i < remainingVertices; i++) {
|
||||
data[cursor] = 0;
|
||||
data[cursor + 1] = 0;
|
||||
data[cursor + 2] = 0;
|
||||
data[cursor + 3] = 0;
|
||||
data[cursor + 4] = 0;
|
||||
data[cursor + 5] = 0;
|
||||
data[cursor + 6] = -1;
|
||||
data[cursor + 7] = 0;
|
||||
data[cursor + 8] = 0;
|
||||
cursor += floatsPerVertex;
|
||||
}
|
||||
|
||||
this.dirtyChunks.add(chunk);
|
||||
|
||||
FrameProfiler.end("TerritoryBorderWebGL:writeChunk", span);
|
||||
}
|
||||
|
||||
private uploadDirtyChunks() {
|
||||
if (!this.gl || !this.vertexBuffer) {
|
||||
return;
|
||||
}
|
||||
const gl = this.gl;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
const chunkFloats = TerritoryBorderWebGL.FLOATS_PER_TILE;
|
||||
for (const chunk of this.dirtyChunks) {
|
||||
if (chunk >= this.usedChunks) {
|
||||
continue;
|
||||
}
|
||||
const start = chunk * chunkFloats;
|
||||
const view = this.vertexData.subarray(start, start + chunkFloats);
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, start * 4, view);
|
||||
}
|
||||
this.dirtyChunks.clear();
|
||||
}
|
||||
|
||||
private ensureCapacity(requiredChunks: number) {
|
||||
if (requiredChunks <= this.capacityChunks) {
|
||||
return;
|
||||
}
|
||||
const span = FrameProfiler.start();
|
||||
let nextCapacity = this.capacityChunks;
|
||||
while (nextCapacity < requiredChunks) {
|
||||
nextCapacity *= 2;
|
||||
}
|
||||
// Debug: log capacity growth so we can see typical ranges in real games.
|
||||
|
||||
console.log(
|
||||
"[TerritoryBorderWebGL] growing capacityChunks",
|
||||
"from",
|
||||
this.capacityChunks,
|
||||
"to",
|
||||
nextCapacity,
|
||||
"requiredChunks=",
|
||||
requiredChunks,
|
||||
);
|
||||
const nextData = new Float32Array(
|
||||
nextCapacity * TerritoryBorderWebGL.FLOATS_PER_TILE,
|
||||
);
|
||||
nextData.set(
|
||||
this.vertexData.subarray(
|
||||
0,
|
||||
this.usedChunks * TerritoryBorderWebGL.FLOATS_PER_TILE,
|
||||
),
|
||||
);
|
||||
this.vertexData = nextData;
|
||||
this.capacityChunks = nextCapacity;
|
||||
|
||||
if (this.gl && this.vertexBuffer) {
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
this.gl.bufferData(
|
||||
this.gl.ARRAY_BUFFER,
|
||||
this.vertexData,
|
||||
this.gl.DYNAMIC_DRAW,
|
||||
);
|
||||
this.dirtyChunks.clear();
|
||||
}
|
||||
|
||||
FrameProfiler.end("TerritoryBorderWebGL:ensureCapacity.grow", span);
|
||||
}
|
||||
|
||||
private applyThemeUniforms() {
|
||||
if (!this.gl || !this.program) return;
|
||||
const gl = this.gl;
|
||||
const toVec4 = (col: Colord) => {
|
||||
const rgba = col.rgba;
|
||||
return [rgba.r / 255, rgba.g / 255, rgba.b / 255, rgba.a ?? 1];
|
||||
};
|
||||
const setColor = (location: WebGLUniformLocation | null, col: Colord) => {
|
||||
if (!location) return;
|
||||
const vec = toVec4(col);
|
||||
gl.uniform4f(location, vec[0], vec[1], vec[2], vec[3]);
|
||||
};
|
||||
setColor(this.uniforms.themeSelf, this.theme.selfColor());
|
||||
setColor(this.uniforms.themeFriendly, this.theme.allyColor());
|
||||
setColor(this.uniforms.themeNeutral, this.theme.neutralColor());
|
||||
setColor(this.uniforms.themeEnemy, this.theme.enemyColor());
|
||||
}
|
||||
|
||||
private compileShader(type: number, source: string): WebGLShader | null {
|
||||
if (!this.gl) return null;
|
||||
const shader = this.gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
this.gl.shaderSource(shader, source);
|
||||
this.gl.compileShader(shader);
|
||||
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||
console.error(
|
||||
"TerritoryBorderWebGL shader error",
|
||||
this.gl.getShaderInfoLog(shader),
|
||||
);
|
||||
this.gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
private createProgram(
|
||||
vertexShader: WebGLShader | null,
|
||||
fragmentShader: WebGLShader | null,
|
||||
): WebGLProgram | null {
|
||||
if (!this.gl || !vertexShader || !fragmentShader) return null;
|
||||
const program = this.gl.createProgram();
|
||||
if (!program) return null;
|
||||
this.gl.attachShader(program, vertexShader);
|
||||
this.gl.attachShader(program, fragmentShader);
|
||||
this.gl.linkProgram(program);
|
||||
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
|
||||
console.error(
|
||||
"TerritoryBorderWebGL link error",
|
||||
this.gl.getProgramInfoLog(program),
|
||||
);
|
||||
this.gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,17 @@ import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import {
|
||||
AlternateViewEvent,
|
||||
ContextMenuEvent,
|
||||
DragEvent,
|
||||
MouseOverEvent,
|
||||
TerritoryWebGLStatusEvent,
|
||||
ToggleTerritoryWebGLEvent,
|
||||
} from "../../InputHandler";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { resolveHoverTarget } from "../HoverTargetResolver";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer";
|
||||
|
||||
export class TerritoryLayer implements Layer {
|
||||
private userSettings: UserSettings;
|
||||
@@ -47,6 +52,7 @@ export class TerritoryLayer implements Layer {
|
||||
private highlightContext: CanvasRenderingContext2D;
|
||||
|
||||
private highlightedTerritory: PlayerView | null = null;
|
||||
private territoryRenderer: TerritoryWebGLRenderer | null = null;
|
||||
|
||||
private alternativeView = false;
|
||||
private lastDragTime = 0;
|
||||
@@ -57,6 +63,9 @@ export class TerritoryLayer implements Layer {
|
||||
private lastRefresh = 0;
|
||||
|
||||
private lastFocusedPlayer: PlayerView | null = null;
|
||||
private lastMyPlayerSmallId: number | null = null;
|
||||
private useWebGL: boolean;
|
||||
private webglSupported = true;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -67,6 +76,8 @@ export class TerritoryLayer implements Layer {
|
||||
this.userSettings = userSettings;
|
||||
this.theme = game.config().theme();
|
||||
this.cachedTerritoryPatternsEnabled = undefined;
|
||||
this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null;
|
||||
this.useWebGL = this.userSettings.territoryWebGL();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
@@ -88,6 +99,11 @@ export class TerritoryLayer implements Layer {
|
||||
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
|
||||
const playerUpdates =
|
||||
updates !== null ? updates[GameUpdateType.Player] : [];
|
||||
if (playerUpdates.length > 0) {
|
||||
this.territoryRenderer?.refreshPalette();
|
||||
}
|
||||
unitUpdates.forEach((update) => {
|
||||
if (update.unitType === UnitType.DefensePost) {
|
||||
// Only update borders if the defense post is not under construction
|
||||
@@ -153,14 +169,21 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
const focusedPlayer = this.game.focusedPlayer();
|
||||
if (focusedPlayer !== this.lastFocusedPlayer) {
|
||||
if (this.lastFocusedPlayer) {
|
||||
this.paintPlayerBorder(this.lastFocusedPlayer);
|
||||
}
|
||||
if (focusedPlayer) {
|
||||
this.paintPlayerBorder(focusedPlayer);
|
||||
if (!this.territoryRenderer) {
|
||||
if (this.lastFocusedPlayer) {
|
||||
this.paintPlayerBorder(this.lastFocusedPlayer);
|
||||
}
|
||||
if (focusedPlayer) {
|
||||
this.paintPlayerBorder(focusedPlayer);
|
||||
}
|
||||
}
|
||||
this.lastFocusedPlayer = focusedPlayer;
|
||||
}
|
||||
|
||||
const currentMyPlayer = this.game.myPlayer()?.smallID() ?? null;
|
||||
if (currentMyPlayer !== this.lastMyPlayerSmallId) {
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private spawnHighlight() {
|
||||
@@ -267,8 +290,18 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
init() {
|
||||
this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
|
||||
this.eventBus.on(ContextMenuEvent, (e) => this.onMouseOver(e));
|
||||
this.eventBus.on(AlternateViewEvent, (e) => {
|
||||
this.alternativeView = e.alternateView;
|
||||
this.territoryRenderer?.setAlternativeView(this.alternativeView);
|
||||
this.territoryRenderer?.setHoverHighlightOptions(
|
||||
this.hoverHighlightOptions(),
|
||||
);
|
||||
});
|
||||
this.eventBus.on(ToggleTerritoryWebGLEvent, () => {
|
||||
this.userSettings.toggleTerritoryWebGL();
|
||||
this.useWebGL = this.userSettings.territoryWebGL();
|
||||
this.redraw();
|
||||
});
|
||||
this.eventBus.on(DragEvent, (e) => {
|
||||
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
|
||||
@@ -283,7 +316,9 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
private updateHighlightedTerritory() {
|
||||
if (!this.alternativeView) {
|
||||
const supportsHover =
|
||||
this.alternativeView || this.territoryRenderer !== null;
|
||||
if (!supportsHover) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -300,7 +335,7 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
const previousTerritory = this.highlightedTerritory;
|
||||
const territory = this.getTerritoryAtCell(cell);
|
||||
const territory = resolveHoverTarget(this.game, cell).player;
|
||||
|
||||
if (territory) {
|
||||
this.highlightedTerritory = territory;
|
||||
@@ -309,32 +344,26 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
|
||||
const territories: PlayerView[] = [];
|
||||
if (previousTerritory) {
|
||||
territories.push(previousTerritory);
|
||||
if (this.territoryRenderer) {
|
||||
this.territoryRenderer.setHoveredPlayerId(
|
||||
this.highlightedTerritory?.smallID() ?? null,
|
||||
);
|
||||
} else {
|
||||
const territories: PlayerView[] = [];
|
||||
if (previousTerritory) {
|
||||
territories.push(previousTerritory);
|
||||
}
|
||||
if (this.highlightedTerritory) {
|
||||
territories.push(this.highlightedTerritory);
|
||||
}
|
||||
this.redrawBorder(...territories);
|
||||
}
|
||||
if (this.highlightedTerritory) {
|
||||
territories.push(this.highlightedTerritory);
|
||||
}
|
||||
this.redrawBorder(...territories);
|
||||
}
|
||||
}
|
||||
|
||||
private getTerritoryAtCell(cell: { x: number; y: number }) {
|
||||
const tile = this.game.ref(cell.x, cell.y);
|
||||
if (!tile) {
|
||||
return null;
|
||||
}
|
||||
// If the tile has no owner, it is either a fallout tile or a terra nullius tile.
|
||||
if (!this.game.hasOwner(tile)) {
|
||||
return null;
|
||||
}
|
||||
const owner = this.game.owner(tile);
|
||||
return owner instanceof PlayerView ? owner : null;
|
||||
}
|
||||
|
||||
redraw() {
|
||||
console.log("redrew territory layer");
|
||||
this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null;
|
||||
this.canvas = document.createElement("canvas");
|
||||
const context = this.canvas.getContext("2d");
|
||||
if (context === null) throw new Error("2d context not supported");
|
||||
@@ -362,6 +391,8 @@ export class TerritoryLayer implements Layer {
|
||||
0,
|
||||
);
|
||||
|
||||
this.configureRenderers();
|
||||
|
||||
// Add a second canvas for highlights
|
||||
this.highlightCanvas = document.createElement("canvas");
|
||||
const highlightContext = this.highlightCanvas.getContext("2d", {
|
||||
@@ -377,6 +408,90 @@ export class TerritoryLayer implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private configureRenderers() {
|
||||
this.territoryRenderer = null;
|
||||
|
||||
if (!this.useWebGL) {
|
||||
this.webglSupported = true;
|
||||
this.emitWebGLStatus(
|
||||
false,
|
||||
false,
|
||||
this.webglSupported,
|
||||
"WebGL territory layer hidden.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { renderer, reason } = TerritoryWebGLRenderer.create(
|
||||
this.game,
|
||||
this.theme,
|
||||
);
|
||||
this.territoryRenderer = renderer;
|
||||
if (this.territoryRenderer) {
|
||||
this.territoryRenderer.setAlternativeView(this.alternativeView);
|
||||
this.territoryRenderer.markAllDirty();
|
||||
this.territoryRenderer.refreshPalette();
|
||||
this.territoryRenderer.setHoverHighlightOptions(
|
||||
this.hoverHighlightOptions(),
|
||||
);
|
||||
this.territoryRenderer.setHoveredPlayerId(
|
||||
this.highlightedTerritory?.smallID() ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
const supported = this.territoryRenderer !== null;
|
||||
const active = this.territoryRenderer !== null;
|
||||
const fallbackReason =
|
||||
reason ??
|
||||
"WebGL not available. Using canvas fallback for borders and fill.";
|
||||
|
||||
this.webglSupported = supported;
|
||||
this.emitWebGLStatus(
|
||||
true,
|
||||
active,
|
||||
supported,
|
||||
active ? undefined : fallbackReason,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Central configuration for WebGL border hover styling.
|
||||
* Keeps main view and alternate view behavior explicit and tweakable.
|
||||
*/
|
||||
private hoverHighlightOptions() {
|
||||
const baseColor = this.theme.spawnHighlightSelfColor();
|
||||
const rgba = baseColor.rgba;
|
||||
|
||||
if (this.alternativeView) {
|
||||
// Alternate view: borders are the primary visual, so make hover stronger
|
||||
return {
|
||||
color: { r: rgba.r, g: rgba.g, b: rgba.b },
|
||||
strength: 0.8,
|
||||
pulseStrength: 0.45,
|
||||
pulseSpeed: Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
// Main view: keep highlight noticeable but a bit subtler
|
||||
return {
|
||||
color: { r: rgba.r, g: rgba.g, b: rgba.b },
|
||||
strength: 0.6,
|
||||
pulseStrength: 0.35,
|
||||
pulseSpeed: Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
private emitWebGLStatus(
|
||||
enabled: boolean,
|
||||
active: boolean,
|
||||
supported: boolean,
|
||||
message?: string,
|
||||
) {
|
||||
this.eventBus.emit(
|
||||
new TerritoryWebGLStatusEvent(enabled, active, supported, message),
|
||||
);
|
||||
}
|
||||
|
||||
redrawBorder(...players: PlayerView[]) {
|
||||
return Promise.all(
|
||||
players.map(async (player) => {
|
||||
@@ -400,6 +515,9 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
const now = Date.now();
|
||||
const gpuTerritoryActive = this.territoryRenderer !== null;
|
||||
const skipTerritoryCanvas = gpuTerritoryActive;
|
||||
|
||||
if (
|
||||
now > this.lastDragTime + this.nodrawDragDuration &&
|
||||
now > this.lastRefresh + this.refreshRate
|
||||
@@ -418,7 +536,13 @@ export class TerritoryLayer implements Layer {
|
||||
const w = vx1 - vx0 + 1;
|
||||
const h = vy1 - vy0 + 1;
|
||||
|
||||
if (w > 0 && h > 0) {
|
||||
// When WebGL borders are active and we're in alternative view, the 2D
|
||||
// territory buffer (alternativeImageData) is effectively transparent and
|
||||
// all visible work is done by the WebGL layer. Skip putImageData in that
|
||||
// case to avoid unnecessary CPU work each frame.
|
||||
const shouldBlitTerritories = !skipTerritoryCanvas && !gpuTerritoryActive;
|
||||
|
||||
if (w > 0 && h > 0 && shouldBlitTerritories) {
|
||||
const putImageStart = FrameProfiler.start();
|
||||
this.context.putImageData(
|
||||
this.alternativeView ? this.alternativeImageData : this.imageData,
|
||||
@@ -433,15 +557,37 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
|
||||
if (gpuTerritoryActive) {
|
||||
const webglRenderStart = FrameProfiler.start();
|
||||
this.territoryRenderer?.render();
|
||||
FrameProfiler.end(
|
||||
"TerritoryLayer:territoryWebGL.render",
|
||||
webglRenderStart,
|
||||
);
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.territoryRenderer!.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end(
|
||||
"TerritoryLayer:territoryWebGL.drawImage",
|
||||
drawCanvasStart,
|
||||
);
|
||||
} else if (!skipTerritoryCanvas) {
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
|
||||
}
|
||||
|
||||
if (this.game.inSpawnPhase()) {
|
||||
const highlightDrawStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
@@ -475,63 +621,71 @@ export class TerritoryLayer implements Layer {
|
||||
const tile = entry.tile;
|
||||
this.paintTerritory(tile);
|
||||
for (const neighbor of this.game.neighbors(tile)) {
|
||||
this.paintTerritory(neighbor, true);
|
||||
this.paintTerritory(neighbor, true); //this is a misuse of the _Border parameter, making it a maybe stale border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paintTerritory(tile: TileRef, isBorder: boolean = false) {
|
||||
if (isBorder && !this.game.hasOwner(tile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.game.hasOwner(tile)) {
|
||||
if (this.game.hasFallout(tile)) {
|
||||
this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150);
|
||||
this.paintTile(
|
||||
this.alternativeImageData,
|
||||
tile,
|
||||
this.theme.falloutColor(),
|
||||
150,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.clearTile(tile);
|
||||
return;
|
||||
}
|
||||
const owner = this.game.owner(tile) as PlayerView;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const isHighlighted =
|
||||
this.highlightedTerritory &&
|
||||
this.highlightedTerritory.id() === owner.id();
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
if (this.game.isBorder(tile)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const playerIsFocused = owner && this.game.focusedPlayer() === owner;
|
||||
if (myPlayer) {
|
||||
const alternativeColor = this.alternateViewColor(owner);
|
||||
this.paintTile(this.alternativeImageData, tile, alternativeColor, 255);
|
||||
}
|
||||
const isDefended = this.game.hasUnitNearby(
|
||||
paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) {
|
||||
const cpuStart = FrameProfiler.start();
|
||||
const useGpuTerritory = this.territoryRenderer !== null;
|
||||
const hasOwner = this.game.hasOwner(tile);
|
||||
const owner = hasOwner ? (this.game.owner(tile) as PlayerView) : null;
|
||||
const isBorderTile = this.game.isBorder(tile);
|
||||
const hasFallout = this.game.hasFallout(tile);
|
||||
let isDefended = false;
|
||||
if (owner && isBorderTile) {
|
||||
isDefended = this.game.hasUnitNearby(
|
||||
tile,
|
||||
this.game.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
|
||||
this.paintTile(
|
||||
this.imageData,
|
||||
tile,
|
||||
owner.borderColor(tile, isDefended),
|
||||
255,
|
||||
);
|
||||
} else {
|
||||
// Alternative view only shows borders.
|
||||
this.clearAlternativeTile(tile);
|
||||
|
||||
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
|
||||
}
|
||||
|
||||
if (useGpuTerritory) {
|
||||
this.territoryRenderer?.markTile(tile);
|
||||
} else {
|
||||
if (!owner) {
|
||||
if (hasFallout) {
|
||||
this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150);
|
||||
this.paintTile(
|
||||
this.alternativeImageData,
|
||||
tile,
|
||||
this.theme.falloutColor(),
|
||||
150,
|
||||
);
|
||||
} else {
|
||||
this.clearTile(tile);
|
||||
}
|
||||
} else {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
if (isBorderTile) {
|
||||
if (myPlayer) {
|
||||
const alternativeColor = this.alternateViewColor(owner);
|
||||
this.paintTile(
|
||||
this.alternativeImageData,
|
||||
tile,
|
||||
alternativeColor,
|
||||
255,
|
||||
);
|
||||
}
|
||||
this.paintTile(
|
||||
this.imageData,
|
||||
tile,
|
||||
owner.borderColor(tile, isDefended),
|
||||
255,
|
||||
);
|
||||
} else {
|
||||
// Alternative view only shows borders.
|
||||
this.clearAlternativeTile(tile);
|
||||
|
||||
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
FrameProfiler.end("TerritoryLayer:paintTerritory.cpu", cpuStart);
|
||||
}
|
||||
|
||||
alternateViewColor(other: PlayerView): Colord {
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
|
||||
type DirtySpan = { minX: number; maxX: number };
|
||||
|
||||
export interface TerritoryWebGLCreateResult {
|
||||
renderer: TerritoryWebGLRenderer | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HoverHighlightOptions {
|
||||
color?: { r: number; g: number; b: number };
|
||||
strength?: number;
|
||||
pulseStrength?: number;
|
||||
pulseSpeed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebGL2 territory renderer that reads the shared tile state buffer
|
||||
* (SharedArrayBuffer) and shades tiles via a small palette texture.
|
||||
* Borders are still drawn by the dedicated border renderer; this class
|
||||
* only fills territory / fallout tiles.
|
||||
*/
|
||||
export class TerritoryWebGLRenderer {
|
||||
public readonly canvas: HTMLCanvasElement;
|
||||
|
||||
private readonly gl: WebGL2RenderingContext | null;
|
||||
private readonly program: WebGLProgram | null;
|
||||
private readonly vao: WebGLVertexArrayObject | null;
|
||||
private readonly vertexBuffer: WebGLBuffer | null;
|
||||
private readonly stateTexture: WebGLTexture | null;
|
||||
private readonly paletteTexture: WebGLTexture | null;
|
||||
private readonly relationTexture: WebGLTexture | null;
|
||||
private readonly uniforms: {
|
||||
resolution: WebGLUniformLocation | null;
|
||||
state: WebGLUniformLocation | null;
|
||||
palette: WebGLUniformLocation | null;
|
||||
relations: WebGLUniformLocation | null;
|
||||
fallout: WebGLUniformLocation | null;
|
||||
altSelf: WebGLUniformLocation | null;
|
||||
altAlly: WebGLUniformLocation | null;
|
||||
altNeutral: WebGLUniformLocation | null;
|
||||
altEnemy: WebGLUniformLocation | null;
|
||||
alpha: WebGLUniformLocation | null;
|
||||
alternativeView: WebGLUniformLocation | null;
|
||||
hoveredPlayerId: WebGLUniformLocation | null;
|
||||
hoverHighlightStrength: WebGLUniformLocation | null;
|
||||
hoverHighlightColor: WebGLUniformLocation | null;
|
||||
hoverPulseStrength: WebGLUniformLocation | null;
|
||||
hoverPulseSpeed: WebGLUniformLocation | null;
|
||||
time: WebGLUniformLocation | null;
|
||||
};
|
||||
|
||||
private readonly state: Uint16Array;
|
||||
private readonly dirtyRows: Map<number, DirtySpan> = new Map();
|
||||
private needsFullUpload = true;
|
||||
private alternativeView = false;
|
||||
private paletteWidth = 0;
|
||||
private hoverHighlightStrength = 0.7;
|
||||
private hoverHighlightColor: [number, number, number] = [1, 1, 1];
|
||||
private hoverPulseStrength = 0.25;
|
||||
private hoverPulseSpeed = Math.PI * 2;
|
||||
private hoveredPlayerId = -1;
|
||||
private animationStartTime = Date.now();
|
||||
|
||||
private constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly theme: Theme,
|
||||
sharedState: SharedArrayBuffer,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.width = game.width();
|
||||
this.canvas.height = game.height();
|
||||
|
||||
this.state = new Uint16Array(sharedState);
|
||||
|
||||
this.gl = this.canvas.getContext("webgl2", {
|
||||
premultipliedAlpha: true,
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
if (!this.gl) {
|
||||
this.program = null;
|
||||
this.vao = null;
|
||||
this.vertexBuffer = null;
|
||||
this.stateTexture = null;
|
||||
this.paletteTexture = null;
|
||||
this.relationTexture = null;
|
||||
this.uniforms = {
|
||||
resolution: null,
|
||||
state: null,
|
||||
palette: null,
|
||||
relations: null,
|
||||
fallout: null,
|
||||
altSelf: null,
|
||||
altAlly: null,
|
||||
altNeutral: null,
|
||||
altEnemy: null,
|
||||
alpha: null,
|
||||
alternativeView: null,
|
||||
hoveredPlayerId: null,
|
||||
hoverHighlightStrength: null,
|
||||
hoverHighlightColor: null,
|
||||
hoverPulseStrength: null,
|
||||
hoverPulseSpeed: null,
|
||||
time: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const gl = this.gl;
|
||||
this.program = this.createProgram(gl);
|
||||
if (!this.program) {
|
||||
this.vao = null;
|
||||
this.vertexBuffer = null;
|
||||
this.stateTexture = null;
|
||||
this.paletteTexture = null;
|
||||
this.relationTexture = null;
|
||||
this.uniforms = {
|
||||
resolution: null,
|
||||
state: null,
|
||||
palette: null,
|
||||
relations: null,
|
||||
fallout: null,
|
||||
altSelf: null,
|
||||
altAlly: null,
|
||||
altNeutral: null,
|
||||
altEnemy: null,
|
||||
alpha: null,
|
||||
alternativeView: null,
|
||||
hoveredPlayerId: null,
|
||||
hoverHighlightStrength: null,
|
||||
hoverHighlightColor: null,
|
||||
hoverPulseStrength: null,
|
||||
hoverPulseSpeed: null,
|
||||
time: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.uniforms = {
|
||||
resolution: gl.getUniformLocation(this.program, "u_resolution"),
|
||||
state: gl.getUniformLocation(this.program, "u_state"),
|
||||
palette: gl.getUniformLocation(this.program, "u_palette"),
|
||||
relations: gl.getUniformLocation(this.program, "u_relations"),
|
||||
fallout: gl.getUniformLocation(this.program, "u_fallout"),
|
||||
altSelf: gl.getUniformLocation(this.program, "u_altSelf"),
|
||||
altAlly: gl.getUniformLocation(this.program, "u_altAlly"),
|
||||
altNeutral: gl.getUniformLocation(this.program, "u_altNeutral"),
|
||||
altEnemy: gl.getUniformLocation(this.program, "u_altEnemy"),
|
||||
alpha: gl.getUniformLocation(this.program, "u_alpha"),
|
||||
alternativeView: gl.getUniformLocation(this.program, "u_alternativeView"),
|
||||
hoveredPlayerId: gl.getUniformLocation(this.program, "u_hoveredPlayerId"),
|
||||
hoverHighlightStrength: gl.getUniformLocation(
|
||||
this.program,
|
||||
"u_hoverHighlightStrength",
|
||||
),
|
||||
hoverHighlightColor: gl.getUniformLocation(
|
||||
this.program,
|
||||
"u_hoverHighlightColor",
|
||||
),
|
||||
hoverPulseStrength: gl.getUniformLocation(
|
||||
this.program,
|
||||
"u_hoverPulseStrength",
|
||||
),
|
||||
hoverPulseSpeed: gl.getUniformLocation(this.program, "u_hoverPulseSpeed"),
|
||||
time: gl.getUniformLocation(this.program, "u_time"),
|
||||
};
|
||||
|
||||
// Vertex data: two triangles covering the full map (pixel-perfect).
|
||||
const vertices = new Float32Array([
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
0,
|
||||
0,
|
||||
this.canvas.height,
|
||||
0,
|
||||
this.canvas.height,
|
||||
this.canvas.width,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
]);
|
||||
|
||||
this.vao = gl.createVertexArray();
|
||||
this.vertexBuffer = gl.createBuffer();
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
|
||||
|
||||
const posLoc = gl.getAttribLocation(this.program, "a_position");
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 2 * 4, 0);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
this.stateTexture = gl.createTexture();
|
||||
this.paletteTexture = gl.createTexture();
|
||||
this.relationTexture = gl.createTexture();
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
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.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R16UI,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
0,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_SHORT,
|
||||
this.state,
|
||||
);
|
||||
|
||||
this.uploadPalette();
|
||||
|
||||
gl.useProgram(this.program);
|
||||
gl.uniform1i(this.uniforms.state, 0);
|
||||
gl.uniform1i(this.uniforms.palette, 1);
|
||||
gl.uniform1i(this.uniforms.relations, 2);
|
||||
|
||||
if (this.uniforms.resolution) {
|
||||
gl.uniform2f(
|
||||
this.uniforms.resolution,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.alpha) {
|
||||
gl.uniform1f(this.uniforms.alpha, 150 / 255);
|
||||
}
|
||||
if (this.uniforms.fallout) {
|
||||
const f = this.theme.falloutColor().rgba;
|
||||
gl.uniform4f(
|
||||
this.uniforms.fallout,
|
||||
f.r / 255,
|
||||
f.g / 255,
|
||||
f.b / 255,
|
||||
f.a ?? 1,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.altSelf) {
|
||||
const c = this.theme.selfColor().rgba;
|
||||
gl.uniform4f(
|
||||
this.uniforms.altSelf,
|
||||
c.r / 255,
|
||||
c.g / 255,
|
||||
c.b / 255,
|
||||
c.a ?? 1,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.altAlly) {
|
||||
const c = this.theme.allyColor().rgba;
|
||||
gl.uniform4f(
|
||||
this.uniforms.altAlly,
|
||||
c.r / 255,
|
||||
c.g / 255,
|
||||
c.b / 255,
|
||||
c.a ?? 1,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.altNeutral) {
|
||||
const c = this.theme.neutralColor().rgba;
|
||||
gl.uniform4f(
|
||||
this.uniforms.altNeutral,
|
||||
c.r / 255,
|
||||
c.g / 255,
|
||||
c.b / 255,
|
||||
c.a ?? 1,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.altEnemy) {
|
||||
const c = this.theme.enemyColor().rgba;
|
||||
gl.uniform4f(
|
||||
this.uniforms.altEnemy,
|
||||
c.r / 255,
|
||||
c.g / 255,
|
||||
c.b / 255,
|
||||
c.a ?? 1,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.alternativeView) {
|
||||
gl.uniform1i(this.uniforms.alternativeView, 0);
|
||||
}
|
||||
if (this.uniforms.hoveredPlayerId) {
|
||||
gl.uniform1f(this.uniforms.hoveredPlayerId, -1);
|
||||
}
|
||||
if (this.uniforms.hoverHighlightStrength) {
|
||||
gl.uniform1f(
|
||||
this.uniforms.hoverHighlightStrength,
|
||||
this.hoverHighlightStrength,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.hoverHighlightColor) {
|
||||
const [r, g, b] = this.hoverHighlightColor;
|
||||
gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b);
|
||||
}
|
||||
if (this.uniforms.hoverPulseStrength) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength);
|
||||
}
|
||||
if (this.uniforms.hoverPulseSpeed) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed);
|
||||
}
|
||||
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult {
|
||||
const sharedState = game.sharedStateBuffer();
|
||||
if (!sharedState) {
|
||||
return {
|
||||
renderer: null,
|
||||
reason:
|
||||
"Shared tile state not available. WebGL territory renderer needs SharedArrayBuffer.",
|
||||
};
|
||||
}
|
||||
|
||||
const expected = game.width() * game.height();
|
||||
if (new Uint16Array(sharedState).length !== expected) {
|
||||
return {
|
||||
renderer: null,
|
||||
reason:
|
||||
"Shared tile buffer size mismatch; falling back to canvas territory draw.",
|
||||
};
|
||||
}
|
||||
|
||||
const renderer = new TerritoryWebGLRenderer(game, theme, sharedState);
|
||||
if (!renderer.isValid()) {
|
||||
return {
|
||||
renderer: null,
|
||||
reason: "WebGL2 not available; falling back to canvas territory draw.",
|
||||
};
|
||||
}
|
||||
return { renderer };
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
return !!this.gl && !!this.program && !!this.vao;
|
||||
}
|
||||
|
||||
setAlternativeView(enabled: boolean) {
|
||||
this.alternativeView = enabled;
|
||||
}
|
||||
|
||||
setHoveredPlayerId(playerSmallId: number | null) {
|
||||
const encoded = playerSmallId ?? -1;
|
||||
this.hoveredPlayerId = encoded;
|
||||
}
|
||||
|
||||
setHoverHighlightOptions(options: HoverHighlightOptions) {
|
||||
if (options.strength !== undefined) {
|
||||
this.hoverHighlightStrength = Math.max(0, Math.min(1, options.strength));
|
||||
}
|
||||
if (options.color) {
|
||||
this.hoverHighlightColor = [
|
||||
options.color.r / 255,
|
||||
options.color.g / 255,
|
||||
options.color.b / 255,
|
||||
];
|
||||
}
|
||||
if (options.pulseStrength !== undefined) {
|
||||
this.hoverPulseStrength = Math.max(0, Math.min(1, options.pulseStrength));
|
||||
}
|
||||
if (options.pulseSpeed !== undefined) {
|
||||
this.hoverPulseSpeed = Math.max(0, options.pulseSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
markTile(tile: TileRef) {
|
||||
if (this.needsFullUpload) {
|
||||
return;
|
||||
}
|
||||
const x = tile % this.canvas.width;
|
||||
const y = Math.floor(tile / this.canvas.width);
|
||||
const span = this.dirtyRows.get(y);
|
||||
if (span === undefined) {
|
||||
this.dirtyRows.set(y, { minX: x, maxX: x });
|
||||
} else {
|
||||
span.minX = Math.min(span.minX, x);
|
||||
span.maxX = Math.max(span.maxX, x);
|
||||
}
|
||||
}
|
||||
|
||||
markAllDirty() {
|
||||
this.needsFullUpload = true;
|
||||
this.dirtyRows.clear();
|
||||
}
|
||||
|
||||
refreshPalette() {
|
||||
if (!this.gl || !this.paletteTexture || !this.relationTexture) {
|
||||
return;
|
||||
}
|
||||
this.uploadPalette();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.gl || !this.program || !this.vao) {
|
||||
return;
|
||||
}
|
||||
const gl = this.gl;
|
||||
|
||||
const uploadSpan = FrameProfiler.start();
|
||||
this.uploadStateTexture();
|
||||
FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadSpan);
|
||||
|
||||
const renderSpan = FrameProfiler.start();
|
||||
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
gl.useProgram(this.program);
|
||||
gl.bindVertexArray(this.vao);
|
||||
if (this.uniforms.alternativeView) {
|
||||
gl.uniform1i(this.uniforms.alternativeView, this.alternativeView ? 1 : 0);
|
||||
}
|
||||
if (this.uniforms.hoveredPlayerId) {
|
||||
gl.uniform1f(this.uniforms.hoveredPlayerId, this.hoveredPlayerId);
|
||||
}
|
||||
if (this.uniforms.hoverHighlightStrength) {
|
||||
gl.uniform1f(
|
||||
this.uniforms.hoverHighlightStrength,
|
||||
this.hoverHighlightStrength,
|
||||
);
|
||||
}
|
||||
if (this.uniforms.hoverHighlightColor) {
|
||||
const [r, g, b] = this.hoverHighlightColor;
|
||||
gl.uniform3f(this.uniforms.hoverHighlightColor, r, g, b);
|
||||
}
|
||||
if (this.uniforms.hoverPulseStrength) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseStrength, this.hoverPulseStrength);
|
||||
}
|
||||
if (this.uniforms.hoverPulseSpeed) {
|
||||
gl.uniform1f(this.uniforms.hoverPulseSpeed, this.hoverPulseSpeed);
|
||||
}
|
||||
if (this.uniforms.time) {
|
||||
const currentTime = (Date.now() - this.animationStartTime) / 1000.0;
|
||||
gl.uniform1f(this.uniforms.time, currentTime);
|
||||
}
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.bindVertexArray(null);
|
||||
FrameProfiler.end("TerritoryWebGLRenderer:draw", renderSpan);
|
||||
}
|
||||
|
||||
private uploadStateTexture() {
|
||||
if (!this.gl || !this.stateTexture) return;
|
||||
const gl = this.gl;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
|
||||
|
||||
if (this.needsFullUpload) {
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R16UI,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
0,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_SHORT,
|
||||
this.state,
|
||||
);
|
||||
this.needsFullUpload = false;
|
||||
this.dirtyRows.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dirtyRows.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [y, span] of this.dirtyRows) {
|
||||
const width = span.maxX - span.minX + 1;
|
||||
const offset = y * this.canvas.width + span.minX;
|
||||
const rowSlice = this.state.subarray(offset, offset + width);
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
span.minX,
|
||||
y,
|
||||
width,
|
||||
1,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_SHORT,
|
||||
rowSlice,
|
||||
);
|
||||
}
|
||||
this.dirtyRows.clear();
|
||||
}
|
||||
|
||||
private uploadPalette() {
|
||||
if (!this.gl || !this.paletteTexture || !this.relationTexture) return;
|
||||
const gl = this.gl;
|
||||
const players = this.game.playerViews().filter((p) => p.isPlayer());
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
const maxId = players.reduce((max, p) => Math.max(max, p.smallID()), 0) + 1;
|
||||
this.paletteWidth = Math.max(maxId, 1);
|
||||
|
||||
const paletteData = new Uint8Array(this.paletteWidth * 4);
|
||||
const relationData = new Uint8Array(this.paletteWidth);
|
||||
|
||||
for (const p of players) {
|
||||
const id = p.smallID();
|
||||
const rgba = p.territoryColor().rgba;
|
||||
paletteData[id * 4] = rgba.r;
|
||||
paletteData[id * 4 + 1] = rgba.g;
|
||||
paletteData[id * 4 + 2] = rgba.b;
|
||||
paletteData[id * 4 + 3] = Math.round((rgba.a ?? 1) * 255);
|
||||
|
||||
relationData[id] = this.resolveRelationCode(p, myPlayer);
|
||||
}
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
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.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA8,
|
||||
this.paletteWidth,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
paletteData,
|
||||
);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE2);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.relationTexture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
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.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R8UI,
|
||||
this.paletteWidth,
|
||||
1,
|
||||
0,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
relationData,
|
||||
);
|
||||
}
|
||||
|
||||
private resolveRelationCode(
|
||||
owner: PlayerView,
|
||||
myPlayer: PlayerView | null,
|
||||
): number {
|
||||
if (!myPlayer) {
|
||||
return 3; // Neutral
|
||||
}
|
||||
if (owner.smallID() === myPlayer.smallID()) {
|
||||
return 1; // Self
|
||||
}
|
||||
if (owner.isFriendly(myPlayer)) {
|
||||
return 2; // Ally
|
||||
}
|
||||
if (!owner.hasEmbargo(myPlayer)) {
|
||||
return 3; // Neutral
|
||||
}
|
||||
return 4; // Enemy
|
||||
}
|
||||
|
||||
private createProgram(gl: WebGL2RenderingContext): WebGLProgram | null {
|
||||
const vertexShaderSource = `#version 300 es
|
||||
precision mediump float;
|
||||
in vec2 a_position;
|
||||
uniform vec2 u_resolution;
|
||||
void main() {
|
||||
vec2 zeroToOne = a_position / u_resolution;
|
||||
vec2 clipSpace = zeroToOne * 2.0 - 1.0;
|
||||
clipSpace.y = -clipSpace.y;
|
||||
gl_Position = vec4(clipSpace, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShaderSource = `#version 300 es
|
||||
precision mediump float;
|
||||
precision highp usampler2D;
|
||||
|
||||
uniform usampler2D u_state;
|
||||
uniform sampler2D u_palette;
|
||||
uniform usampler2D u_relations;
|
||||
uniform vec2 u_resolution;
|
||||
uniform vec4 u_fallout;
|
||||
uniform vec4 u_altSelf;
|
||||
uniform vec4 u_altAlly;
|
||||
uniform vec4 u_altNeutral;
|
||||
uniform vec4 u_altEnemy;
|
||||
uniform float u_alpha;
|
||||
uniform bool u_alternativeView;
|
||||
uniform float u_hoveredPlayerId;
|
||||
uniform vec3 u_hoverHighlightColor;
|
||||
uniform float u_hoverHighlightStrength;
|
||||
uniform float u_hoverPulseStrength;
|
||||
uniform float u_hoverPulseSpeed;
|
||||
uniform float u_time;
|
||||
|
||||
out vec4 outColor;
|
||||
|
||||
uint ownerAtTex(ivec2 texCoord) {
|
||||
ivec2 clamped = clamp(
|
||||
texCoord,
|
||||
ivec2(0, 0),
|
||||
ivec2(int(u_resolution.x) - 1, int(u_resolution.y) - 1)
|
||||
);
|
||||
return texelFetch(u_state, clamped, 0).r & 0xFFFu;
|
||||
}
|
||||
|
||||
void main() {
|
||||
ivec2 fragCoord = ivec2(gl_FragCoord.xy);
|
||||
// gl_FragCoord origin is bottom-left; flip Y to match top-left oriented buffers.
|
||||
ivec2 texCoord = ivec2(fragCoord.x, int(u_resolution.y) - 1 - fragCoord.y);
|
||||
|
||||
uint state = texelFetch(u_state, texCoord, 0).r;
|
||||
uint owner = state & 0xFFFu;
|
||||
bool hasFallout = (state & 0x2000u) != 0u; // bit 13
|
||||
|
||||
if (owner == 0u) {
|
||||
if (hasFallout) {
|
||||
outColor = vec4(u_fallout.rgb, u_alpha);
|
||||
} else {
|
||||
outColor = vec4(0.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Border detection via neighbor comparison
|
||||
bool isBorder = false;
|
||||
uint nOwner = ownerAtTex(texCoord + ivec2(1, 0));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
nOwner = ownerAtTex(texCoord + ivec2(-1, 0));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
nOwner = ownerAtTex(texCoord + ivec2(0, 1));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
nOwner = ownerAtTex(texCoord + ivec2(0, -1));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
|
||||
if (u_alternativeView) {
|
||||
uint relation = texelFetch(u_relations, ivec2(int(owner), 0), 0).r;
|
||||
vec4 altColor = u_altNeutral;
|
||||
if (relation == 1u) {
|
||||
altColor = u_altSelf;
|
||||
} else if (relation == 2u) {
|
||||
altColor = u_altAlly;
|
||||
} else if (relation >= 4u) {
|
||||
altColor = u_altEnemy;
|
||||
}
|
||||
float a = isBorder ? 1.0 : 0.0;
|
||||
vec3 color = altColor.rgb;
|
||||
if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) {
|
||||
float pulse = u_hoverPulseStrength > 0.0
|
||||
? (1.0 - u_hoverPulseStrength) +
|
||||
u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed))
|
||||
: 1.0;
|
||||
color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse);
|
||||
}
|
||||
outColor = vec4(color, a);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 base = texelFetch(u_palette, ivec2(int(owner), 0), 0);
|
||||
float a = isBorder ? 1.0 : u_alpha;
|
||||
vec3 color = base.rgb;
|
||||
|
||||
if (u_hoveredPlayerId >= 0.0 && abs(float(owner) - u_hoveredPlayerId) < 0.5) {
|
||||
float pulse = u_hoverPulseStrength > 0.0
|
||||
? (1.0 - u_hoverPulseStrength) +
|
||||
u_hoverPulseStrength * (0.5 + 0.5 * sin(u_time * u_hoverPulseSpeed))
|
||||
: 1.0;
|
||||
color = mix(color, u_hoverHighlightColor, u_hoverHighlightStrength * pulse);
|
||||
}
|
||||
|
||||
outColor = vec4(color, a);
|
||||
}
|
||||
`;
|
||||
|
||||
const vertexShader = this.compileShader(
|
||||
gl,
|
||||
gl.VERTEX_SHADER,
|
||||
vertexShaderSource,
|
||||
);
|
||||
const fragmentShader = this.compileShader(
|
||||
gl,
|
||||
gl.FRAGMENT_SHADER,
|
||||
fragmentShaderSource,
|
||||
);
|
||||
if (!vertexShader || !fragmentShader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const program = gl.createProgram();
|
||||
if (!program) return null;
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.error(
|
||||
"[TerritoryWebGLRenderer] link error",
|
||||
gl.getProgramInfoLog(program),
|
||||
);
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
private compileShader(
|
||||
gl: WebGL2RenderingContext,
|
||||
type: number,
|
||||
source: string,
|
||||
): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error(
|
||||
"[TerritoryWebGLRenderer] shader error",
|
||||
gl.getShaderInfoLog(shader),
|
||||
);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import {
|
||||
TerritoryWebGLStatusEvent,
|
||||
ToggleTerritoryWebGLEvent,
|
||||
} from "../../InputHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("territory-webgl-status")
|
||||
export class TerritoryWebGLStatus extends LitElement implements Layer {
|
||||
@property({ attribute: false })
|
||||
public eventBus!: EventBus;
|
||||
|
||||
@property({ attribute: false })
|
||||
public userSettings!: UserSettings;
|
||||
|
||||
@state()
|
||||
private enabled = true;
|
||||
|
||||
@state()
|
||||
private active = false;
|
||||
|
||||
@state()
|
||||
private supported = true;
|
||||
|
||||
@state()
|
||||
private lastMessage: string | null = null;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9998;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
min-width: 220px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.status-fallback {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #334155;
|
||||
}
|
||||
`;
|
||||
|
||||
init() {
|
||||
this.enabled = this.userSettings?.territoryWebGL() ?? true;
|
||||
if (this.eventBus) {
|
||||
this.eventBus.on(TerritoryWebGLStatusEvent, (event) => {
|
||||
this.enabled = event.enabled;
|
||||
this.active = event.active;
|
||||
this.supported = event.supported;
|
||||
this.lastMessage = event.message ?? null;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private handleToggle() {
|
||||
if (!this.eventBus) return;
|
||||
this.eventBus.emit(new ToggleTerritoryWebGLEvent());
|
||||
}
|
||||
|
||||
private statusClass(): string {
|
||||
if (!this.enabled) return "status-disabled";
|
||||
if (this.enabled && this.active) return "status-active";
|
||||
if (!this.supported) return "status-disabled";
|
||||
return "status-fallback";
|
||||
}
|
||||
|
||||
private statusText(): string {
|
||||
if (!this.enabled) {
|
||||
return "WebGL borders hidden";
|
||||
}
|
||||
if (!this.supported) {
|
||||
return "WebGL unsupported (fallback)";
|
||||
}
|
||||
if (this.active) {
|
||||
return "WebGL borders active";
|
||||
}
|
||||
return "WebGL enabled (fallback)";
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="panel">
|
||||
<div class="status-line">
|
||||
<span class="label">Territory Renderer</span>
|
||||
<span class="value ${this.statusClass()}">${this.statusText()}</span>
|
||||
</div>
|
||||
${this.lastMessage
|
||||
? html`<div class="message">${this.lastMessage}</div>`
|
||||
: html``}
|
||||
<div class="actions">
|
||||
<button type="button" @click=${this.handleToggle}>
|
||||
${this.enabled ? "Hide WebGL layer" : "Show WebGL layer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+134
-9
@@ -41,6 +41,10 @@ import { UserSettings } from "./UserSettings";
|
||||
|
||||
const userSettings: UserSettings = new UserSettings();
|
||||
|
||||
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
|
||||
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
|
||||
const BORDER_TINT_RATIO = 0.35;
|
||||
|
||||
export class UnitView {
|
||||
public _wasUpdated = true;
|
||||
public lastPos: TileRef[] = [];
|
||||
@@ -184,9 +188,17 @@ export class PlayerView {
|
||||
|
||||
private _territoryColor: Colord;
|
||||
private _borderColor: Colord;
|
||||
|
||||
// Update here to include structure light and dark colors
|
||||
private _structureColors: { light: Colord; dark: Colord };
|
||||
private _defendedBorderColors: { light: Colord; dark: Colord };
|
||||
|
||||
// Pre-computed border color variants
|
||||
private _borderColorNeutral: Colord;
|
||||
private _borderColorFriendly: Colord;
|
||||
private _borderColorEmbargo: Colord;
|
||||
private _borderColorDefendedNeutral: { light: Colord; dark: Colord };
|
||||
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
|
||||
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -246,11 +258,56 @@ export class PlayerView {
|
||||
this.cosmetics.color?.color ??
|
||||
maybeFocusedBorderColor.toHex(),
|
||||
);
|
||||
const theme = this.game.config().theme();
|
||||
const baseRgb = this._borderColor.toRgb();
|
||||
|
||||
this._defendedBorderColors = this.game
|
||||
.config()
|
||||
.theme()
|
||||
.defendedBorderColors(this._borderColor);
|
||||
// Neutral is just the base color
|
||||
this._borderColorNeutral = this._borderColor;
|
||||
|
||||
// Compute friendly tint
|
||||
this._borderColorFriendly = colord({
|
||||
r: Math.round(
|
||||
baseRgb.r * (1 - BORDER_TINT_RATIO) +
|
||||
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
|
||||
),
|
||||
g: Math.round(
|
||||
baseRgb.g * (1 - BORDER_TINT_RATIO) +
|
||||
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
|
||||
),
|
||||
b: Math.round(
|
||||
baseRgb.b * (1 - BORDER_TINT_RATIO) +
|
||||
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
|
||||
),
|
||||
a: baseRgb.a,
|
||||
});
|
||||
|
||||
// Compute embargo tint
|
||||
this._borderColorEmbargo = colord({
|
||||
r: Math.round(
|
||||
baseRgb.r * (1 - BORDER_TINT_RATIO) +
|
||||
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
|
||||
),
|
||||
g: Math.round(
|
||||
baseRgb.g * (1 - BORDER_TINT_RATIO) +
|
||||
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
|
||||
),
|
||||
b: Math.round(
|
||||
baseRgb.b * (1 - BORDER_TINT_RATIO) +
|
||||
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
|
||||
),
|
||||
a: baseRgb.a,
|
||||
});
|
||||
|
||||
// Pre-compute defended variants
|
||||
this._borderColorDefendedNeutral = theme.defendedBorderColors(
|
||||
this._borderColorNeutral,
|
||||
);
|
||||
this._borderColorDefendedFriendly = theme.defendedBorderColors(
|
||||
this._borderColorFriendly,
|
||||
);
|
||||
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
|
||||
this._borderColorEmbargo,
|
||||
);
|
||||
|
||||
this.decoder =
|
||||
this.cosmetics.pattern === undefined
|
||||
@@ -273,18 +330,74 @@ export class PlayerView {
|
||||
return this._structureColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Border color for a tile:
|
||||
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
|
||||
* - If defended, applies theme checkerboard to the tinted color.
|
||||
*/
|
||||
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
|
||||
if (tile === undefined || !isDefended) {
|
||||
if (tile === undefined) {
|
||||
return this._borderColor;
|
||||
}
|
||||
|
||||
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
|
||||
|
||||
let baseColor: Colord;
|
||||
let defendedColors: { light: Colord; dark: Colord };
|
||||
|
||||
if (hasEmbargo) {
|
||||
baseColor = this._borderColorEmbargo;
|
||||
defendedColors = this._borderColorDefendedEmbargo;
|
||||
} else if (hasFriendly) {
|
||||
baseColor = this._borderColorFriendly;
|
||||
defendedColors = this._borderColorDefendedFriendly;
|
||||
} else {
|
||||
baseColor = this._borderColorNeutral;
|
||||
defendedColors = this._borderColorDefendedNeutral;
|
||||
}
|
||||
|
||||
if (!isDefended) {
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
const lightTile =
|
||||
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
|
||||
return lightTile
|
||||
? this._defendedBorderColors.light
|
||||
: this._defendedBorderColors.dark;
|
||||
return lightTile ? defendedColors.light : defendedColors.dark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Border relation flags for a tile, used by both CPU and WebGL renderers.
|
||||
*/
|
||||
borderRelationFlags(tile: TileRef): {
|
||||
hasEmbargo: boolean;
|
||||
hasFriendly: boolean;
|
||||
} {
|
||||
const mySmallID = this.smallID();
|
||||
let hasEmbargo = false;
|
||||
let hasFriendly = false;
|
||||
|
||||
for (const n of this.game.neighbors(tile)) {
|
||||
if (!this.game.hasOwner(n)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const otherOwner = this.game.owner(n);
|
||||
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.hasEmbargo(otherOwner)) {
|
||||
hasEmbargo = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
|
||||
hasFriendly = true;
|
||||
}
|
||||
}
|
||||
return { hasEmbargo, hasFriendly };
|
||||
}
|
||||
|
||||
async actions(tile?: TileRef): Promise<PlayerActions> {
|
||||
@@ -789,6 +902,18 @@ export class GameView implements GameMap {
|
||||
return this._gameID;
|
||||
}
|
||||
|
||||
hasSharedTileState(): boolean {
|
||||
return this.usesSharedTileState;
|
||||
}
|
||||
|
||||
sharedStateBuffer(): SharedArrayBuffer | undefined {
|
||||
if (!this.usesSharedTileState) {
|
||||
return undefined;
|
||||
}
|
||||
const buffer = this._mapData.sharedStateBuffer;
|
||||
return buffer instanceof SharedArrayBuffer ? buffer : undefined;
|
||||
}
|
||||
|
||||
focusedPlayer(): PlayerView | null {
|
||||
return this.myPlayer();
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ export class UserSettings {
|
||||
return this.get("settings.structureSprites", true);
|
||||
}
|
||||
|
||||
territoryWebGL() {
|
||||
return this.get("settings.territoryWebGL", true);
|
||||
}
|
||||
|
||||
darkMode() {
|
||||
return this.get("settings.darkMode", false);
|
||||
}
|
||||
@@ -115,6 +119,10 @@ export class UserSettings {
|
||||
this.set("settings.structureSprites", !this.structureSprites());
|
||||
}
|
||||
|
||||
toggleTerritoryWebGL() {
|
||||
this.set("settings.territoryWebGL", !this.territoryWebGL());
|
||||
}
|
||||
|
||||
toggleTerritoryPatterns() {
|
||||
this.set("settings.territoryPatterns", !this.territoryPatterns());
|
||||
}
|
||||
|
||||
+131
-78
@@ -1,6 +1,7 @@
|
||||
import { execSync } from "child_process";
|
||||
import CopyPlugin from "copy-webpack-plugin";
|
||||
import ESLintPlugin from "eslint-webpack-plugin";
|
||||
import fs from "fs";
|
||||
import HtmlWebpackPlugin from "html-webpack-plugin";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
@@ -9,11 +10,138 @@ import webpack from "webpack";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const crossOriginHeaders = {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Origin-Agent-Cluster": "?1",
|
||||
};
|
||||
|
||||
const devHttpsEnabled =
|
||||
process.env.DEV_HTTPS === "1" ||
|
||||
(process.env.DEV_HTTPS ?? "").toLowerCase() === "true";
|
||||
|
||||
const devKeyPath =
|
||||
process.env.DEV_KEY ?? path.resolve(__dirname, "resources/certs/dev.key");
|
||||
const devCertPath =
|
||||
process.env.DEV_CERT ?? path.resolve(__dirname, "resources/certs/dev.crt");
|
||||
|
||||
const addProxyHeaders = (proxyRes) => {
|
||||
Object.entries(crossOriginHeaders).forEach(([key, value]) => {
|
||||
proxyRes.headers[key] = value;
|
||||
});
|
||||
};
|
||||
|
||||
const buildDevProxyConfig = () =>
|
||||
[
|
||||
// WebSocket proxies
|
||||
{
|
||||
context: ["/socket"],
|
||||
target: "ws://localhost:3000",
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
// Worker WebSocket proxies - using direct paths without /socket suffix
|
||||
{
|
||||
context: ["/w0"],
|
||||
target: "ws://localhost:3001",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w1"],
|
||||
target: "ws://localhost:3002",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w2"],
|
||||
target: "ws://localhost:3003",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
// Worker proxies for HTTP requests
|
||||
{
|
||||
context: ["/w0"],
|
||||
target: "http://localhost:3001",
|
||||
pathRewrite: { "^/w0": "" },
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w1"],
|
||||
target: "http://localhost:3002",
|
||||
pathRewrite: { "^/w1": "" },
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w2"],
|
||||
target: "http://localhost:3003",
|
||||
pathRewrite: { "^/w2": "" },
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
// Original API endpoints
|
||||
{
|
||||
context: [
|
||||
"/api/env",
|
||||
"/api/game",
|
||||
"/api/public_lobbies",
|
||||
"/api/join_game",
|
||||
"/api/start_game",
|
||||
"/api/create_game",
|
||||
"/api/archive_singleplayer_game",
|
||||
"/api/auth/callback",
|
||||
"/api/auth/discord",
|
||||
"/api/kick_player",
|
||||
],
|
||||
target: "http://localhost:3000",
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
},
|
||||
].map((proxyEntry) => ({
|
||||
onProxyRes: addProxyHeaders,
|
||||
...proxyEntry,
|
||||
}));
|
||||
|
||||
const getHttpsServerConfig = () => {
|
||||
if (!devHttpsEnabled) return undefined;
|
||||
|
||||
try {
|
||||
return {
|
||||
type: "https",
|
||||
options: {
|
||||
key: fs.readFileSync(devKeyPath),
|
||||
cert: fs.readFileSync(devCertPath),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`DEV_HTTPS enabled but could not read cert/key at ${devCertPath} / ${devKeyPath}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const gitCommit =
|
||||
process.env.GIT_COMMIT ?? execSync("git rev-parse HEAD").toString().trim();
|
||||
|
||||
export default async (env, argv) => {
|
||||
const isProduction = argv.mode === "production";
|
||||
const serverConfig = isProduction ? undefined : getHttpsServerConfig();
|
||||
const proxyConfig = isProduction ? [] : buildDevProxyConfig();
|
||||
|
||||
return {
|
||||
entry: "./src/client/Main.ts",
|
||||
@@ -173,6 +301,8 @@ export default async (env, argv) => {
|
||||
devServer: isProduction
|
||||
? {}
|
||||
: {
|
||||
server: serverConfig,
|
||||
headers: crossOriginHeaders,
|
||||
devMiddleware: { writeToDisk: true },
|
||||
static: {
|
||||
directory: path.join(__dirname, "static"),
|
||||
@@ -180,84 +310,7 @@ export default async (env, argv) => {
|
||||
historyApiFallback: true,
|
||||
compress: true,
|
||||
port: 9000,
|
||||
proxy: [
|
||||
// WebSocket proxies
|
||||
{
|
||||
context: ["/socket"],
|
||||
target: "ws://localhost:3000",
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
// Worker WebSocket proxies - using direct paths without /socket suffix
|
||||
{
|
||||
context: ["/w0"],
|
||||
target: "ws://localhost:3001",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w1"],
|
||||
target: "ws://localhost:3002",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w2"],
|
||||
target: "ws://localhost:3003",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
// Worker proxies for HTTP requests
|
||||
{
|
||||
context: ["/w0"],
|
||||
target: "http://localhost:3001",
|
||||
pathRewrite: { "^/w0": "" },
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w1"],
|
||||
target: "http://localhost:3002",
|
||||
pathRewrite: { "^/w1": "" },
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
{
|
||||
context: ["/w2"],
|
||||
target: "http://localhost:3003",
|
||||
pathRewrite: { "^/w2": "" },
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
},
|
||||
// Original API endpoints
|
||||
{
|
||||
context: [
|
||||
"/api/env",
|
||||
"/api/game",
|
||||
"/api/public_lobbies",
|
||||
"/api/join_game",
|
||||
"/api/start_game",
|
||||
"/api/create_game",
|
||||
"/api/archive_singleplayer_game",
|
||||
"/api/auth/callback",
|
||||
"/api/auth/discord",
|
||||
"/api/kick_player",
|
||||
],
|
||||
target: "http://localhost:3000",
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
},
|
||||
],
|
||||
proxy: proxyConfig,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user