Files
OpenFrontIO/src/client/view/PlayerView.ts
T
evanpelle 5b663fae14 refactor: share renderer state shapes between game and WebGL renderer
PlayerView/UnitView now wrap renderer-shaped state objects (PlayerState,
PlayerStatic, UnitState) directly instead of holding engine wire types.
GameView owns a long-lived FrameData object kept in sync each tick:
players/units/tiles/trail/railroad are mutated in place; derived buffers
(playerStatus, relationMatrix, allianceClusters, nukeTelegraphs,
attackRings) and events are recomputed in a final populateFrame() pass.

The renderer reads gameView.frameData() and the same byte-identical
state objects PlayerView/UnitView wrap. WebGLFrameBuilder shrinks from
~270 to ~70 LOC: palette management + a single uploadFrameData() call,
no per-frame UnitState allocation on the hot path.

Wiring: maxPlayers=1024 on RendererConfig (pre-sizes NamePass/palette/
relation matrix textures); NamePass disabled so HTML NameLayer remains
the only on-screen player names.

Also: 39 new tests covering PlayerView/GameView/FrameData behavior;
replace .data field access in three layer call sites with accessor
methods (betrayals(), type(), getTraitorRemainingTicks()).
2026-05-16 13:27:31 -07:00

577 lines
16 KiB
TypeScript

