mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:40:42 +00:00
v1-dev
This commit is contained in:
@@ -3,7 +3,6 @@ import { Colord } from "colord";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
Cell,
|
||||
ColoredTeams,
|
||||
PlayerType,
|
||||
Team,
|
||||
@@ -12,21 +11,30 @@ import {
|
||||
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import {
|
||||
AlternateViewEvent,
|
||||
ContextMenuEvent,
|
||||
DragEvent,
|
||||
MouseOverEvent,
|
||||
} from "../../InputHandler";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import {
|
||||
CanvasTerritoryRenderer,
|
||||
TerritoryRendererStrategy,
|
||||
WebglTerritoryRenderer,
|
||||
} from "./TerritoryRenderers";
|
||||
import { TerritoryWebGLRenderer } from "./TerritoryWebGLRenderer";
|
||||
|
||||
export class TerritoryLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private imageData: ImageData;
|
||||
private alternativeImageData: ImageData;
|
||||
profileName(): string {
|
||||
return "TerritoryLayer:renderLayer";
|
||||
}
|
||||
|
||||
private userSettings = new UserSettings();
|
||||
private borderAnimTime = 0;
|
||||
|
||||
private cachedTerritoryPatternsEnabled: boolean | undefined;
|
||||
@@ -45,6 +53,7 @@ export class TerritoryLayer implements Layer {
|
||||
private highlightContext: CanvasRenderingContext2D;
|
||||
|
||||
private highlightedTerritory: PlayerView | null = null;
|
||||
private territoryRenderer: TerritoryRendererStrategy | null = null;
|
||||
|
||||
private alternativeView = false;
|
||||
private lastDragTime = 0;
|
||||
@@ -55,6 +64,7 @@ export class TerritoryLayer implements Layer {
|
||||
private lastRefresh = 0;
|
||||
|
||||
private lastFocusedPlayer: PlayerView | null = null;
|
||||
private lastMyPlayerSmallId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -63,6 +73,7 @@ export class TerritoryLayer implements Layer {
|
||||
) {
|
||||
this.theme = game.config().theme();
|
||||
this.cachedTerritoryPatternsEnabled = undefined;
|
||||
this.lastMyPlayerSmallId = game.myPlayer()?.smallID() ?? null;
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
@@ -77,10 +88,17 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
const tickProfile = FrameProfiler.start();
|
||||
if (this.game.inSpawnPhase()) {
|
||||
this.spawnHighlight();
|
||||
}
|
||||
|
||||
const patternsEnabled = this.userSettings.territoryPatterns();
|
||||
if (this.cachedTerritoryPatternsEnabled !== patternsEnabled) {
|
||||
this.cachedTerritoryPatternsEnabled = patternsEnabled;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
this.game.recentlyUpdatedTiles().forEach((t) => {
|
||||
this.enqueueTile(t);
|
||||
// Immediately clear territory overlay for water tiles so old
|
||||
@@ -156,14 +174,24 @@ 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?.isWebGL()) {
|
||||
this.redraw();
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
FrameProfiler.end("TerritoryLayer:tick", tickProfile);
|
||||
}
|
||||
|
||||
private spawnHighlight() {
|
||||
@@ -201,7 +229,6 @@ export class TerritoryLayer implements Layer {
|
||||
color = this.theme.spawnHighlightColor();
|
||||
} else if (myPlayer !== null && myPlayer !== human) {
|
||||
// In Team games, the spawn highlight color becomes that player's team color
|
||||
// Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively
|
||||
const team = human.team();
|
||||
if (team !== null && teamColors.includes(team)) {
|
||||
color = this.theme.teamColor(team);
|
||||
@@ -239,11 +266,10 @@ export class TerritoryLayer implements Layer {
|
||||
this.borderAnimTime += 0.5;
|
||||
const minRad = 8;
|
||||
const maxRad = 24;
|
||||
// Range: [minPadding..maxPadding]
|
||||
const radius =
|
||||
minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime));
|
||||
|
||||
const baseColor = this.theme.spawnHighlightSelfColor(); //white
|
||||
const baseColor = this.theme.spawnHighlightSelfColor();
|
||||
let teamColor: Colord | null = null;
|
||||
|
||||
const team: Team | null = focusedPlayer.team();
|
||||
@@ -259,11 +285,10 @@ export class TerritoryLayer implements Layer {
|
||||
minRad,
|
||||
maxRad,
|
||||
radius,
|
||||
baseColor, // Always draw white static semi-transparent ring
|
||||
teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games.
|
||||
baseColor,
|
||||
teamColor,
|
||||
);
|
||||
|
||||
// Draw breathing rings for teammates in team games (helps colorblind players identify teammates)
|
||||
this.drawTeammateHighlights(minRad, maxRad, radius);
|
||||
}
|
||||
|
||||
@@ -281,7 +306,6 @@ export class TerritoryLayer implements Layer {
|
||||
.playerViews()
|
||||
.filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p));
|
||||
|
||||
// Smaller radius for teammates (more subtle than self highlight)
|
||||
const teammateMinRad = 5;
|
||||
const teammateMaxRad = 14;
|
||||
const teammateRadius =
|
||||
@@ -322,10 +346,16 @@ 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?.markAllDirty();
|
||||
this.territoryRenderer?.setHoverHighlightOptions(
|
||||
this.hoverHighlightOptions(),
|
||||
);
|
||||
});
|
||||
this.eventBus.on(DragEvent, (e) => {
|
||||
this.eventBus.on(DragEvent, () => {
|
||||
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
|
||||
// this.lastDragTime = Date.now();
|
||||
});
|
||||
@@ -338,7 +368,9 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
private updateHighlightedTerritory() {
|
||||
if (!this.alternativeView) {
|
||||
const supportsHover =
|
||||
this.alternativeView || this.territoryRenderer?.isWebGL() === true;
|
||||
if (!supportsHover) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,14 +396,20 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
|
||||
const territories: PlayerView[] = [];
|
||||
if (previousTerritory) {
|
||||
territories.push(previousTerritory);
|
||||
if (this.territoryRenderer?.isWebGL()) {
|
||||
this.territoryRenderer.setHover(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +418,6 @@ export class TerritoryLayer implements Layer {
|
||||
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;
|
||||
}
|
||||
@@ -390,32 +427,10 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
redraw() {
|
||||
console.log("redrew territory layer");
|
||||
this.canvas = document.createElement("canvas");
|
||||
const context = this.canvas.getContext("2d");
|
||||
if (context === null) throw new Error("2d context not supported");
|
||||
this.context = context;
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
|
||||
this.imageData = this.context.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
this.alternativeImageData = this.context.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
this.initImageData();
|
||||
|
||||
this.context.putImageData(
|
||||
this.alternativeView ? this.alternativeImageData : this.imageData,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
this.lastMyPlayerSmallId = this.game.myPlayer()?.smallID() ?? null;
|
||||
this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns();
|
||||
this.configureRenderers();
|
||||
this.territoryRenderer?.redraw();
|
||||
|
||||
// Add a second canvas for highlights
|
||||
this.highlightCanvas = document.createElement("canvas");
|
||||
@@ -432,7 +447,51 @@ export class TerritoryLayer implements Layer {
|
||||
});
|
||||
}
|
||||
|
||||
private configureRenderers() {
|
||||
this.territoryRenderer = null;
|
||||
|
||||
const { renderer } = TerritoryWebGLRenderer.create(this.game, this.theme);
|
||||
if (renderer) {
|
||||
const strategy = new WebglTerritoryRenderer(renderer, this.game);
|
||||
strategy.setAlternativeView(this.alternativeView);
|
||||
strategy.markAllDirty();
|
||||
strategy.refreshPalette();
|
||||
strategy.setHoverHighlightOptions(this.hoverHighlightOptions());
|
||||
strategy.setHover(this.highlightedTerritory?.smallID() ?? null);
|
||||
this.territoryRenderer = strategy;
|
||||
return;
|
||||
}
|
||||
|
||||
this.territoryRenderer = new CanvasTerritoryRenderer(this.game, this.theme);
|
||||
this.territoryRenderer.setAlternativeView(this.alternativeView);
|
||||
this.territoryRenderer.setHoverHighlightOptions(
|
||||
this.hoverHighlightOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
private hoverHighlightOptions() {
|
||||
const baseColor = this.theme.spawnHighlightSelfColor();
|
||||
const rgba = baseColor.rgba;
|
||||
|
||||
if (this.alternativeView) {
|
||||
return {
|
||||
color: { r: rgba.r, g: rgba.g, b: rgba.b },
|
||||
strength: 0.8,
|
||||
pulseStrength: 0.45,
|
||||
pulseSpeed: Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
color: { r: rgba.r, g: rgba.g, b: rgba.b },
|
||||
strength: 0.6,
|
||||
pulseStrength: 0.35,
|
||||
pulseSpeed: Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
redrawBorder(...players: PlayerView[]) {
|
||||
const shouldRefreshPalette = this.territoryRenderer?.isWebGL() ?? false;
|
||||
return Promise.all(
|
||||
players.map(async (player) => {
|
||||
const tiles = await player.borderTiles();
|
||||
@@ -440,63 +499,46 @@ export class TerritoryLayer implements Layer {
|
||||
this.paintTerritory(tile, true);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
initImageData() {
|
||||
this.game.forEachTile((tile) => {
|
||||
const cell = new Cell(this.game.x(tile), this.game.y(tile));
|
||||
const index = cell.y * this.game.width() + cell.x;
|
||||
const offset = index * 4;
|
||||
this.imageData.data[offset + 3] = 0;
|
||||
this.alternativeImageData.data[offset + 3] = 0;
|
||||
).then(() => {
|
||||
if (shouldRefreshPalette) {
|
||||
this.territoryRenderer?.refreshPalette();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
const now = Date.now();
|
||||
if (
|
||||
const canRefresh =
|
||||
now > this.lastDragTime + this.nodrawDragDuration &&
|
||||
now > this.lastRefresh + this.refreshRate
|
||||
) {
|
||||
now > this.lastRefresh + this.refreshRate;
|
||||
if (canRefresh) {
|
||||
this.lastRefresh = now;
|
||||
const renderTerritoryStart = FrameProfiler.start();
|
||||
this.renderTerritory();
|
||||
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
|
||||
|
||||
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
|
||||
const vx0 = Math.max(0, topLeft.x);
|
||||
const vy0 = Math.max(0, topLeft.y);
|
||||
const vx1 = Math.min(this.game.width() - 1, bottomRight.x);
|
||||
const vy1 = Math.min(this.game.height() - 1, bottomRight.y);
|
||||
|
||||
const w = vx1 - vx0 + 1;
|
||||
const h = vy1 - vy0 + 1;
|
||||
|
||||
if (w > 0 && h > 0) {
|
||||
const putImageStart = FrameProfiler.start();
|
||||
this.context.putImageData(
|
||||
this.alternativeView ? this.alternativeImageData : this.imageData,
|
||||
0,
|
||||
0,
|
||||
vx0,
|
||||
vy0,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
FrameProfiler.end("TerritoryLayer:putImageData", putImageStart);
|
||||
}
|
||||
}
|
||||
|
||||
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 [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
|
||||
const vx0 = Math.max(0, topLeft.x);
|
||||
const vy0 = Math.max(0, topLeft.y);
|
||||
const vx1 = Math.min(this.game.width() - 1, bottomRight.x);
|
||||
const vy1 = Math.min(this.game.height() - 1, bottomRight.y);
|
||||
|
||||
const w = vx1 - vx0 + 1;
|
||||
const h = vy1 - vy0 + 1;
|
||||
if (this.territoryRenderer) {
|
||||
this.territoryRenderer.render(
|
||||
context,
|
||||
{
|
||||
x: vx0,
|
||||
y: vy0,
|
||||
width: w,
|
||||
height: h,
|
||||
},
|
||||
canRefresh,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.game.inSpawnPhase()) {
|
||||
const highlightDrawStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
@@ -514,11 +556,21 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
renderTerritory() {
|
||||
if (!this.territoryRenderer) {
|
||||
return;
|
||||
}
|
||||
let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
|
||||
if (numToRender === 0 || this.game.inSpawnPhase()) {
|
||||
if (
|
||||
numToRender === 0 ||
|
||||
this.game.inSpawnPhase() ||
|
||||
this.territoryRenderer.isWebGL()
|
||||
) {
|
||||
numToRender = this.tileToRenderQueue.size();
|
||||
}
|
||||
|
||||
const useNeighborPaint = !(this.territoryRenderer?.isWebGL() ?? false);
|
||||
const neighborsToPaint: TileRef[] = [];
|
||||
const mainSpan = FrameProfiler.start();
|
||||
while (numToRender > 0) {
|
||||
numToRender--;
|
||||
|
||||
@@ -529,105 +581,33 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
const tile = entry.tile;
|
||||
this.paintTerritory(tile);
|
||||
for (const neighbor of this.game.neighbors(tile)) {
|
||||
|
||||
if (useNeighborPaint) {
|
||||
for (const neighbor of this.game.neighbors(tile)) {
|
||||
neighborsToPaint.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
FrameProfiler.end("TerritoryLayer:renderTerritory.mainPaint", mainSpan);
|
||||
|
||||
if (useNeighborPaint && neighborsToPaint.length > 0) {
|
||||
const neighborSpan = FrameProfiler.start();
|
||||
for (const neighbor of neighborsToPaint) {
|
||||
this.paintTerritory(neighbor, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
tile,
|
||||
this.game.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
FrameProfiler.end(
|
||||
"TerritoryLayer:renderTerritory.neighborPaint",
|
||||
neighborSpan,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
alternateViewColor(other: PlayerView): Colord {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) {
|
||||
return this.theme.neutralColor();
|
||||
}
|
||||
if (other.smallID() === myPlayer.smallID()) {
|
||||
return this.theme.selfColor();
|
||||
}
|
||||
if (other.isFriendly(myPlayer)) {
|
||||
return this.theme.allyColor();
|
||||
}
|
||||
if (!other.hasEmbargo(myPlayer)) {
|
||||
return this.theme.neutralColor();
|
||||
}
|
||||
return this.theme.enemyColor();
|
||||
}
|
||||
|
||||
paintAlternateViewTile(tile: TileRef, other: PlayerView) {
|
||||
const color = this.alternateViewColor(other);
|
||||
this.paintTile(this.alternativeImageData, tile, color, 255);
|
||||
}
|
||||
|
||||
paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) {
|
||||
const offset = tile * 4;
|
||||
imageData.data[offset] = color.rgba.r;
|
||||
imageData.data[offset + 1] = color.rgba.g;
|
||||
imageData.data[offset + 2] = color.rgba.b;
|
||||
imageData.data[offset + 3] = alpha;
|
||||
paintTerritory(tile: TileRef, _maybeStaleBorder: boolean = false) {
|
||||
this.territoryRenderer?.paintTile(tile);
|
||||
}
|
||||
|
||||
clearTile(tile: TileRef) {
|
||||
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)
|
||||
}
|
||||
|
||||
clearAlternativeTile(tile: TileRef) {
|
||||
const offset = tile * 4;
|
||||
this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
|
||||
this.territoryRenderer?.clearTile(tile);
|
||||
}
|
||||
|
||||
enqueueTile(tile: TileRef) {
|
||||
@@ -672,36 +652,26 @@ export class TerritoryLayer implements Layer {
|
||||
|
||||
// Draw a semi-transparent ring around the starting location
|
||||
ctx.beginPath();
|
||||
// Transparency matches the highlight color provided
|
||||
const transparent = transparentColor.alpha(0);
|
||||
const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad);
|
||||
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad.addColorStop(0, transparent.toRgbString());
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad.addColorStop(0.01, transparentColor.toRgbString());
|
||||
radGrad.addColorStop(0.1, transparentColor.toRgbString());
|
||||
// The outer edge of the ring is transparent
|
||||
radGrad.addColorStop(1, transparent.toRgbString());
|
||||
|
||||
// Draw an arc at the max radius and fill with the created radial gradient
|
||||
ctx.arc(cx, cy, maxRad, 0, Math.PI * 2);
|
||||
ctx.fillStyle = radGrad;
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
const breatheInner = breathingColor.alpha(0);
|
||||
// Draw a solid ring around the starting location with outer radius = the breathing radius
|
||||
ctx.beginPath();
|
||||
const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius);
|
||||
// Pixels with radius < minRad are transparent
|
||||
radGrad2.addColorStop(0, breatheInner.toRgbString());
|
||||
// The ring then starts with solid highlight color
|
||||
radGrad2.addColorStop(0.01, breathingColor.toRgbString());
|
||||
// The ring is solid throughout
|
||||
radGrad2.addColorStop(1, breathingColor.toRgbString());
|
||||
|
||||
// Draw an arc at the current breathing radius and fill with the created "gradient"
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = radGrad2;
|
||||
ctx.fill();
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { Colord } from "colord";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import {
|
||||
HoverHighlightOptions,
|
||||
TerritoryWebGLRenderer,
|
||||
} from "./TerritoryWebGLRenderer";
|
||||
|
||||
export interface TerritoryRendererStrategy {
|
||||
isWebGL(): boolean;
|
||||
redraw(): void;
|
||||
markAllDirty(): void;
|
||||
paintTile(tile: TileRef): void;
|
||||
render(
|
||||
context: CanvasRenderingContext2D,
|
||||
viewport: { x: number; y: number; width: number; height: number },
|
||||
shouldBlit: boolean,
|
||||
): void;
|
||||
setAlternativeView(enabled: boolean): void;
|
||||
setHover(playerSmallId: number | null): void;
|
||||
setHoverHighlightOptions(options: HoverHighlightOptions): void;
|
||||
refreshPalette(): void;
|
||||
clearTile(tile: TileRef): void;
|
||||
}
|
||||
|
||||
export class CanvasTerritoryRenderer implements TerritoryRendererStrategy {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
private imageData: ImageData;
|
||||
private alternativeImageData: ImageData;
|
||||
private alternativeView = false;
|
||||
|
||||
constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly theme: Theme,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
const context = this.canvas.getContext("2d");
|
||||
if (!context) throw new Error("2d context not supported");
|
||||
this.context = context;
|
||||
this.imageData = context.createImageData(1, 1);
|
||||
this.alternativeImageData = context.createImageData(1, 1);
|
||||
}
|
||||
|
||||
isWebGL(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
this.imageData = this.context.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
this.alternativeImageData = this.context.getImageData(
|
||||
0,
|
||||
0,
|
||||
this.canvas.width,
|
||||
this.canvas.height,
|
||||
);
|
||||
this.initImageData();
|
||||
}
|
||||
|
||||
markAllDirty(): void {
|
||||
// No special handling needed for canvas path.
|
||||
}
|
||||
|
||||
paintTile(tile: TileRef) {
|
||||
const cpuStart = FrameProfiler.start();
|
||||
const hasOwner = this.game.hasOwner(tile);
|
||||
const rawOwner = hasOwner ? this.game.owner(tile) : null;
|
||||
const owner =
|
||||
rawOwner &&
|
||||
typeof (rawOwner as any).isPlayer === "function" &&
|
||||
(rawOwner as any).isPlayer()
|
||||
? (rawOwner as PlayerView)
|
||||
: null;
|
||||
const isBorderTile = this.game.isBorder(tile);
|
||||
const hasFallout = this.game.hasFallout(tile);
|
||||
const isDefended =
|
||||
owner && isBorderTile ? this.game.isDefended(tile) : false;
|
||||
|
||||
if (!owner) {
|
||||
if (hasFallout) {
|
||||
this.paintTileColor(
|
||||
this.imageData,
|
||||
tile,
|
||||
this.theme.falloutColor(),
|
||||
150,
|
||||
);
|
||||
this.paintTileColor(
|
||||
this.alternativeImageData,
|
||||
tile,
|
||||
this.theme.falloutColor(),
|
||||
150,
|
||||
);
|
||||
} else {
|
||||
this.clearTile(tile);
|
||||
}
|
||||
FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart);
|
||||
return;
|
||||
}
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
if (isBorderTile) {
|
||||
if (myPlayer) {
|
||||
const alternativeColor = this.alternateViewColor(owner);
|
||||
this.paintTileColor(
|
||||
this.alternativeImageData,
|
||||
tile,
|
||||
alternativeColor,
|
||||
255,
|
||||
);
|
||||
}
|
||||
this.paintTileColor(
|
||||
this.imageData,
|
||||
tile,
|
||||
owner.borderColor(tile, isDefended),
|
||||
255,
|
||||
);
|
||||
} else {
|
||||
// Alternative view only shows borders.
|
||||
this.clearAlternativeTile(tile);
|
||||
this.paintTileColor(
|
||||
this.imageData,
|
||||
tile,
|
||||
owner.territoryColor(tile),
|
||||
150,
|
||||
);
|
||||
}
|
||||
FrameProfiler.end("CanvasTerritoryRenderer:paintTile", cpuStart);
|
||||
}
|
||||
|
||||
render(
|
||||
context: CanvasRenderingContext2D,
|
||||
viewport: { x: number; y: number; width: number; height: number },
|
||||
shouldBlit: boolean,
|
||||
) {
|
||||
const { x, y, width, height } = viewport;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
if (shouldBlit) {
|
||||
const putImageStart = FrameProfiler.start();
|
||||
this.context.putImageData(
|
||||
this.alternativeView ? this.alternativeImageData : this.imageData,
|
||||
0,
|
||||
0,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
FrameProfiler.end("CanvasTerritoryRenderer:putImageData", putImageStart);
|
||||
}
|
||||
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end("CanvasTerritoryRenderer:drawCanvas", drawCanvasStart);
|
||||
}
|
||||
|
||||
setAlternativeView(enabled: boolean): void {
|
||||
this.alternativeView = enabled;
|
||||
}
|
||||
|
||||
setHover(): void {
|
||||
// Canvas path relies on CPU highlight redraw in TerritoryLayer.
|
||||
}
|
||||
|
||||
setHoverHighlightOptions(): void {
|
||||
// Not used in canvas mode.
|
||||
}
|
||||
|
||||
refreshPalette(): void {
|
||||
// Nothing to refresh for canvas path.
|
||||
}
|
||||
|
||||
clearTile(tile: TileRef) {
|
||||
const offset = tile * 4;
|
||||
this.imageData.data[offset + 3] = 0;
|
||||
this.alternativeImageData.data[offset + 3] = 0;
|
||||
}
|
||||
|
||||
private alternateViewColor(other: PlayerView): Colord {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
if (!myPlayer) {
|
||||
return this.theme.neutralColor();
|
||||
}
|
||||
if (other.smallID() === myPlayer.smallID()) {
|
||||
return this.theme.selfColor();
|
||||
}
|
||||
if (other.isFriendly(myPlayer)) {
|
||||
return this.theme.allyColor();
|
||||
}
|
||||
if (!other.hasEmbargo(myPlayer)) {
|
||||
return this.theme.neutralColor();
|
||||
}
|
||||
return this.theme.enemyColor();
|
||||
}
|
||||
|
||||
private paintTileColor(
|
||||
imageData: ImageData,
|
||||
tile: TileRef,
|
||||
color: Colord,
|
||||
alpha: number,
|
||||
) {
|
||||
const offset = tile * 4;
|
||||
imageData.data[offset] = color.rgba.r;
|
||||
imageData.data[offset + 1] = color.rgba.g;
|
||||
imageData.data[offset + 2] = color.rgba.b;
|
||||
imageData.data[offset + 3] = alpha;
|
||||
}
|
||||
|
||||
private clearAlternativeTile(tile: TileRef) {
|
||||
const offset = tile * 4;
|
||||
this.alternativeImageData.data[offset + 3] = 0;
|
||||
}
|
||||
|
||||
private initImageData() {
|
||||
this.game.forEachTile((tile) => {
|
||||
const offset = tile * 4;
|
||||
this.imageData.data[offset + 3] = 0;
|
||||
this.alternativeImageData.data[offset + 3] = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class WebglTerritoryRenderer implements TerritoryRendererStrategy {
|
||||
constructor(
|
||||
private readonly renderer: TerritoryWebGLRenderer,
|
||||
private readonly game: GameView,
|
||||
) {}
|
||||
|
||||
isWebGL(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
redraw(): void {
|
||||
this.markAllDirty();
|
||||
}
|
||||
|
||||
markAllDirty(): void {
|
||||
this.renderer.markAllDirty();
|
||||
}
|
||||
|
||||
paintTile(tile: TileRef): void {
|
||||
this.renderer.markTile(tile);
|
||||
}
|
||||
|
||||
render(
|
||||
context: CanvasRenderingContext2D,
|
||||
_viewport: { x: number; y: number; width: number; height: number },
|
||||
_shouldBlit: boolean,
|
||||
): void {
|
||||
const webglRenderStart = FrameProfiler.start();
|
||||
this.renderer.render();
|
||||
FrameProfiler.end("WebglTerritoryRenderer:render", webglRenderStart);
|
||||
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.renderer.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end("WebglTerritoryRenderer:drawImage", drawCanvasStart);
|
||||
}
|
||||
|
||||
setAlternativeView(enabled: boolean): void {
|
||||
this.renderer.setAlternativeView(enabled);
|
||||
}
|
||||
|
||||
setHover(playerSmallId: number | null): void {
|
||||
this.renderer.setHoveredPlayerId(playerSmallId ?? null);
|
||||
}
|
||||
|
||||
setHoverHighlightOptions(options: HoverHighlightOptions): void {
|
||||
this.renderer.setHoverHighlightOptions(options);
|
||||
}
|
||||
|
||||
refreshPalette(): void {
|
||||
this.renderer.refreshPalette();
|
||||
}
|
||||
|
||||
clearTile(): void {
|
||||
// No-op for WebGL; canvas alpha clearing is not used.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
import { base64url } from "jose";
|
||||
import { DefaultPattern } from "../../../core/CosmeticSchemas";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
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;
|
||||
}
|
||||
|
||||
const PATTERN_STRIDE_BYTES = 1052;
|
||||
|
||||
// WebGL2 territory renderer that shades tiles from packed tile state
|
||||
// (Uint16Array) using palette, relation, and pattern textures.
|
||||
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 patternTexture: WebGLTexture | null;
|
||||
private readonly uniforms: {
|
||||
resolution: WebGLUniformLocation | null;
|
||||
state: WebGLUniformLocation | null;
|
||||
palette: WebGLUniformLocation | null;
|
||||
relations: WebGLUniformLocation | null;
|
||||
patterns: WebGLUniformLocation | null;
|
||||
patternStride: WebGLUniformLocation | null;
|
||||
patternRows: 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;
|
||||
viewerId: 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 readonly userSettings = new UserSettings();
|
||||
private readonly patternBytesCache = new Map<string, Uint8Array>();
|
||||
|
||||
private constructor(
|
||||
private readonly game: GameView,
|
||||
private readonly theme: Theme,
|
||||
state: Uint16Array,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.canvas.width = game.width();
|
||||
this.canvas.height = game.height();
|
||||
|
||||
this.state = state;
|
||||
|
||||
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.patternTexture = null;
|
||||
this.uniforms = {
|
||||
resolution: null,
|
||||
state: null,
|
||||
palette: null,
|
||||
relations: null,
|
||||
patterns: null,
|
||||
patternStride: null,
|
||||
patternRows: 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,
|
||||
viewerId: 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.patternTexture = null;
|
||||
this.uniforms = {
|
||||
resolution: null,
|
||||
state: null,
|
||||
palette: null,
|
||||
relations: null,
|
||||
patterns: null,
|
||||
patternStride: null,
|
||||
patternRows: 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,
|
||||
viewerId: 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"),
|
||||
patterns: gl.getUniformLocation(this.program, "u_patterns"),
|
||||
patternStride: gl.getUniformLocation(this.program, "u_patternStride"),
|
||||
patternRows: gl.getUniformLocation(this.program, "u_patternRows"),
|
||||
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"),
|
||||
viewerId: gl.getUniformLocation(this.program, "u_viewerId"),
|
||||
};
|
||||
|
||||
// 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();
|
||||
this.patternTexture = 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);
|
||||
gl.uniform1i(this.uniforms.patterns, 3);
|
||||
|
||||
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.viewerId) {
|
||||
const viewerId = this.game.myPlayer()?.smallID() ?? 0;
|
||||
gl.uniform1i(this.uniforms.viewerId, viewerId);
|
||||
}
|
||||
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.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult {
|
||||
const state = game.tileStateView();
|
||||
const expected = game.width() * game.height();
|
||||
if (state.length !== expected) {
|
||||
return {
|
||||
renderer: null,
|
||||
reason:
|
||||
"Tile state buffer size mismatch; falling back to canvas territory draw.",
|
||||
};
|
||||
}
|
||||
|
||||
const renderer = new TerritoryWebGLRenderer(game, theme, state);
|
||||
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 uploadStateSpan = FrameProfiler.start();
|
||||
this.uploadStateTexture();
|
||||
FrameProfiler.end("TerritoryWebGLRenderer:uploadState", uploadStateSpan);
|
||||
|
||||
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);
|
||||
}
|
||||
if (this.uniforms.viewerId) {
|
||||
const viewerId = this.game.myPlayer()?.smallID() ?? 0;
|
||||
gl.uniform1i(this.uniforms.viewerId, viewerId);
|
||||
}
|
||||
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
gl.bindVertexArray(null);
|
||||
FrameProfiler.end("TerritoryWebGLRenderer:draw", renderSpan);
|
||||
}
|
||||
|
||||
private uploadStateTexture(): { rows: number; bytes: number } {
|
||||
if (!this.gl || !this.stateTexture) return { rows: 0, bytes: 0 };
|
||||
const gl = this.gl;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
|
||||
|
||||
const bytesPerPixel = Uint16Array.BYTES_PER_ELEMENT;
|
||||
let rowsUploaded = 0;
|
||||
let bytesUploaded = 0;
|
||||
|
||||
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();
|
||||
rowsUploaded = this.canvas.height;
|
||||
bytesUploaded = this.canvas.width * this.canvas.height * bytesPerPixel;
|
||||
return { rows: rowsUploaded, bytes: bytesUploaded };
|
||||
}
|
||||
|
||||
if (this.dirtyRows.size === 0) {
|
||||
return { rows: 0, bytes: 0 };
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
rowsUploaded++;
|
||||
bytesUploaded += width * bytesPerPixel;
|
||||
}
|
||||
this.dirtyRows.clear();
|
||||
return { rows: rowsUploaded, bytes: bytesUploaded };
|
||||
}
|
||||
|
||||
private uploadPalette() {
|
||||
if (
|
||||
!this.gl ||
|
||||
!this.paletteTexture ||
|
||||
!this.relationTexture ||
|
||||
!this.patternTexture ||
|
||||
!this.program
|
||||
)
|
||||
return;
|
||||
const gl = this.gl;
|
||||
const players = this.game.playerViews().filter((p) => p.isPlayer());
|
||||
|
||||
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 * 8);
|
||||
const relationData = new Uint8Array(this.paletteWidth * this.paletteWidth);
|
||||
const patternData = new Uint8Array(
|
||||
this.paletteWidth * PATTERN_STRIDE_BYTES,
|
||||
);
|
||||
|
||||
const patternsEnabled = this.userSettings.territoryPatterns();
|
||||
const defaultPatternBytes = this.getPatternBytes(
|
||||
DefaultPattern.patternData,
|
||||
);
|
||||
|
||||
for (const p of players) {
|
||||
const id = p.smallID();
|
||||
const territoryRgba = p.territoryColor().rgba;
|
||||
paletteData[id * 8] = territoryRgba.r;
|
||||
paletteData[id * 8 + 1] = territoryRgba.g;
|
||||
paletteData[id * 8 + 2] = territoryRgba.b;
|
||||
paletteData[id * 8 + 3] = Math.round((territoryRgba.a ?? 1) * 255);
|
||||
|
||||
const borderRgba = p.borderColor().rgba;
|
||||
paletteData[id * 8 + 4] = borderRgba.r;
|
||||
paletteData[id * 8 + 5] = borderRgba.g;
|
||||
paletteData[id * 8 + 6] = borderRgba.b;
|
||||
paletteData[id * 8 + 7] = Math.round((borderRgba.a ?? 1) * 255);
|
||||
|
||||
const patternBytes =
|
||||
patternsEnabled && p.cosmetics.pattern
|
||||
? this.getPatternBytes(p.cosmetics.pattern.patternData)
|
||||
: defaultPatternBytes;
|
||||
const offset = id * PATTERN_STRIDE_BYTES;
|
||||
patternData.set(patternBytes.slice(0, PATTERN_STRIDE_BYTES), offset);
|
||||
}
|
||||
|
||||
for (let ownerId = 0; ownerId < this.paletteWidth; ownerId++) {
|
||||
const owner = this.safePlayerBySmallId(ownerId);
|
||||
for (let otherId = 0; otherId < this.paletteWidth; otherId++) {
|
||||
const other = this.safePlayerBySmallId(otherId);
|
||||
relationData[ownerId * this.paletteWidth + otherId] =
|
||||
this.resolveRelationCode(owner, other);
|
||||
}
|
||||
}
|
||||
|
||||
gl.useProgram(this.program);
|
||||
|
||||
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 * 2,
|
||||
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,
|
||||
this.paletteWidth,
|
||||
0,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
relationData,
|
||||
);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE3);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.patternTexture);
|
||||
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,
|
||||
PATTERN_STRIDE_BYTES,
|
||||
this.paletteWidth,
|
||||
0,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
patternData,
|
||||
);
|
||||
|
||||
if (this.uniforms.patternStride) {
|
||||
gl.uniform1i(this.uniforms.patternStride, PATTERN_STRIDE_BYTES);
|
||||
}
|
||||
if (this.uniforms.patternRows) {
|
||||
gl.uniform1i(this.uniforms.patternRows, this.paletteWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRelationCode(
|
||||
owner: PlayerView | null,
|
||||
other: PlayerView | null,
|
||||
): number {
|
||||
if (!owner || !other || !owner.isPlayer() || !other.isPlayer()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let code = 0;
|
||||
if (owner.smallID() === other.smallID()) {
|
||||
code |= 4;
|
||||
}
|
||||
if (owner.isFriendly(other) || other.isFriendly(owner)) {
|
||||
code |= 1;
|
||||
}
|
||||
if (owner.hasEmbargo(other)) {
|
||||
code |= 2;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private safePlayerBySmallId(id: number): PlayerView | null {
|
||||
const player = this.game.playerBySmallID(id);
|
||||
return player instanceof PlayerView ? player : null;
|
||||
}
|
||||
|
||||
private getPatternBytes(patternData: string): Uint8Array {
|
||||
const cached = this.patternBytesCache.get(patternData);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const bytes = base64url.decode(patternData);
|
||||
this.patternBytesCache.set(patternData, bytes);
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
const fallback = base64url.decode(DefaultPattern.patternData);
|
||||
this.patternBytesCache.set(patternData, fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
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 usampler2D u_patterns;
|
||||
uniform int u_patternStride;
|
||||
uniform int u_patternRows;
|
||||
uniform int u_viewerId;
|
||||
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;
|
||||
}
|
||||
|
||||
uint relationCode(uint owner, uint other) {
|
||||
if (owner == 0u || other == 0u) {
|
||||
return 0u;
|
||||
}
|
||||
return texelFetch(u_relations, ivec2(int(owner), int(other)), 0).r;
|
||||
}
|
||||
|
||||
bool isFriendly(uint code) {
|
||||
return (code & 1u) != 0u;
|
||||
}
|
||||
|
||||
bool isEmbargo(uint code) {
|
||||
return (code & 2u) != 0u;
|
||||
}
|
||||
|
||||
bool isSelf(uint code) {
|
||||
return (code & 4u) != 0u;
|
||||
}
|
||||
|
||||
uint patternByte(uint owner, uint offset) {
|
||||
int x = int(offset);
|
||||
int y = int(owner);
|
||||
if (x < 0 || x >= u_patternStride || y < 0 || y >= u_patternRows) {
|
||||
return 0u;
|
||||
}
|
||||
return texelFetch(u_patterns, ivec2(x, y), 0).r;
|
||||
}
|
||||
|
||||
bool patternIsPrimary(uint owner, ivec2 texCoord) {
|
||||
uint version = patternByte(owner, 0u);
|
||||
if (version != 0u) {
|
||||
return true;
|
||||
}
|
||||
uint b1 = patternByte(owner, 1u);
|
||||
uint b2 = patternByte(owner, 2u);
|
||||
uint scale = b1 & 7u;
|
||||
uint width = (((b2 & 3u) << 5) | ((b1 >> 3) & 31u)) + 2u;
|
||||
uint height = ((b2 >> 2) & 63u) + 2u;
|
||||
if (width == 0u || height == 0u) {
|
||||
return true;
|
||||
}
|
||||
uint px = (uint(texCoord.x) >> scale) % width;
|
||||
uint py = (uint(texCoord.y) >> scale) % height;
|
||||
uint idx = py * width + px;
|
||||
uint byteIndex = idx >> 3;
|
||||
uint bitIndex = idx & 7u;
|
||||
uint byteVal = patternByte(owner, 3u + byteIndex);
|
||||
return (byteVal & (1u << bitIndex)) == 0u;
|
||||
}
|
||||
|
||||
void main() {
|
||||
ivec2 fragCoord = ivec2(gl_FragCoord.xy);
|
||||
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;
|
||||
bool isDefended = (state & 0x1000u) != 0u;
|
||||
|
||||
if (owner == 0u) {
|
||||
if (hasFallout) {
|
||||
vec3 color = u_fallout.rgb;
|
||||
float a = u_alpha;
|
||||
outColor = vec4(color * a, a);
|
||||
} else {
|
||||
outColor = vec4(0.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
bool isBorder = false;
|
||||
bool hasFriendlyRelation = false;
|
||||
bool hasEmbargoRelation = false;
|
||||
uint nOwner = ownerAtTex(texCoord + ivec2(1, 0));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
if (nOwner != owner && nOwner != 0u) {
|
||||
uint rel = relationCode(owner, nOwner);
|
||||
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
|
||||
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
|
||||
}
|
||||
nOwner = ownerAtTex(texCoord + ivec2(-1, 0));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
if (nOwner != owner && nOwner != 0u) {
|
||||
uint rel = relationCode(owner, nOwner);
|
||||
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
|
||||
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
|
||||
}
|
||||
nOwner = ownerAtTex(texCoord + ivec2(0, 1));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
if (nOwner != owner && nOwner != 0u) {
|
||||
uint rel = relationCode(owner, nOwner);
|
||||
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
|
||||
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
|
||||
}
|
||||
nOwner = ownerAtTex(texCoord + ivec2(0, -1));
|
||||
isBorder = isBorder || (nOwner != owner);
|
||||
if (nOwner != owner && nOwner != 0u) {
|
||||
uint rel = relationCode(owner, nOwner);
|
||||
hasEmbargoRelation = hasEmbargoRelation || isEmbargo(rel);
|
||||
hasFriendlyRelation = hasFriendlyRelation || isFriendly(rel);
|
||||
}
|
||||
|
||||
if (u_alternativeView) {
|
||||
uint relationAlt = relationCode(owner, uint(u_viewerId));
|
||||
vec4 altColor = u_altNeutral;
|
||||
if (isSelf(relationAlt)) {
|
||||
altColor = u_altSelf;
|
||||
} else if (isFriendly(relationAlt)) {
|
||||
altColor = u_altAlly;
|
||||
} else if (isEmbargo(relationAlt)) {
|
||||
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, a);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 base = texelFetch(u_palette, ivec2(int(owner) * 2, 0), 0);
|
||||
vec4 baseBorder = texelFetch(u_palette, ivec2(int(owner) * 2 + 1, 0), 0);
|
||||
vec3 color = base.rgb;
|
||||
float a = u_alpha;
|
||||
|
||||
if (isBorder) {
|
||||
vec3 borderColor = baseBorder.rgb;
|
||||
|
||||
const float BORDER_TINT_RATIO = 0.35;
|
||||
const vec3 FRIENDLY_TINT_TARGET = vec3(0.0, 1.0, 0.0);
|
||||
const vec3 EMBARGO_TINT_TARGET = vec3(1.0, 0.0, 0.0);
|
||||
|
||||
if (hasFriendlyRelation) {
|
||||
borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) +
|
||||
FRIENDLY_TINT_TARGET * BORDER_TINT_RATIO;
|
||||
}
|
||||
if (hasEmbargoRelation) {
|
||||
borderColor = borderColor * (1.0 - BORDER_TINT_RATIO) +
|
||||
EMBARGO_TINT_TARGET * BORDER_TINT_RATIO;
|
||||
}
|
||||
|
||||
if (isDefended) {
|
||||
bool isLightTile = ((texCoord.x % 2) == (texCoord.y % 2));
|
||||
const float LIGHT_FACTOR = 1.2;
|
||||
const float DARK_FACTOR = 0.8;
|
||||
borderColor *= isLightTile ? LIGHT_FACTOR : DARK_FACTOR;
|
||||
}
|
||||
|
||||
color = borderColor;
|
||||
a = baseBorder.a;
|
||||
} else {
|
||||
bool isPrimary = patternIsPrimary(owner, texCoord);
|
||||
color = isPrimary ? base.rgb : baseBorder.rgb;
|
||||
a = u_alpha;
|
||||
}
|
||||
|
||||
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, 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;
|
||||
}
|
||||
}
|
||||
@@ -692,6 +692,7 @@ export class GameImpl implements Game {
|
||||
owner._lastTileChange = this._ticks;
|
||||
this.updateBorders(tile);
|
||||
this._map.setFallout(tile, false);
|
||||
this.updateDefendedStateForTileChange(tile, owner);
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
|
||||
@@ -710,6 +711,9 @@ export class GameImpl implements Game {
|
||||
|
||||
this._map.setOwnerID(tile, 0);
|
||||
this.updateBorders(tile);
|
||||
if (this._map.isDefended(tile)) {
|
||||
this._map.setDefended(tile, false);
|
||||
}
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
|
||||
@@ -962,18 +966,32 @@ export class GameImpl implements Game {
|
||||
addUnit(u: Unit) {
|
||||
this.unitGrid.addUnit(u);
|
||||
this._unitMap.set(u.id(), u);
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
}
|
||||
removeUnit(u: Unit) {
|
||||
this.unitGrid.removeUnit(u);
|
||||
this._unitMap.delete(u.id());
|
||||
this.planDrivenUnitIds.delete(u.id());
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
if (u.hasTrainStation()) {
|
||||
this._railNetwork.removeStation(u);
|
||||
}
|
||||
}
|
||||
updateUnitTile(u: Unit) {
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
this.unitGrid.updateUnitCell(u);
|
||||
}
|
||||
refreshDefensePostDefendedState(u: Unit) {
|
||||
if (u.type() === UnitType.DefensePost) {
|
||||
this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl);
|
||||
}
|
||||
}
|
||||
|
||||
hasUnitNearby(
|
||||
tile: TileRef,
|
||||
@@ -1097,6 +1115,12 @@ export class GameImpl implements Game {
|
||||
hasFallout(ref: TileRef): boolean {
|
||||
return this._map.hasFallout(ref);
|
||||
}
|
||||
isDefended(ref: TileRef): boolean {
|
||||
return this._map.isDefended(ref);
|
||||
}
|
||||
setDefended(ref: TileRef, value: boolean): void {
|
||||
this._map.setDefended(ref, value);
|
||||
}
|
||||
isBorder(ref: TileRef): boolean {
|
||||
return this._map.isBorder(ref);
|
||||
}
|
||||
@@ -1155,6 +1179,9 @@ export class GameImpl implements Game {
|
||||
updateTile(tile: TileRef, state: number): boolean {
|
||||
return this._map.updateTile(tile, state);
|
||||
}
|
||||
tileStateView(): Uint16Array {
|
||||
return this._map.tileStateView();
|
||||
}
|
||||
numTilesWithFallout(): number {
|
||||
return this._map.numTilesWithFallout();
|
||||
}
|
||||
@@ -1243,6 +1270,56 @@ export class GameImpl implements Game {
|
||||
gold: goldCaptured,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update defended state for border tiles within range of a defense post.
|
||||
*/
|
||||
private updateDefendedStateForDefensePost(
|
||||
center: TileRef,
|
||||
owner: PlayerImpl,
|
||||
) {
|
||||
const range = this.config().defensePostRange();
|
||||
const rangeSq = range * range;
|
||||
|
||||
for (const tile of owner._borderTiles) {
|
||||
if (this._map.euclideanDistSquared(center, tile) <= rangeSq) {
|
||||
const wasDefended = this._map.isDefended(tile);
|
||||
const isDefended = this.unitGrid.hasUnitNearby(
|
||||
tile,
|
||||
range,
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
if (wasDefended !== isDefended) {
|
||||
this._map.setDefended(tile, isDefended);
|
||||
this.recordTileUpdate(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update defended state when a tile changes ownership.
|
||||
*/
|
||||
private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) {
|
||||
const wasDefended = this._map.isDefended(tile);
|
||||
const isDefended = this.unitGrid.hasUnitNearby(
|
||||
tile,
|
||||
this.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
if (wasDefended !== isDefended) {
|
||||
this._map.setDefended(tile, isDefended);
|
||||
}
|
||||
|
||||
// If the conquered tile has a defense post, update nearby border tiles
|
||||
if (
|
||||
this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id())
|
||||
) {
|
||||
this.updateDefendedStateForDefensePost(tile, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Or a more dynamic approach that will catch new enum values:
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface GameMap {
|
||||
setOwnerID(ref: TileRef, playerId: number): void;
|
||||
hasFallout(ref: TileRef): boolean;
|
||||
setFallout(ref: TileRef, value: boolean): void;
|
||||
isDefended(ref: TileRef): boolean;
|
||||
setDefended(ref: TileRef, value: boolean): void;
|
||||
tileStateView(): Uint16Array;
|
||||
isOnEdgeOfMap(ref: TileRef): boolean;
|
||||
isBorder(ref: TileRef): boolean;
|
||||
neighbors(ref: TileRef): TileRef[];
|
||||
@@ -96,6 +99,7 @@ export class GameMapImpl implements GameMap {
|
||||
|
||||
// State bits (Uint16Array)
|
||||
private static readonly PLAYER_ID_MASK = 0xfff;
|
||||
private static readonly DEFENDED_BIT = 12;
|
||||
private static readonly FALLOUT_BIT = 13;
|
||||
private static readonly DEFENSE_BONUS_BIT = 14;
|
||||
// Bit 15 still reserved
|
||||
@@ -266,6 +270,22 @@ export class GameMapImpl implements GameMap {
|
||||
}
|
||||
}
|
||||
|
||||
isDefended(ref: TileRef): boolean {
|
||||
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT));
|
||||
}
|
||||
|
||||
setDefended(ref: TileRef, value: boolean): void {
|
||||
if (value) {
|
||||
this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT;
|
||||
} else {
|
||||
this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT);
|
||||
}
|
||||
}
|
||||
|
||||
tileStateView(): Uint16Array {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
isOnEdgeOfMap(ref: TileRef): boolean {
|
||||
const x = this.x(ref);
|
||||
const y = this.y(ref);
|
||||
|
||||
@@ -1323,6 +1323,15 @@ export class GameView implements GameMap {
|
||||
setFallout(ref: TileRef, value: boolean): void {
|
||||
return this._map.setFallout(ref, value);
|
||||
}
|
||||
isDefended(ref: TileRef): boolean {
|
||||
return this._map.isDefended(ref);
|
||||
}
|
||||
setDefended(ref: TileRef, value: boolean): void {
|
||||
return this._map.setDefended(ref, value);
|
||||
}
|
||||
tileStateView(): Uint16Array {
|
||||
return this._map.tileStateView();
|
||||
}
|
||||
isBorder(ref: TileRef): boolean {
|
||||
return this._map.isBorder(ref);
|
||||
}
|
||||
|
||||
@@ -433,6 +433,9 @@ export class UnitImpl implements Unit {
|
||||
setUnderConstruction(underConstruction: boolean): void {
|
||||
if (this._underConstruction !== underConstruction) {
|
||||
this._underConstruction = underConstruction;
|
||||
if (this._type === UnitType.DefensePost) {
|
||||
this.mg.refreshDefensePostDefendedState(this);
|
||||
}
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user