This commit is contained in:
scamiv
2025-11-19 01:30:47 +01:00
parent ba77ca9468
commit de085076c4
10 changed files with 1813 additions and 125 deletions
+15
View File
@@ -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) {}
}
@@ -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";
@@ -29,30 +27,11 @@ import {
translateText,
} from "../../Utils";
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
import { resolveHoverTarget } from "../HoverTargetResolver";
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;
}
}
+228 -77
View File
@@ -18,10 +18,16 @@ import {
AlternateViewEvent,
DragEvent,
MouseOverEvent,
TerritoryWebGLStatusEvent,
ToggleTerritoryWebGLDebugBordersEvent,
ToggleTerritoryWebGLEvent,
} from "../../InputHandler";
import { FrameProfiler } from "../FrameProfiler";
import { resolveHoverTarget } from "../HoverTargetResolver";
import { TransformHandler } from "../TransformHandler";
import { BorderRenderer, NullBorderRenderer } from "./BorderRenderer";
import { Layer } from "./Layer";
import { WebGLBorderRenderer } from "./WebGLBorderRenderer";
export class TerritoryLayer implements Layer {
private userSettings: UserSettings;
@@ -47,6 +53,7 @@ export class TerritoryLayer implements Layer {
private highlightContext: CanvasRenderingContext2D;
private highlightedTerritory: PlayerView | null = null;
private borderRenderer: BorderRenderer = new NullBorderRenderer();
private alternativeView = false;
private lastDragTime = 0;
@@ -57,6 +64,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 +77,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 {
@@ -148,14 +160,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.borderRenderer.drawsOwnBorders()) {
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() {
@@ -264,6 +283,22 @@ export class TerritoryLayer implements Layer {
this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
this.borderRenderer.setAlternativeView(this.alternativeView);
if (this.borderRenderer instanceof WebGLBorderRenderer) {
this.borderRenderer.setHoverHighlightOptions(
this.hoverHighlightOptions(),
);
}
});
this.eventBus.on(ToggleTerritoryWebGLEvent, () => {
this.userSettings.toggleTerritoryWebGL();
this.useWebGL = this.userSettings.territoryWebGL();
this.redraw();
});
this.eventBus.on(ToggleTerritoryWebGLDebugBordersEvent, (e) => {
if (this.borderRenderer instanceof WebGLBorderRenderer) {
this.borderRenderer.setDebugPulseEnabled(e.enabled);
}
});
this.eventBus.on(DragEvent, (e) => {
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
@@ -278,7 +313,9 @@ export class TerritoryLayer implements Layer {
}
private updateHighlightedTerritory() {
if (!this.alternativeView) {
const supportsHover =
this.alternativeView || this.borderRenderer.drawsOwnBorders();
if (!supportsHover) {
return;
}
@@ -295,7 +332,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;
@@ -304,32 +341,26 @@ export class TerritoryLayer implements Layer {
}
if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
const territories: PlayerView[] = [];
if (previousTerritory) {
territories.push(previousTerritory);
if (this.borderRenderer.drawsOwnBorders()) {
this.borderRenderer.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");
@@ -357,6 +388,8 @@ export class TerritoryLayer implements Layer {
0,
);
this.configureBorderRenderer();
// Add a second canvas for highlights
this.highlightCanvas = document.createElement("canvas");
const highlightContext = this.highlightCanvas.getContext("2d", {
@@ -372,6 +405,77 @@ export class TerritoryLayer implements Layer {
});
}
private configureBorderRenderer() {
if (!this.useWebGL) {
this.borderRenderer = new NullBorderRenderer();
this.webglSupported = true;
this.emitWebGLStatus(
false,
false,
this.webglSupported,
"WebGL territory layer hidden.",
);
return;
}
const renderer = new WebGLBorderRenderer(this.game, this.theme);
this.webglSupported = renderer.isSupported();
if (renderer.isActive()) {
this.borderRenderer = renderer;
this.borderRenderer.setAlternativeView(this.alternativeView);
this.borderRenderer.setHoveredPlayerId(
this.highlightedTerritory?.smallID() ?? null,
);
renderer.setHoverHighlightOptions(this.hoverHighlightOptions());
this.emitWebGLStatus(true, true, this.webglSupported);
} else {
this.borderRenderer = new NullBorderRenderer();
this.emitWebGLStatus(
true,
false,
this.webglSupported,
"WebGL not available. Using canvas fallback for borders.",
);
}
}
/**
* Central configuration for WebGL border hover styling.
* Keeps main view and alternate view behavior explicit and tweakable.
*/
private hoverHighlightOptions() {
const baseColor = this.theme.spawnHighlightSelfColor();
if (this.alternativeView) {
// Alternate view: borders are the primary visual, so make hover stronger
return {
color: baseColor,
strength: 0.8,
pulseStrength: 0.45,
pulseSpeed: Math.PI * 2,
};
}
// Main view: keep highlight noticeable but a bit subtler
return {
color: baseColor,
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) => {
@@ -395,6 +499,9 @@ export class TerritoryLayer implements Layer {
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
const skipTerritoryCanvas =
this.alternativeView && this.borderRenderer.drawsOwnBorders();
if (
now > this.lastDragTime + this.nodrawDragDuration &&
now > this.lastRefresh + this.refreshRate
@@ -413,7 +520,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;
if (w > 0 && h > 0 && shouldBlitTerritories) {
const putImageStart = FrameProfiler.start();
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
@@ -428,15 +541,24 @@ 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(),
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);
}
const borderRenderStart = FrameProfiler.start();
this.borderRenderer.render(context);
FrameProfiler.end(
"TerritoryLayer:borderRenderer.render",
borderRenderStart,
);
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
if (this.game.inSpawnPhase()) {
const highlightDrawStart = FrameProfiler.start();
context.drawImage(
@@ -470,18 +592,22 @@ 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;
}
paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) {
const cpuStart = FrameProfiler.start();
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;
const rendererHandlesBorders = this.borderRenderer.drawsOwnBorders();
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
if (!owner) {
if (hasFallout) {
this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150);
this.paintTile(
this.alternativeImageData,
@@ -489,43 +615,67 @@ export class TerritoryLayer implements Layer {
this.theme.falloutColor(),
150,
);
return;
} else {
this.clearTile(tile);
}
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(
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);
const myPlayer = this.game.myPlayer();
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
if (isBorderTile) {
isDefended = this.game.hasUnitNearby(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
);
if (rendererHandlesBorders) {
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
} else {
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 {
if (!rendererHandlesBorders) {
// Alternative view only shows borders.
this.clearAlternativeTile(tile);
}
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
}
}
FrameProfiler.end("TerritoryLayer:paintTerritory.cpu", cpuStart);
if (rendererHandlesBorders) {
if (_maybeStaleBorder && !isBorderTile) {
this.borderRenderer.clearTile(tile);
} else {
const borderUpdateStart = FrameProfiler.start();
this.borderRenderer.updateBorder(
tile,
owner,
isBorderTile,
isDefended,
hasFallout,
);
FrameProfiler.end(
"TerritoryLayer:borderRenderer.updateBorder",
borderUpdateStart,
);
}
}
}
@@ -560,6 +710,7 @@ export class TerritoryLayer implements Layer {
}
clearTile(tile: TileRef) {
this.borderRenderer.clearTile(tile);
const offset = tile * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
@@ -0,0 +1,193 @@
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,
ToggleTerritoryWebGLDebugBordersEvent,
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;
@state()
private debugBorders = false;
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 handleToggleDebugBorders() {
if (!this.eventBus) return;
this.debugBorders = !this.debugBorders;
this.eventBus.emit(
new ToggleTerritoryWebGLDebugBordersEvent(this.debugBorders),
);
}
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>
<button type="button" @click=${this.handleToggleDebugBorders}>
${this.debugBorders
? "Disable GL border highlight"
: "Highlight GL borders"}
</button>
</div>
</div>
`;
}
}
@@ -0,0 +1,238 @@
import { Theme } from "../../../core/configuration/Config";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { FrameProfiler } from "../FrameProfiler";
import { BorderRenderer } from "./BorderRenderer";
import {
BorderEdge,
HoverHighlightOptions,
TerritoryBorderWebGL,
TileRelation,
} from "./TerritoryBorderWebGL";
export class WebGLBorderRenderer implements BorderRenderer {
private readonly renderer: TerritoryBorderWebGL | null;
constructor(
private readonly game: GameView,
private readonly theme: Theme,
) {
this.renderer = TerritoryBorderWebGL.create(
game.width(),
game.height(),
theme,
);
}
drawsOwnBorders(): boolean {
return true;
}
isSupported(): boolean {
return this.renderer !== null;
}
isActive(): boolean {
return this.renderer !== null;
}
setAlternativeView(enabled: boolean): void {
this.renderer?.setAlternativeView(enabled);
}
setHoveredPlayerId(playerSmallId: number | null): void {
this.renderer?.setHoveredPlayerId(playerSmallId);
}
setDebugPulseEnabled(enabled: boolean): void {
this.renderer?.setDebugPulseEnabled(enabled);
}
setHoverHighlightOptions(options: HoverHighlightOptions): void {
this.renderer?.setHoverHighlightOptions(options);
}
updateBorder(
tile: TileRef,
owner: PlayerView | null,
isBorder: boolean,
isDefended: boolean,
_hasFallout: boolean,
): void {
const span = FrameProfiler.start();
if (!this.renderer) {
FrameProfiler.end("WebGLBorderRenderer:updateBorder.noRenderer", span);
return;
}
if (!owner || !isBorder) {
this.renderer.clearTile(tile as number);
FrameProfiler.end("WebGLBorderRenderer:updateBorder.clearTile", span);
return;
}
const buildEdgesSpan = FrameProfiler.start();
const edges = this.buildBorderEdges(tile, owner, isDefended);
FrameProfiler.end(
"WebGLBorderRenderer:updateBorder.buildBorderEdges",
buildEdgesSpan,
);
if (edges.length === 0) {
this.renderer.clearTile(tile as number);
FrameProfiler.end("WebGLBorderRenderer:updateBorder.noEdges", span);
return;
}
const updateEdgesSpan = FrameProfiler.start();
this.renderer.updateEdges(tile as number, edges);
FrameProfiler.end(
"WebGLBorderRenderer:updateBorder.renderer.updateEdges",
updateEdgesSpan,
);
FrameProfiler.end("WebGLBorderRenderer:updateBorder.total", span);
}
clearTile(tile: TileRef): void {
this.renderer?.clearTile(tile as number);
}
render(context: CanvasRenderingContext2D): void {
const span = FrameProfiler.start();
if (!this.renderer) {
FrameProfiler.end("WebGLBorderRenderer:render.noRenderer", span);
return;
}
const webglSpan = FrameProfiler.start();
this.renderer.render();
FrameProfiler.end("WebGLBorderRenderer:render.renderer.render", webglSpan);
const drawImageSpan = FrameProfiler.start();
context.drawImage(
this.renderer.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
FrameProfiler.end(
"WebGLBorderRenderer:render.context.drawImage",
drawImageSpan,
);
FrameProfiler.end("WebGLBorderRenderer:render.total", span);
}
private buildBorderEdges(
tile: TileRef,
owner: PlayerView,
isDefended: boolean,
): BorderEdge[] {
const span = FrameProfiler.start();
const edges: BorderEdge[] = [];
const x = this.game.x(tile);
const y = this.game.y(tile);
const ownerId = owner.smallID();
const relation = this.resolveRelation(owner);
const color = owner.borderColor();
const { hasEmbargo, hasFriendly } = owner.borderRelationFlags(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
const flags =
(isDefended ? 1 : 0) |
(hasFriendly ? 2 : 0) |
(hasEmbargo ? 4 : 0) |
(lightTile ? 8 : 0);
// Inset borders by 1 tile (0.1 tiles inward) so both countries' borders can be drawn
const inset = 0.1;
const segments = [
{
dx: 0,
dy: -1,
startX: x + inset,
startY: y + inset,
endX: x + 1 - inset,
endY: y + inset,
},
{
dx: 1,
dy: 0,
startX: x + 1 - inset,
startY: y + inset,
endX: x + 1 - inset,
endY: y + 1 - inset,
},
{
dx: 0,
dy: 1,
startX: x + inset,
startY: y + 1 - inset,
endX: x + 1 - inset,
endY: y + 1 - inset,
},
{
dx: -1,
dy: 0,
startX: x + inset,
startY: y + inset,
endX: x + inset,
endY: y + 1 - inset,
},
];
for (const segment of segments) {
const neighborOwner = this.ownerSmallIdAt(x + segment.dx, y + segment.dy);
if (neighborOwner === ownerId) {
continue;
}
edges.push({
startX: segment.startX,
startY: segment.startY,
endX: segment.endX,
endY: segment.endY,
color,
ownerSmallId: ownerId,
relation,
flags,
});
}
FrameProfiler.end("WebGLBorderRenderer:buildBorderEdges", span);
return edges;
}
private resolveRelation(owner: PlayerView | null): TileRelation {
const myPlayer = this.game.myPlayer();
if (!owner || !myPlayer) {
return TileRelation.Unknown;
}
if (owner.smallID() === myPlayer.smallID()) {
return TileRelation.Self;
}
if (owner.isFriendly(myPlayer)) {
return TileRelation.Friendly;
}
if (!owner.hasEmbargo(myPlayer)) {
return TileRelation.Neutral;
}
return TileRelation.Enemy;
}
private ownerSmallIdAt(x: number, y: number): number | null {
if (!this.game.isValidCoord(x, y)) {
return null;
}
const neighbor = this.game.ref(x, y);
if (!this.game.hasOwner(neighbor)) {
return null;
}
return this.game.ownerID(neighbor);
}
}
+122 -9
View File
@@ -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> {
+8
View File
@@ -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());
}