mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 17:00:56 +00:00
341f344ce5
## Description: Skip slow and blocking LocalStorage reads, replace by a Map. Also some refactoring. ### Contains - No out-of-sync issue between main and worker thread: Earlier PRs got a comment from evan about main & worker.worker thread having their own version of usersettings and possibly getting out-of-sync (see https://github.com/openfrontio/OpenFrontIO/pull/760#pullrequestreview-2845155737, https://github.com/openfrontio/OpenFrontIO/pull/896#pullrequestreview-2871836979 and https://github.com/openfrontio/OpenFrontIO/pull/1266. But userSettings is not used in files ran by worker.worker, not even 10 months after evan's first comment about it. In GameRunner, createGameRunner sends NULL to getConfig as argument for userSettings. And DefaultConfig guards against userSettings being null by throwing an error, but it has never been thrown which points to worker.worker thread not using userSettings. So we do not need to worry about syncing between the threads currently. (If needed in the future after all, we could quite easily sync it, by loading the userSettings cache on worker.worker and listening to the "user-settings-changed" event @scamiv to keep it synced (changes in WorkerMessages and WorkerClient etc would be needed to handle this). - Went with cache in UserSettings, not with listening to "user-settings-changed" event: "user-settings-changed" was added by @scamiv and is used in PerformanceOverlay. Which is great for single files that need the very best performance. But having to add that same system to any file reading settings, scales poorly and would lead to messy code. Also, a developer could make the mistake of not listening to the event and it would end up just reading LocalStorage again just like now. Also a developer might forget removing the listener or so etc. The cache is a central solution and fast, without changes to other files needed and future-proof. - Make sure each setting is cached: UserSettingsModal was using LocalStorage directly by itself for some things. Made it use the central UserSettings methods instead so we avoid LocalStorage reads as much as possible. For this, changed get() and set() in UserSettings to getBool() and setBool(), to introduce a getString() and setString() for use in UserSettingsModal while keeping getCached() and setCached() private within UserSettings. - Remove unused 'focusLocked' and 'toggleFocusLocked' from UserSettings: was last changed 11 months ago to just return false. Since then we've moved to different ways of highlighting and this setting isn't used anymore. No existing references or callers are left. - Other files: -- Have callers call the renamed functions (see point above) -- Remove userSettings from UILayer and Territorylayer: the variable is unused in those files. Also remove from GameRenderer when it calls TerritoryLayer. -- Cache calls to defaultconfig Theme (which in turn calls dark mode setting)/Config better in: GameView and Terrainlayer. ### Update on Contents later on It wasn't really in scope of this PR but further consolidation was called for. These changes could also pave the way for UserSettingsModal (main menu) perhaps being partly mergable with SettingsModal (in-game) one day as it begins to look more like it. Even though UserSettingsModal still does things its own way, and does console.log where SettingsModal doesn't, etc. They both have partially different content and settings but also have a large overlap. - UserSettings: Removed localStorage call from clearFlag() and setFlag() which were added after creation of this PR, and were neatly merged in silence without merge conflicts so i wasn't aware of them yet until now. - UserSettings: added key constants, exported to use both inside UserSettings and in files that listen to its events. - UserSettings 'emitChange': now done from setCached, removed from setBool, setFlag etc. Also removed from the new setFlag. And from setPattern even though it emitted "pattern" instead of key name "territoryPattern"; now it emits the default "territoryPattern" from PATTERN_KEY which is re-used in Store, TerritoryPatternsModal and PatternInput. - UserSettingsModal: made UserSettingsModal call existing toggle functions in UserSettings, or new or existing getter or setter. We do not need CustomEvent: checked anymore. In UserSettingsModal, its toggle functions did not all actually toggle, some like toggleLeftClickOpensMenu actually just set a value. Based on the 'checked' value of the CustomEvent. But we don't need that 'checked' value anymore and none of the checks for it inside the toggle functions in UserSettingsModal, now that we just directly call toggleLeftClickOpensMenu and others in UserSettings. - SettingToggle: continuing about not needing CustomEvent anymore: the old way actually fired two events. The native change event from <input> and our own CustomEvent from handleChange in SettingToggle. It prevented handling both events by checking e.detail?.checked === undefined. But now, the native <input> event is all we need to show the visual toggle change and trigger @changed in UserSettingsModal which calls the toggle function. - Use the toggle functions too from CopyButton and PerformanceOverlay.ts. In PerformanceOverlay, change in onUserSettingsChanged was needed because of how setBool works. - UserSettingsModal 'toggleDarkMode': in UserSettingsModal, removed the event from toggleDarkMode in UserSettingsModal; nothing is listening to this event anymore after DarkModeButton.ts was removed some time ago. Also both UserSettingsModal an UserSettings added/removed "dark" from the document element. Now that UserSettingsModal calls toggleDarkMode in UserSettings, we could centralize that. But UserSettings is in core, not in client like UserSettingsModal. But now that we emit "user-settings-changed", we could handle it even more centralized and not have UserSettingsModal or UserSettings touch the element directly. Instead have Main.ts listen to the event and change it dark mode from there. - UserSettings: added claryfing comment to attackRatioIncrement and the new attackRatio setters/getters, to explain their difference. Noticed a small omitment in its description and fixed that right away in en.json: you can change attack ratio increment by shift+mouse wheel scroll or by hotkey. So made "How much the attack ratio keybinds change per press" also mention "/scroll." **BEFORE** (with getDisplayName added back to NameLayer as a fix i will do soon) get > getItem in UserSettings  renderLayer in NameLayer (with getDisplayName added back to NameLayer as a fix i will do soon)  **AFTER** (with getDisplayName added back to NameLayer as a fix i will do soon) getCached in UserSettings  renderLayer in NameLayer (with getDisplayName added back to NameLayer as a fix i will do soon)  ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33
707 lines
21 KiB
TypeScript
707 lines
21 KiB
TypeScript
import { PriorityQueue } from "@datastructures-js/priority-queue";
|
|
import { Colord } from "colord";
|
|
import { Theme } from "../../../core/configuration/Config";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import {
|
|
Cell,
|
|
ColoredTeams,
|
|
PlayerType,
|
|
Team,
|
|
UnitType,
|
|
} from "../../../core/game/Game";
|
|
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
|
|
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
|
import { GameView, PlayerView } from "../../../core/game/GameView";
|
|
import { PseudoRandom } from "../../../core/PseudoRandom";
|
|
import {
|
|
AlternateViewEvent,
|
|
DragEvent,
|
|
MouseOverEvent,
|
|
} from "../../InputHandler";
|
|
import { FrameProfiler } from "../FrameProfiler";
|
|
import { TransformHandler } from "../TransformHandler";
|
|
import { Layer } from "./Layer";
|
|
|
|
export class TerritoryLayer implements Layer {
|
|
private canvas: HTMLCanvasElement;
|
|
private context: CanvasRenderingContext2D;
|
|
private imageData: ImageData;
|
|
private alternativeImageData: ImageData;
|
|
private borderAnimTime = 0;
|
|
|
|
private cachedTerritoryPatternsEnabled: boolean | undefined;
|
|
|
|
private tileToRenderQueue: PriorityQueue<{
|
|
tile: TileRef;
|
|
lastUpdate: number;
|
|
}> = new PriorityQueue((a, b) => {
|
|
return a.lastUpdate - b.lastUpdate;
|
|
});
|
|
private random = new PseudoRandom(123);
|
|
private theme: Theme;
|
|
|
|
// Used for spawn highlighting
|
|
private highlightCanvas: HTMLCanvasElement;
|
|
private highlightContext: CanvasRenderingContext2D;
|
|
|
|
private highlightedTerritory: PlayerView | null = null;
|
|
|
|
private alternativeView = false;
|
|
private lastDragTime = 0;
|
|
private nodrawDragDuration = 200;
|
|
private lastMousePosition: { x: number; y: number } | null = null;
|
|
|
|
private refreshRate = 10; //refresh every 10ms
|
|
private lastRefresh = 0;
|
|
|
|
private lastFocusedPlayer: PlayerView | null = null;
|
|
|
|
constructor(
|
|
private game: GameView,
|
|
private eventBus: EventBus,
|
|
private transformHandler: TransformHandler,
|
|
) {
|
|
this.theme = game.config().theme();
|
|
this.cachedTerritoryPatternsEnabled = undefined;
|
|
}
|
|
|
|
shouldTransform(): boolean {
|
|
return true;
|
|
}
|
|
|
|
async paintPlayerBorder(player: PlayerView) {
|
|
const tiles = await player.borderTiles();
|
|
tiles.borderTiles.forEach((tile: TileRef) => {
|
|
this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing
|
|
});
|
|
}
|
|
|
|
tick() {
|
|
if (this.game.inSpawnPhase()) {
|
|
this.spawnHighlight();
|
|
}
|
|
|
|
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
|
|
const updates = this.game.updatesSinceLastTick();
|
|
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
|
|
unitUpdates.forEach((update) => {
|
|
if (update.unitType === UnitType.DefensePost) {
|
|
// Only update borders if the defense post is not under construction
|
|
if (update.underConstruction) {
|
|
return; // Skip barrier creation while under construction
|
|
}
|
|
|
|
const tile = update.pos;
|
|
this.game
|
|
.bfs(tile, euclDistFN(tile, this.game.config().defensePostRange()))
|
|
.forEach((t) => {
|
|
if (
|
|
this.game.isBorder(t) &&
|
|
(this.game.ownerID(t) === update.ownerID ||
|
|
this.game.ownerID(t) === update.lastOwnerID)
|
|
) {
|
|
this.enqueueTile(t);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Detect alliance mutations
|
|
const myPlayer = this.game.myPlayer();
|
|
if (myPlayer) {
|
|
updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => {
|
|
const territory = this.game.playerBySmallID(update.betrayedID);
|
|
if (territory && territory instanceof PlayerView) {
|
|
this.redrawBorder(territory);
|
|
}
|
|
});
|
|
|
|
updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => {
|
|
if (
|
|
update.accepted &&
|
|
(update.request.requestorID === myPlayer.smallID() ||
|
|
update.request.recipientID === myPlayer.smallID())
|
|
) {
|
|
const territoryId =
|
|
update.request.requestorID === myPlayer.smallID()
|
|
? update.request.recipientID
|
|
: update.request.requestorID;
|
|
const territory = this.game.playerBySmallID(territoryId);
|
|
if (territory && territory instanceof PlayerView) {
|
|
this.redrawBorder(territory);
|
|
}
|
|
}
|
|
});
|
|
updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => {
|
|
const player = this.game.playerBySmallID(update.playerID) as PlayerView;
|
|
const embargoed = this.game.playerBySmallID(
|
|
update.embargoedID,
|
|
) as PlayerView;
|
|
|
|
if (
|
|
player.id() === myPlayer?.id() ||
|
|
embargoed.id() === myPlayer?.id()
|
|
) {
|
|
this.redrawBorder(player, embargoed);
|
|
}
|
|
});
|
|
}
|
|
|
|
const focusedPlayer = this.game.focusedPlayer();
|
|
if (focusedPlayer !== this.lastFocusedPlayer) {
|
|
if (this.lastFocusedPlayer) {
|
|
this.paintPlayerBorder(this.lastFocusedPlayer);
|
|
}
|
|
if (focusedPlayer) {
|
|
this.paintPlayerBorder(focusedPlayer);
|
|
}
|
|
this.lastFocusedPlayer = focusedPlayer;
|
|
}
|
|
}
|
|
|
|
private spawnHighlight() {
|
|
if (this.game.ticks() % 5 === 0) {
|
|
return;
|
|
}
|
|
|
|
this.highlightContext.clearRect(
|
|
0,
|
|
0,
|
|
this.game.width(),
|
|
this.game.height(),
|
|
);
|
|
|
|
this.drawFocusedPlayerHighlight();
|
|
|
|
const humans = this.game
|
|
.playerViews()
|
|
.filter((p) => p.type() === PlayerType.Human);
|
|
|
|
const focusedPlayer = this.game.focusedPlayer();
|
|
const teamColors = Object.values(ColoredTeams);
|
|
for (const human of humans) {
|
|
if (human === focusedPlayer) {
|
|
continue;
|
|
}
|
|
const center = human.nameLocation();
|
|
if (!center) {
|
|
continue;
|
|
}
|
|
const centerTile = this.game.ref(center.x, center.y);
|
|
if (!centerTile) {
|
|
continue;
|
|
}
|
|
let color = this.theme.spawnHighlightColor();
|
|
const myPlayer = this.game.myPlayer();
|
|
if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) {
|
|
// In FFA games (when team === null), use default yellow spawn highlight color
|
|
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);
|
|
} else {
|
|
if (myPlayer.isFriendly(human)) {
|
|
color = this.theme.spawnHighlightTeamColor();
|
|
} else {
|
|
color = this.theme.spawnHighlightColor();
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const tile of this.game.bfs(
|
|
centerTile,
|
|
euclDistFN(centerTile, 9, true),
|
|
)) {
|
|
if (!this.game.hasOwner(tile)) {
|
|
this.paintHighlightTile(tile, color, 255);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private drawFocusedPlayerHighlight() {
|
|
const focusedPlayer = this.game.focusedPlayer();
|
|
|
|
if (!focusedPlayer) {
|
|
return;
|
|
}
|
|
const center = focusedPlayer.nameLocation();
|
|
if (!center) {
|
|
return;
|
|
}
|
|
// Breathing border animation
|
|
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
|
|
let teamColor: Colord | null = null;
|
|
|
|
const team: Team | null = focusedPlayer.team();
|
|
if (team !== null && Object.values(ColoredTeams).includes(team)) {
|
|
teamColor = this.theme.teamColor(team).alpha(0.5);
|
|
} else {
|
|
teamColor = baseColor;
|
|
}
|
|
|
|
this.drawBreathingRing(
|
|
center.x,
|
|
center.y,
|
|
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.
|
|
);
|
|
|
|
// Draw breathing rings for teammates in team games (helps colorblind players identify teammates)
|
|
this.drawTeammateHighlights(minRad, maxRad, radius);
|
|
}
|
|
|
|
private drawTeammateHighlights(
|
|
minRad: number,
|
|
maxRad: number,
|
|
radius: number,
|
|
) {
|
|
const myPlayer = this.game.myPlayer();
|
|
if (myPlayer === null || myPlayer.team() === null) {
|
|
return;
|
|
}
|
|
|
|
const teammates = this.game
|
|
.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 =
|
|
teammateMinRad +
|
|
(teammateMaxRad - teammateMinRad) *
|
|
((radius - minRad) / (maxRad - minRad));
|
|
|
|
const teamColors = Object.values(ColoredTeams);
|
|
for (const teammate of teammates) {
|
|
const center = teammate.nameLocation();
|
|
if (!center) {
|
|
continue;
|
|
}
|
|
|
|
const team = teammate.team();
|
|
let baseColor: Colord;
|
|
let breathingColor: Colord;
|
|
|
|
if (team !== null && teamColors.includes(team)) {
|
|
baseColor = this.theme.teamColor(team).alpha(0.5);
|
|
breathingColor = this.theme.teamColor(team).alpha(0.5);
|
|
} else {
|
|
baseColor = this.theme.spawnHighlightTeamColor();
|
|
breathingColor = this.theme.spawnHighlightTeamColor();
|
|
}
|
|
|
|
this.drawBreathingRing(
|
|
center.x,
|
|
center.y,
|
|
teammateMinRad,
|
|
teammateMaxRad,
|
|
teammateRadius,
|
|
baseColor,
|
|
breathingColor,
|
|
);
|
|
}
|
|
}
|
|
|
|
init() {
|
|
this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e));
|
|
this.eventBus.on(AlternateViewEvent, (e) => {
|
|
this.alternativeView = e.alternateView;
|
|
});
|
|
this.eventBus.on(DragEvent, (e) => {
|
|
// TODO: consider re-enabling this on mobile or low end devices for smoother dragging.
|
|
// this.lastDragTime = Date.now();
|
|
});
|
|
this.redraw();
|
|
}
|
|
|
|
onMouseOver(event: MouseOverEvent) {
|
|
this.lastMousePosition = { x: event.x, y: event.y };
|
|
this.updateHighlightedTerritory();
|
|
}
|
|
|
|
private updateHighlightedTerritory() {
|
|
if (!this.alternativeView) {
|
|
return;
|
|
}
|
|
|
|
if (!this.lastMousePosition) {
|
|
return;
|
|
}
|
|
|
|
const cell = this.transformHandler.screenToWorldCoordinates(
|
|
this.lastMousePosition.x,
|
|
this.lastMousePosition.y,
|
|
);
|
|
if (!this.game.isValidCoord(cell.x, cell.y)) {
|
|
return;
|
|
}
|
|
|
|
const previousTerritory = this.highlightedTerritory;
|
|
const territory = this.getTerritoryAtCell(cell);
|
|
|
|
if (territory) {
|
|
this.highlightedTerritory = territory;
|
|
} else {
|
|
this.highlightedTerritory = null;
|
|
}
|
|
|
|
if (previousTerritory?.id() !== this.highlightedTerritory?.id()) {
|
|
const territories: PlayerView[] = [];
|
|
if (previousTerritory) {
|
|
territories.push(previousTerritory);
|
|
}
|
|
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.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,
|
|
);
|
|
|
|
// Add a second canvas for highlights
|
|
this.highlightCanvas = document.createElement("canvas");
|
|
const highlightContext = this.highlightCanvas.getContext("2d", {
|
|
alpha: true,
|
|
});
|
|
if (highlightContext === null) throw new Error("2d context not supported");
|
|
this.highlightContext = highlightContext;
|
|
this.highlightCanvas.width = this.game.width();
|
|
this.highlightCanvas.height = this.game.height();
|
|
|
|
this.game.forEachTile((t) => {
|
|
this.paintTerritory(t);
|
|
});
|
|
}
|
|
|
|
redrawBorder(...players: PlayerView[]) {
|
|
return Promise.all(
|
|
players.map(async (player) => {
|
|
const tiles = await player.borderTiles();
|
|
tiles.borderTiles.forEach((tile: TileRef) => {
|
|
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;
|
|
});
|
|
}
|
|
|
|
renderLayer(context: CanvasRenderingContext2D) {
|
|
const now = Date.now();
|
|
if (
|
|
now > this.lastDragTime + this.nodrawDragDuration &&
|
|
now > this.lastRefresh + this.refreshRate
|
|
) {
|
|
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);
|
|
if (this.game.inSpawnPhase()) {
|
|
const highlightDrawStart = FrameProfiler.start();
|
|
context.drawImage(
|
|
this.highlightCanvas,
|
|
-this.game.width() / 2,
|
|
-this.game.height() / 2,
|
|
this.game.width(),
|
|
this.game.height(),
|
|
);
|
|
FrameProfiler.end(
|
|
"TerritoryLayer:drawHighlightCanvas",
|
|
highlightDrawStart,
|
|
);
|
|
}
|
|
}
|
|
|
|
renderTerritory() {
|
|
let numToRender = Math.floor(this.tileToRenderQueue.size() / 10);
|
|
if (numToRender === 0 || this.game.inSpawnPhase()) {
|
|
numToRender = this.tileToRenderQueue.size();
|
|
}
|
|
|
|
while (numToRender > 0) {
|
|
numToRender--;
|
|
|
|
const entry = this.tileToRenderQueue.pop();
|
|
if (!entry) {
|
|
break;
|
|
}
|
|
|
|
const tile = entry.tile;
|
|
this.paintTerritory(tile);
|
|
for (const neighbor of this.game.neighbors(tile)) {
|
|
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(),
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
enqueueTile(tile: TileRef) {
|
|
this.tileToRenderQueue.push({
|
|
tile: tile,
|
|
lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5),
|
|
});
|
|
}
|
|
|
|
async enqueuePlayerBorder(player: PlayerView) {
|
|
const playerBorderTiles = await player.borderTiles();
|
|
playerBorderTiles.borderTiles.forEach((tile: TileRef) => {
|
|
this.enqueueTile(tile);
|
|
});
|
|
}
|
|
|
|
paintHighlightTile(tile: TileRef, color: Colord, alpha: number) {
|
|
this.clearTile(tile);
|
|
const x = this.game.x(tile);
|
|
const y = this.game.y(tile);
|
|
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
|
|
this.highlightContext.fillRect(x, y, 1, 1);
|
|
}
|
|
|
|
clearHighlightTile(tile: TileRef) {
|
|
const x = this.game.x(tile);
|
|
const y = this.game.y(tile);
|
|
this.highlightContext.clearRect(x, y, 1, 1);
|
|
}
|
|
|
|
private drawBreathingRing(
|
|
cx: number,
|
|
cy: number,
|
|
minRad: number,
|
|
maxRad: number,
|
|
radius: number,
|
|
transparentColor: Colord,
|
|
breathingColor: Colord,
|
|
) {
|
|
const ctx = this.highlightContext;
|
|
if (!ctx) return;
|
|
|
|
// 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();
|
|
}
|
|
}
|