import { Colord, colord } from "colord";
import { base64url } from "jose";
import { ColorPalette } from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { ClientID, PlayerCosmetics } from "../../core/Schemas";
import { createRandomName } from "../../core/Util";
import {
BuildableUnit,
Cell,
EmojiMessage,
Gold,
NameViewData,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
PlayerType,
Team,
Tick,
UnitType,
} from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import {
AllianceView,
AttackUpdate,
PlayerUpdate,
} from "../../core/game/GameUpdates";
import { UserSettings } from "../../core/game/UserSettings";
import { PlayerState, PlayerStatic, PlayerTypeEnum } from "../render/types";
import { GameView } from "./GameView";
import { UnitView } from "./UnitView";
const userSettings: UserSettings = new UserSettings();
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
const BORDER_TINT_RATIO = 0.35;
function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum {
switch (t) {
case PlayerType.Human:
return PlayerTypeEnum.Human;
case PlayerType.Bot:
return PlayerTypeEnum.Bot;
case PlayerType.Nation:
return PlayerTypeEnum.Nation;
default:
return PlayerTypeEnum.Bot;
}
}
function staticFromUpdate(pu: PlayerUpdate): PlayerStatic {
return {
smallID: pu.smallID,
id: pu.id,
name: pu.name,
displayName: pu.displayName,
clientID: pu.clientID,
playerType: gamePlayerTypeToEnum(pu.playerType),
team: pu.team ?? null,
isLobbyCreator: pu.isLobbyCreator,
};
}
function stateFromUpdate(pu: PlayerUpdate): PlayerState {
// embargoes: Set<PlayerID strings> on the wire, but the renderer expects
// stringified smallIDs. GameView fills these in via setEmbargoes() because
// it has the PlayerID → smallID lookup table.
return {
smallID: pu.smallID,
isAlive: pu.isAlive,
isDisconnected: pu.isDisconnected,
tilesOwned: pu.tilesOwned,
gold: Number(pu.gold),
troops: pu.troops,
isTraitor: pu.isTraitor,
traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0),
betrayals: pu.betrayals,
hasSpawned: pu.hasSpawned,
lastDeleteUnitTick: pu.lastDeleteUnitTick,
allies: pu.allies.slice(),
embargoes: [],
targets: pu.targets.slice(),
outgoingAttacks: pu.outgoingAttacks,
incomingAttacks: pu.incomingAttacks,
outgoingAllianceRequests: pu.outgoingAllianceRequests.slice(),
alliances: pu.alliances,
outgoingEmojis: pu.outgoingEmojis,
};
}
function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
// smallID is identity — never changes for a given PlayerView.
target.isAlive = pu.isAlive;
target.isDisconnected = pu.isDisconnected;
target.tilesOwned = pu.tilesOwned;
target.gold = Number(pu.gold);
target.troops = pu.troops;
target.isTraitor = pu.isTraitor;
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks ?? 0);
target.betrayals = pu.betrayals;
target.hasSpawned = pu.hasSpawned;
target.lastDeleteUnitTick = pu.lastDeleteUnitTick;
// Slice() to detach from the wire object — accumulated state mustn't share
// mutable arrays with per-tick update payloads.
target.allies = pu.allies.slice();
target.targets = pu.targets.slice();
target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice();
target.outgoingAttacks = pu.outgoingAttacks;
target.incomingAttacks = pu.incomingAttacks;
target.alliances = pu.alliances;
target.outgoingEmojis = pu.outgoingEmojis;
}
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
/** Long-lived renderer state — mutated in place by applyUpdate(). */
public state: PlayerState;
/** Static header data — set once at construction, never mutated. */
public static: PlayerStatic;
private _territoryColor: Colord;
private _borderColor: Colord;
// Update here to include structure light and dark colors
private _structureColors: { light: Colord; dark: Colord };
// Pre-computed border color variants
private _borderColorNeutral: Colord;
private _borderColorFriendly: Colord;
private _borderColorEmbargo: Colord;
private _borderColorDefendedNeutral: { light: Colord; dark: Colord };
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
constructor(
private game: GameView,
data: PlayerUpdate,
public nameData: NameViewData,
public cosmetics: PlayerCosmetics,
) {
this.state = stateFromUpdate(data);
this.static = staticFromUpdate(data);
if (data.clientID === game.myClientID()) {
this.anonymousName = data.name;
} else {
this.anonymousName = createRandomName(data.name, data.playerType);
}
const theme = this.game.config().theme();
const defaultTerritoryColor = theme.territoryColor(this);
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
if (pattern) {
pattern.colorPalette ??= {
name: "",
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.color?.color ??
pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = defaultTerritoryColor;
}
this._structureColors = theme.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === data.clientID
? theme.focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
const baseRgb = this._borderColor.toRgb();
this._borderColorNeutral = this._borderColor;
this._borderColorFriendly = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
this._borderColorEmbargo = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
this._borderColorDefendedNeutral = theme.defendedBorderColors(
this._borderColorNeutral,
);
this._borderColorDefendedFriendly = theme.defendedBorderColors(
this._borderColorFriendly,
);
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
this._borderColorEmbargo,
);
this.decoder =
pattern === undefined
? undefined
: new PatternDecoder(pattern, base64url.decode);
}
/**
* Update mutable state in place. Called by GameView.update() each tick the
* player appears in the PlayerUpdate stream.
*/
applyUpdate(pu: PlayerUpdate): void {
applyStateUpdate(this.state, pu);
}
/** Set the renderer-format embargoes (stringified smallIDs). */
setEmbargoSmallIDs(smallIDStrings: string[]): void {
this.state.embargoes = smallIDStrings;
}
territoryColor(tile?: TileRef): Colord {
if (tile === undefined || this.decoder === undefined) {
return this._territoryColor;
}
const isPrimary = this.decoder.isPrimary(
this.game.x(tile),
this.game.y(tile),
);
return isPrimary ? this._territoryColor : this._borderColor;
}
structureColors(): { light: Colord; dark: Colord } {
return this._structureColors;
}
/**
* Border color for a tile:
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
* - If defended, applies theme checkerboard to the tinted color.
*/
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
if (tile === undefined) {
return this._borderColor;
}
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
let baseColor: Colord;
let defendedColors: { light: Colord; dark: Colord };
if (hasEmbargo) {
baseColor = this._borderColorEmbargo;
defendedColors = this._borderColorDefendedEmbargo;
} else if (hasFriendly) {
baseColor = this._borderColorFriendly;
defendedColors = this._borderColorDefendedFriendly;
} else {
baseColor = this._borderColorNeutral;
defendedColors = this._borderColorDefendedNeutral;
}
if (!isDefended) {
return baseColor;
}
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
return lightTile ? defendedColors.light : defendedColors.dark;
}
/**
* Border relation flags for a tile, used by both CPU and WebGL renderers.
*/
borderRelationFlags(tile: TileRef): {
hasEmbargo: boolean;
hasFriendly: boolean;
} {
const mySmallID = this.smallID();
let hasEmbargo = false;
let hasFriendly = false;
for (const n of this.game.neighbors(tile)) {
if (!this.game.hasOwner(n)) {
continue;
}
const otherOwner = this.game.owner(n);
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
continue;
}
if (this.hasEmbargo(otherOwner)) {
hasEmbargo = true;
break;
}
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
hasFriendly = true;
}
}
return { hasEmbargo, hasFriendly };
}
async actions(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async buildables(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
return this.game.worker.playerBuildables(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async borderTiles(): Promise<PlayerBorderTiles> {
return this.game.worker.playerBorderTiles(this.id());
}
outgoingAttacks(): AttackUpdate[] {
return this.state.outgoingAttacks;
}
incomingAttacks(): AttackUpdate[] {
return this.state.incomingAttacks;
}
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
return this.game
.units(...types)
.filter((u) => u.owner().smallID() === this.smallID());
}
nameLocation(): NameViewData {
return this.nameData;
}
smallID(): number {
return this.state.smallID;
}
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.static.name;
}
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.static.displayName;
}
clientID(): ClientID | null {
return this.static.clientID;
}
id(): PlayerID {
return this.static.id;
}
team(): Team | null {
return this.static.team;
}
type(): PlayerType {
// Map PlayerStatic's numeric enum back to engine string enum.
switch (this.static.playerType) {
case PlayerTypeEnum.Human:
return PlayerType.Human;
case PlayerTypeEnum.Bot:
return PlayerType.Bot;
case PlayerTypeEnum.Nation:
return PlayerType.Nation;
default:
return PlayerType.Bot;
}
}
isAlive(): boolean {
return this.state.isAlive;
}
isPlayer(): this is PlayerView {
return true;
}
numTilesOwned(): number {
return this.state.tilesOwned;
}
allies(): PlayerView[] {
return this.state.allies.map(
(a) => this.game.playerBySmallID(a) as PlayerView,
);
}
targets(): PlayerView[] {
return this.state.targets.map(
(id) => this.game.playerBySmallID(id) as PlayerView,
);
}
gold(): Gold {
// Engine Gold is bigint; renderer state stores number. Convert back at the
// accessor for game-code that still expects bigint semantics.
return BigInt(this.state.gold);
}
troops(): number {
return this.state.troops;
}
totalUnitLevels(type: UnitType): number {
return this.units(type)
.filter((unit) => !unit.isUnderConstruction())
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isMe(): boolean {
return this.smallID() === this.game.myPlayer()?.smallID();
}
isLobbyCreator(): boolean {
return this.static.isLobbyCreator;
}
isAlliedWith(other: PlayerView): boolean {
return this.state.allies.some((n) => other.smallID() === n);
}
isOnSameTeam(other: PlayerView): boolean {
return this.static.team !== null && this.static.team === other.static.team;
}
isFriendly(other: PlayerView): boolean {
return this.isAlliedWith(other) || this.isOnSameTeam(other);
}
isRequestingAllianceWith(other: PlayerView) {
return this.state.outgoingAllianceRequests.some((id) => other.id() === id);
}
alliances(): AllianceView[] {
return this.state.alliances;
}
hasEmbargoAgainst(other: PlayerView): boolean {
const otherSmallIDStr = String(other.smallID());
return this.state.embargoes.includes(otherSmallIDStr);
}
hasEmbargo(other: PlayerView): boolean {
return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this);
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
const result: PlayerView[] = [];
// Add own targets
for (const id of this.state.targets) {
result.push(this.game.playerBySmallID(id) as PlayerView);
}
// Add allies' targets
for (const allyID of this.state.allies) {
const ally = this.game.playerBySmallID(allyID) as PlayerView;
for (const targetId of ally.state.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
// Add teammates' targets
const myTeam = this.static.team;
if (myTeam !== null) {
for (const p of this.game.playerViews()) {
if (p !== this && p.static.team === myTeam) {
for (const targetId of p.state.targets) {
result.push(this.game.playerBySmallID(targetId) as PlayerView);
}
}
}
}
return result;
}
isTraitor(): boolean {
return this.state.isTraitor;
}
getTraitorRemainingTicks(): number {
return this.state.traitorRemainingTicks;
}
betrayals(): number {
return this.state.betrayals;
}
outgoingEmojis(): EmojiMessage[] {
return this.state.outgoingEmojis;
}
hasSpawned(): boolean {
return this.state.hasSpawned;
}
isDisconnected(): boolean {
return this.state.isDisconnected;
}
lastDeleteUnitTick(): Tick {
return this.state.lastDeleteUnitTick;
}
deleteUnitCooldown(): number {
return (
Math.max(
0,
this.game.config().deleteUnitCooldown() -
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
) / 10
);
}
}