add webgl renderer

This commit is contained in:
evanpelle
2026-05-16 08:54:20 -07:00
parent b8137927a6
commit 9c4ba757c2
169 changed files with 50402 additions and 0 deletions
@@ -0,0 +1,44 @@
import type { PlayerState } from "../../types";
/**
* Compute alliance clusters via union-find.
* Returns a map of `playerSmallID → clusterRootID`.
* Used by SAM radius pass to color allies as a group.
*/
export function computeAllianceClusters(
players: ReadonlyMap<number, PlayerState>,
): Map<number, number> {
const parent = new Map<number, number>();
function find(x: number): number {
while (parent.get(x) !== x) {
const p = parent.get(x)!;
parent.set(x, parent.get(p)!);
x = p;
}
return x;
}
function union(a: number, b: number): void {
const ra = find(a);
const rb = find(b);
if (ra !== rb) parent.set(rb, ra);
}
for (const ps of players.values()) {
if (ps.smallID > 0) parent.set(ps.smallID, ps.smallID);
}
for (const ps of players.values()) {
if (!ps.allies || ps.smallID <= 0) continue;
for (const allyID of ps.allies) {
if (parent.has(allyID)) union(ps.smallID, allyID);
}
}
const result = new Map<number, number>();
for (const id of parent.keys()) {
result.set(id, find(id));
}
return result;
}
@@ -0,0 +1,43 @@
import type { AttackRingInput, UnitState } from "../../types";
import { UT_TRANSPORT } from "../../types";
/**
* Extract attack ring indicators for transport ships with active targets.
* Optionally filter to a specific owner (live path filters to local player).
*/
export function extractAttackRings(
units: ReadonlyMap<number, UnitState>,
mapW: number,
ownerFilter?: number,
): AttackRingInput[] {
const rings: AttackRingInput[] = [];
for (const u of units.values()) {
if (u.unitType !== UT_TRANSPORT) continue;
if (u.targetTile === null || !u.isActive || u.retreating) continue;
if (ownerFilter !== undefined && u.ownerID !== ownerFilter) continue;
const t = u.targetTile;
rings.push({ x: t % mapW, y: (t - (t % mapW)) / mapW, unitId: u.id });
}
return rings;
}
/**
* Targeted variant — iterates only pre-classified transport IDs instead of all units.
* Used by the live path where UnitClassifier maintains the transport ID set.
*/
export function extractAttackRingsFromIds(
transportIds: readonly number[],
units: ReadonlyMap<number, UnitState>,
mapW: number,
ownerFilter?: number,
): AttackRingInput[] {
const rings: AttackRingInput[] = [];
for (const id of transportIds) {
const u = units.get(id);
if (!u || u.targetTile === null || !u.isActive || u.retreating) continue;
if (ownerFilter !== undefined && u.ownerID !== ownerFilter) continue;
const t = u.targetTile;
rings.push({ x: t % mapW, y: (t - (t % mapW)) / mapW, unitId: u.id });
}
return rings;
}
@@ -0,0 +1,57 @@
import type { NukeTelegraphData, UnitState } from "../../types";
import { NUKE_MAGNITUDES } from "../../types";
/**
* Extract nuke telegraph circles for active nukes with targets.
*
* When `friendlyIDs` is provided, only nukes owned by those players are
* included (live game — you see your own + teammates' telegraphs).
* When omitted, all nukes are included (replay / spectator).
*/
export function extractNukeTelegraphs(
units: ReadonlyMap<number, UnitState>,
mapW: number,
friendlyIDs?: ReadonlySet<number>,
): NukeTelegraphData[] {
const telegraphs: NukeTelegraphData[] = [];
for (const u of units.values()) {
if (u.targetTile === null || !u.isActive) continue;
if (friendlyIDs && !friendlyIDs.has(u.ownerID)) continue;
const mag = NUKE_MAGNITUDES[u.unitType];
if (!mag) continue;
telegraphs.push({
x: u.targetTile % mapW,
y: (u.targetTile - (u.targetTile % mapW)) / mapW,
innerRadius: mag.inner,
outerRadius: mag.outer,
});
}
return telegraphs;
}
/**
* Targeted variant — iterates only pre-classified nuke IDs instead of all units.
* Used by the live path where UnitClassifier maintains the nuke ID set.
*/
export function extractNukeTelegraphsFromIds(
nukeIds: readonly number[],
units: ReadonlyMap<number, UnitState>,
mapW: number,
friendlyIDs?: ReadonlySet<number>,
): NukeTelegraphData[] {
const telegraphs: NukeTelegraphData[] = [];
for (const id of nukeIds) {
const u = units.get(id);
if (!u || u.targetTile === null || !u.isActive) continue;
if (friendlyIDs && !friendlyIDs.has(u.ownerID)) continue;
const mag = NUKE_MAGNITUDES[u.unitType];
if (!mag) continue;
telegraphs.push({
x: u.targetTile % mapW,
y: (u.targetTile - (u.targetTile % mapW)) / mapW,
innerRadius: mag.inner,
outerRadius: mag.outer,
});
}
return telegraphs;
}
@@ -0,0 +1,74 @@
import type { PlayerState, PlayerStatusData, UnitState } from "../../types";
import { NUKE_TYPES, UT_MIRV_WARHEAD } from "../../types";
/** Unit types that indicate an active nuke is in flight. */
const NUKE_ACTIVE_TYPES: ReadonlySet<string> = new Set([
...NUKE_TYPES,
UT_MIRV_WARHEAD,
]);
/**
* Compute per-player status flags for the name/status-icon pass.
*
* This is the replay-path version — no local player concept.
* All relative flags (alliance, allianceReq, target, embargo, nukeTargetsMe)
* are always false. The live path uses the shim's own computePlayerStatus
* which has local-player awareness.
*/
export function computePlayerStatus(
players: ReadonlyMap<number, PlayerState>,
units: ReadonlyMap<number, UnitState>,
): Map<number, PlayerStatusData> {
const result = new Map<number, PlayerStatusData>();
// Nuke owners: players who have an active nuke in flight
const nukeOwners = new Set<number>();
for (const u of units.values()) {
if (u.isActive && NUKE_ACTIVE_TYPES.has(u.unitType)) {
nukeOwners.add(u.ownerID);
}
}
// Crown: alive player with most tiles
let crownSmallID = -1;
let maxTiles = 0;
for (const ps of players.values()) {
if (!ps.isAlive) continue;
if (ps.tilesOwned > maxTiles) {
maxTiles = ps.tilesOwned;
crownSmallID = ps.smallID;
}
}
for (const ps of players.values()) {
if (!ps.isAlive) continue;
const crown = ps.smallID === crownSmallID;
const traitor = ps.isTraitor;
const disconnected = ps.isDisconnected;
const traitorRemainingTicks = ps.traitorRemainingTicks;
const nukeActive = nukeOwners.has(ps.smallID);
if (
crown ||
traitor ||
disconnected ||
traitorRemainingTicks > 0 ||
nukeActive
) {
result.set(ps.smallID, {
crown,
traitor,
disconnected,
alliance: false,
allianceReq: false,
target: false,
embargo: false,
nukeActive,
nukeTargetsMe: false,
traitorRemainingTicks,
allianceFraction: 0,
});
}
}
return result;
}
@@ -0,0 +1,92 @@
import type { PlayerState, PlayerStatic } from "../../types";
const RELATION_SIZE = 1024;
const RELATION_NEUTRAL = 0;
const RELATION_FRIENDLY = 1;
const RELATION_EMBARGO = 2;
/** Reusable matrix buffer — one allocation, rewritten each frame. */
const matrix = new Uint8Array(RELATION_SIZE * RELATION_SIZE);
export interface RelationMatrixResult {
matrix: Uint8Array;
size: number;
}
/**
* Build a relationship matrix from player alliance, embargo, and team data.
* Indexed by `[ownerA * size + ownerB]` → 0=neutral, 1=friendly, 2=embargo.
* Embargo overrides friendly (matching game priority).
*
* @param teams Optional smallID→team map. Same-team players are marked friendly.
*/
export function buildRelationMatrix(
players: ReadonlyMap<number, PlayerState>,
teams?: ReadonlyMap<number, string>,
): RelationMatrixResult {
matrix.fill(RELATION_NEUTRAL);
// Teammates — mark same-team pairs as friendly (before embargoes, which override)
if (teams && teams.size > 0) {
const byTeam = new Map<string, number[]>();
for (const [sid, team] of teams) {
if (sid <= 0 || sid >= RELATION_SIZE) continue;
let bucket = byTeam.get(team);
if (!bucket) {
bucket = [];
byTeam.set(team, bucket);
}
bucket.push(sid);
}
for (const members of byTeam.values()) {
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const a = members[i]!,
b = members[j]!;
matrix[a * RELATION_SIZE + b] = RELATION_FRIENDLY;
matrix[b * RELATION_SIZE + a] = RELATION_FRIENDLY;
}
}
}
}
// Alliances
for (const ps of players.values()) {
const sid = ps.smallID;
if (sid <= 0 || sid >= RELATION_SIZE) continue;
if (ps.allies) {
for (const allyID of ps.allies) {
if (allyID > 0 && allyID < RELATION_SIZE) {
const ab = sid * RELATION_SIZE + allyID;
const ba = allyID * RELATION_SIZE + sid;
if (matrix[ab]! < RELATION_FRIENDLY) matrix[ab] = RELATION_FRIENDLY;
if (matrix[ba]! < RELATION_FRIENDLY) matrix[ba] = RELATION_FRIENDLY;
}
}
}
if (ps.embargoes) {
for (const eStr of ps.embargoes) {
const eID = parseInt(eStr, 10);
if (eID > 0 && eID < RELATION_SIZE) {
matrix[sid * RELATION_SIZE + eID] = RELATION_EMBARGO;
matrix[eID * RELATION_SIZE + sid] = RELATION_EMBARGO;
}
}
}
}
return { matrix, size: RELATION_SIZE };
}
/** Build a smallID→team map from a player list. Skips players with no team. */
export function buildTeamMap(
players: readonly PlayerStatic[],
): ReadonlyMap<number, string> {
const m = new Map<number, string>();
for (const p of players) {
if (p.team !== null) m.set(p.smallID, p.team);
}
return m;
}
+20
View File
@@ -0,0 +1,20 @@
// Re-export the boundary contract type
export type { FrameData } from "../types";
// Shared derive functions
export { computeAllianceClusters } from "./derive/alliance-clusters";
export {
extractAttackRings,
extractAttackRingsFromIds,
} from "./derive/attack-rings";
export {
extractNukeTelegraphs,
extractNukeTelegraphsFromIds,
} from "./derive/nuke-telegraphs";
export { computePlayerStatus } from "./derive/player-status";
export { buildRelationMatrix, buildTeamMap } from "./derive/relation-matrix";
// Upload
export type { RelationMatrixResult } from "./derive/relation-matrix";
export { uploadFrameData } from "./upload";
export type { FrameUploadTarget, UploadOptions } from "./upload";
+273
View File
@@ -0,0 +1,273 @@
/**
* RailroadCache — always-on accumulator for railroad events.
*
* The game doesn't expose current railroad state via any API — it only sends
* construction/destruction/snap delta events. This cache accumulates them
* every tick so consumers that start later can reconstruct the full set.
*
* Includes orientation computation, construction animation, and a per-tile
* Uint8Array ready for GPU upload.
*
* Ported verbatim from openfront-workspace/packages/shim/src/railroad-cache.ts;
* only imports changed (types come from src/core/game/GameUpdates instead of
* the shim's local types module).
*/
import {
GameUpdateType,
GameUpdateViewData,
RailroadConstructionUpdate,
RailroadDestructionUpdate,
RailroadSnapUpdate,
} from "../../../core/game/GameUpdates";
// Regular enum (not const enum) for cross-package use.
export enum RailType {
VERTICAL,
HORIZONTAL,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT,
}
interface RailTile {
ref: number;
type: RailType;
}
interface RailroadAnim {
tiles: RailTile[];
headIndex: number;
tailIndex: number;
complete: boolean;
}
const RAIL_INCREMENT = 3;
// ---------------------------------------------------------------------------
// Orientation helpers
// ---------------------------------------------------------------------------
function railExtremity(tile: number, next: number, w: number): RailType {
const dx = (next % w) - (tile % w);
const dy = (next - (next % w)) / w - (tile - (tile % w)) / w;
if (dx === 0) return RailType.VERTICAL;
if (dy === 0) return RailType.HORIZONTAL;
return RailType.VERTICAL;
}
function railDirection(
prev: number,
cur: number,
next: number,
w: number,
): RailType {
const x1 = prev % w,
y1 = (prev - x1) / w;
const x2 = cur % w,
y2 = (cur - x2) / w;
const x3 = next % w,
y3 = (next - x3) / w;
const dx1 = x2 - x1,
dy1 = y2 - y1;
const dx2 = x3 - x2,
dy2 = y3 - y2;
if (dx1 === dx2 && dy1 === dy2) {
return dx1 !== 0 ? RailType.HORIZONTAL : RailType.VERTICAL;
}
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
return RailType.VERTICAL;
}
function computeRailTiles(tileRefs: number[], w: number): RailTile[] {
if (tileRefs.length === 0) return [];
if (tileRefs.length === 1)
return [{ ref: tileRefs[0]!, type: RailType.VERTICAL }];
const result: RailTile[] = [];
result.push({
ref: tileRefs[0]!,
type: railExtremity(tileRefs[0]!, tileRefs[1]!, w),
});
for (let i = 1; i < tileRefs.length - 1; i++) {
result.push({
ref: tileRefs[i]!,
type: railDirection(tileRefs[i - 1]!, tileRefs[i]!, tileRefs[i + 1]!, w),
});
}
const last = tileRefs.length - 1;
result.push({
ref: tileRefs[last]!,
type: railExtremity(tileRefs[last]!, tileRefs[last - 1]!, w),
});
return result;
}
export class RailroadCache {
private mapW: number;
private anims = new Map<number, RailroadAnim>();
/**
* Per-tile reference count. Multiple railroads can share tiles at junctions
* (near stations). A tile is only cleared from railroadState when its ref
* count drops to zero.
*/
private tileRefCount = new Map<number, number>();
/** Per-tile railroad state (0=none, 1-6 = RailType+1). Ready for GPU upload. */
readonly railroadState: Uint8Array;
/** True if railroadState changed this tick. */
railroadDirty = false;
/** Tile refs revealed by animation this tick (for dust FX). */
readonly revealedRailTiles: number[] = [];
constructor(mapW: number, mapH: number) {
this.mapW = mapW;
this.railroadState = new Uint8Array(mapW * mapH);
}
/**
* Process this tick's railroad events and advance animations.
* Event order matches the upstream game client: Construction → Snap → Destruction.
*/
apply(gu: GameUpdateViewData): void {
const constructs = (gu.updates[GameUpdateType.RailroadConstructionEvent] ??
[]) as RailroadConstructionUpdate[];
for (const evt of constructs) this.addRailroad(evt.id, evt.tiles, false);
const snaps = (gu.updates[GameUpdateType.RailroadSnapEvent] ??
[]) as RailroadSnapUpdate[];
for (const evt of snaps) {
this.removeRailroad(evt.originalId);
this.addRailroad(evt.newId1, evt.tiles1, true);
this.addRailroad(evt.newId2, evt.tiles2, true);
}
const destructs = (gu.updates[GameUpdateType.RailroadDestructionEvent] ??
[]) as RailroadDestructionUpdate[];
for (const evt of destructs) this.removeRailroad(evt.id);
this.tickAnimations();
}
/** Clear the dirty flag after the consumer has uploaded the state. */
clearDirty(): void {
this.railroadDirty = false;
}
/** Get raw tile refs for the given railroad IDs (for ghost manager overlap resolution). */
getRailroadTileRefs(ids: number[]): number[] {
const tiles: number[] = [];
for (const id of ids) {
const anim = this.anims.get(id);
if (anim) for (const t of anim.tiles) tiles.push(t.ref);
}
return tiles;
}
/** Read-only view of current railroads: id → raw tile refs. */
getRailroads(): ReadonlyMap<number, number[]> {
const result = new Map<number, number[]>();
for (const [id, anim] of this.anims) {
result.set(
id,
anim.tiles.map((t) => t.ref),
);
}
return result;
}
reset(): void {
this.anims.clear();
this.tileRefCount.clear();
this.railroadState.fill(0);
this.railroadDirty = false;
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
private addRailroad(id: number, tileRefs: number[], complete: boolean): void {
const tiles = computeRailTiles(tileRefs, this.mapW);
const anim: RailroadAnim = {
tiles,
headIndex: complete ? tiles.length : 0,
tailIndex: complete ? 0 : tiles.length,
complete,
};
this.anims.set(id, anim);
// Increment ref counts for all tiles in this railroad
for (const rt of tiles) {
this.tileRefCount.set(rt.ref, (this.tileRefCount.get(rt.ref) ?? 0) + 1);
}
if (complete) {
for (const rt of tiles) this.railroadState[rt.ref] = rt.type + 1;
this.railroadDirty = true;
}
}
private removeRailroad(id: number): void {
const anim = this.anims.get(id);
if (!anim) return;
// Decrement ref counts; only clear tiles whose count drops to zero
for (const rt of anim.tiles) {
const count = (this.tileRefCount.get(rt.ref) ?? 1) - 1;
if (count <= 0) {
this.tileRefCount.delete(rt.ref);
this.railroadState[rt.ref] = 0;
} else {
this.tileRefCount.set(rt.ref, count);
}
}
this.anims.delete(id);
this.railroadDirty = true;
}
private tickAnimations(): void {
this.revealedRailTiles.length = 0;
for (const anim of this.anims.values()) {
if (anim.complete) continue;
if (anim.tailIndex - anim.headIndex <= 2 * RAIL_INCREMENT) {
for (let i = anim.headIndex; i < anim.tailIndex; i++) {
const t = anim.tiles[i]!;
this.railroadState[t.ref] = t.type + 1;
this.revealedRailTiles.push(t.ref);
}
anim.headIndex = anim.tailIndex;
anim.complete = true;
this.railroadDirty = true;
} else {
for (let i = anim.headIndex; i < anim.headIndex + RAIL_INCREMENT; i++) {
const t = anim.tiles[i]!;
this.railroadState[t.ref] = t.type + 1;
this.revealedRailTiles.push(t.ref);
}
for (let i = anim.tailIndex - RAIL_INCREMENT; i < anim.tailIndex; i++) {
const t = anim.tiles[i]!;
this.railroadState[t.ref] = t.type + 1;
this.revealedRailTiles.push(t.ref);
}
anim.headIndex += RAIL_INCREMENT;
anim.tailIndex -= RAIL_INCREMENT;
if (anim.headIndex >= anim.tailIndex) anim.complete = true;
this.railroadDirty = true;
}
}
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* TrailManager — per-tile "last owner" stamp for trail rendering.
*
* Each tick, for each tracked unit, stamps tiles between lastPos and pos
* (bresenham) with the owner's smallID. When a unit dies its tiles are cleared,
* with overlapping tiles repainted from any surviving unit.
*
* Simpler than the original openfront-workspace TrailManager (no MotionPlanStore
* dependency). Since we run in the main thread reading GameView directly, we
* don't need plan-based reconstruction.
*/
import type { UnitState } from "../types";
interface UnitTrail {
ownerID: number;
tiles: Set<number>;
lastPosStamped: number; // tile ref of the last position we stamped
}
export class TrailManager {
private readonly trailState: Uint8Array;
private readonly unitTrails = new Map<number, UnitTrail>();
private readonly mapW: number;
private _dirtyRowMin = Infinity;
private _dirtyRowMax = -1;
constructor(mapW: number, mapH: number) {
this.mapW = mapW;
this.trailState = new Uint8Array(mapW * mapH);
}
getTrailState(): Uint8Array {
return this.trailState;
}
get dirtyRowMin(): number {
return this._dirtyRowMin;
}
get dirtyRowMax(): number {
return this._dirtyRowMax;
}
clearDirtyRows(): void {
this._dirtyRowMin = Infinity;
this._dirtyRowMax = -1;
}
reset(): void {
this.unitTrails.clear();
this.trailState.fill(0);
this._dirtyRowMin = Infinity;
this._dirtyRowMax = -1;
}
/**
* Update trails from the current unit set. Stamps tiles between lastPos and
* pos (bresenham) for each tracked unit, and clears tiles for units that
* have disappeared (overlapping tiles get repainted from survivors).
*/
update(units: Map<number, UnitState>, trackedIds: number[]): void {
this.clearDeadUnits(units);
for (const id of trackedIds) {
const unit = units.get(id);
if (!unit) continue;
let trail = this.unitTrails.get(id);
if (!trail) {
trail = { ownerID: unit.ownerID, tiles: new Set(), lastPosStamped: -1 };
this.unitTrails.set(id, trail);
}
if (trail.lastPosStamped === -1) {
// First sighting — just stamp current pos
this.stamp(unit.pos, trail.ownerID);
trail.tiles.add(unit.pos);
trail.lastPosStamped = unit.pos;
} else if (trail.lastPosStamped !== unit.pos) {
this.bresenham(trail.lastPosStamped, unit.pos, trail);
trail.lastPosStamped = unit.pos;
}
}
}
private clearDeadUnits(units: Map<number, UnitState>): void {
for (const [id, trail] of this.unitTrails) {
if (units.has(id)) continue;
const deadTiles = trail.tiles;
for (const ref of deadTiles) this.stamp(ref, 0);
this.unitTrails.delete(id);
// Repaint any tiles that overlap surviving trails
for (const other of this.unitTrails.values()) {
for (const ref of deadTiles) {
if (other.tiles.has(ref)) this.stamp(ref, other.ownerID);
}
}
}
}
private stamp(ref: number, ownerID: number): void {
this.trailState[ref] = ownerID;
const row = (ref / this.mapW) | 0;
if (row < this._dirtyRowMin) this._dirtyRowMin = row;
if (row > this._dirtyRowMax) this._dirtyRowMax = row;
}
private bresenham(from: number, to: number, trail: UnitTrail): void {
const w = this.mapW;
let x0 = from % w;
let y0 = (from - x0) / w;
const x1 = to % w;
const y1 = (to - x1) / w;
const dx = Math.abs(x1 - x0);
const dy = -Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
for (;;) {
const ref = y0 * w + x0;
trail.tiles.add(ref);
this.stamp(ref, trail.ownerID);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) {
err += dy;
x0 += sx;
}
if (e2 <= dx) {
err += dx;
y0 += sy;
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import type {
AttackRingInput,
BonusEvent,
ConquestFx,
DeadUnitFx,
FrameData,
NameEntry,
NukeTelegraphData,
PlayerState,
PlayerStatusData,
TilePair,
UnitState,
} from "../types";
/**
* Structural interface for the GPU view target.
* Satisfied by GameView through TypeScript structural typing.
*/
export interface FrameUploadTarget {
uploadTileAndTrailState(tileState: Uint16Array, trailState: Uint8Array): void;
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void;
uploadLiveTrailDelta(
trailState: Uint8Array,
dirtyRowMin: number,
dirtyRowMax: number,
): void;
applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void;
applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void;
uploadRailroadState(data: Uint8Array): void;
applyRailroadDust(tileRefs: number[]): void;
updateUnits(units: ReadonlyMap<number, UnitState>, gameTick: number): void;
updateStructures(units: ReadonlyMap<number, UnitState>): void;
applyDeadUnits(deadUnits: DeadUnitFx[]): void;
applyConquestEvents(events: ConquestFx[]): void;
applyBonusEvents(events: BonusEvent[]): void;
updateAttackRings(rings: AttackRingInput[]): void;
updateNukeTelegraphs(data: NukeTelegraphData[]): void;
updateNames(
names: ReadonlyMap<string, NameEntry>,
players: ReadonlyMap<number, PlayerState>,
snap: boolean,
statusData?: ReadonlyMap<number, PlayerStatusData>,
): void;
updateRelations(data: Uint8Array, size: number): void;
setSAMAllianceClusters(clusters: ReadonlyMap<number, number>): void;
}
export interface UploadOptions {
/** Snap name positions instantly (seek mode). Default: false. */
snap?: boolean;
/** Skip tile upload — caller already handled tiles (e.g. seek with bloom reset). */
skipTileUpload?: boolean;
}
/**
* Upload a FrameData snapshot to the GPU view.
*
* Handles tile upload mode switching, all view update calls, and conditional
* railroad/ephemeral uploads. The FrameData itself carries semantic differences
* (seek sets deadUnits=[], conquestEvents=[] etc.) — this function is a
* straightforward dispatch loop.
*/
export function uploadFrameData(
view: FrameUploadTarget,
frame: FrameData,
opts?: UploadOptions,
): void {
const snap = opts?.snap ?? false;
const skipTileUpload = opts?.skipTileUpload ?? false;
// --- Tiles + Trails ---
// Live mode: changedTiles[] means "only these tiles changed" (empty = nothing changed, skip upload).
// changedTiles null/undefined means "no delta info" (first tick — full upload needed).
// Copy mode: changedTiles[] = delta playback, null = full seek.
if (!skipTileUpload) {
if (frame.tileMode === "live" && frame.changedTiles) {
// Live delta path — tiles and trails uploaded independently
if (frame.changedTiles.length > 0) {
view.uploadLiveDelta(frame.tileState, frame.changedTiles);
}
// Trail dirty rows come from TrailManager, independent of tile deltas
if (frame.trailDirtyRowMax >= 0) {
view.uploadLiveTrailDelta(
frame.trailState,
frame.trailDirtyRowMin,
frame.trailDirtyRowMax,
);
}
} else if (frame.tileMode === "live") {
view.uploadTileAndTrailState(frame.tileState, frame.trailState);
} else if (!frame.changedTiles) {
view.applyFullTiles(frame.tileState, frame.trailState);
} else {
view.applyDelta(frame.changedTiles, frame.trailState);
}
}
// --- Railroads ---
if (frame.railroadDirty) {
view.uploadRailroadState(frame.railroadState);
if (frame.revealedRailTiles.length > 0) {
view.applyRailroadDust(frame.revealedRailTiles);
}
}
// --- Units + structures ---
view.updateUnits(frame.units, frame.tick);
if (frame.structuresDirty) {
view.updateStructures(frame.units);
}
// --- Ephemeral effects ---
if (frame.events.deadUnits.length > 0) {
view.applyDeadUnits(frame.events.deadUnits);
}
if (frame.events.conquestEvents.length > 0) {
view.applyConquestEvents(frame.events.conquestEvents);
}
if (frame.events.bonusEvents.length > 0) {
view.applyBonusEvents(frame.events.bonusEvents);
}
// --- Attack rings + nuke telegraphs ---
view.updateAttackRings(frame.attackRings);
view.updateNukeTelegraphs(frame.nukeTelegraphs);
// --- Names + player status ---
view.updateNames(frame.names, frame.players, snap, frame.playerStatus);
// --- Relations ---
view.updateRelations(frame.relationMatrix, frame.relationSize);
// --- Alliance clusters (SAM pass) ---
view.setSAMAllianceClusters(frame.allianceClusters);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* game-constants.ts — Upstream game facts replicated in the renderer/shim.
*
* All values here are sourced from upstream game code. When upstream changes,
* audit this file first.
*
* Primary sources:
* - vendor/openfront/src/core/configuration/DefaultConfig.ts (DefaultConfig, DefaultServerConfig)
* - vendor/openfront/src/client/graphics/layers/FxLayer.ts (visual-only constants)
*/
import {
UT_ATOM_BOMB,
UT_CITY,
UT_DEFENSE_POST,
UT_FACTORY,
UT_HYDROGEN_BOMB,
UT_MIRV_WARHEAD,
UT_MISSILE_SILO,
UT_PORT,
UT_SAM_LAUNCHER,
} from "./types";
// ---------------------------------------------------------------------------
// Tick timing
// ---------------------------------------------------------------------------
/**
* Milliseconds per game tick.
* Source: DefaultServerConfig.turnIntervalMs() → return 100
*/
export const MS_PER_TICK = 100;
// ---------------------------------------------------------------------------
// Unit health
// ---------------------------------------------------------------------------
/**
* Maximum health for a Warship unit.
* Source: DefaultConfig.unitInfo(UnitType.Warship) → { maxHealth: 1000 }
*/
export const WARSHIP_MAX_HEALTH = 1000;
// ---------------------------------------------------------------------------
// Construction durations (ticks)
// ---------------------------------------------------------------------------
/**
* How many ticks each structure type takes to finish construction.
* Source: DefaultConfig.unitInfo(type).constructionDuration (non-instantBuild path):
* case UnitType.City: constructionDuration: 2 * 10
* case UnitType.Port: constructionDuration: 2 * 10
* case UnitType.Factory: constructionDuration: 2 * 10
* case UnitType.DefensePost: constructionDuration: 5 * 10
* case UnitType.MissileSilo: constructionDuration: 10 * 10
* case UnitType.SAMLauncher: constructionDuration: 30 * 10
*/
export const CONSTRUCTION_DURATIONS: Readonly<Record<string, number>> = {
[UT_CITY]: 2 * 10,
[UT_PORT]: 2 * 10,
[UT_FACTORY]: 2 * 10,
[UT_DEFENSE_POST]: 5 * 10,
[UT_MISSILE_SILO]: 10 * 10,
[UT_SAM_LAUNCHER]: 30 * 10,
};
// ---------------------------------------------------------------------------
// Missile cooldowns (ticks)
// ---------------------------------------------------------------------------
/**
* Ticks for a SAM Launcher to reload one missile.
* Source: DefaultConfig.SAMCooldown() → return 120
* NOTE: different from SiloCooldown — do not conflate.
*/
export const SAM_COOLDOWN_TICKS = 120;
/**
* Ticks for a Missile Silo to reload one missile.
* Source: DefaultConfig.SiloCooldown() → return 75
*/
export const SILO_COOLDOWN_TICKS = 75;
// ---------------------------------------------------------------------------
// Deletion mark duration (ticks)
// ---------------------------------------------------------------------------
/**
* How many ticks a structure remains in the "marked for deletion" state.
* Source: DefaultConfig.deletionMarkDuration() → return 30 * 10
*/
export const DELETION_MARK_DURATION = 30 * 10;
// ---------------------------------------------------------------------------
// Nuke explosion visual radii (tiles)
// ---------------------------------------------------------------------------
/**
* Visual explosion radius (tiles) for each nuke type, used for shockwave and
* debris scatter sizing.
*
* Source: FxLayer.ts, inside the unit-death event handler:
* case UnitType.AtomBomb: this.onNukeEvent(unit, 70)
* case UnitType.MIRVWarhead: this.onNukeEvent(unit, 70)
* case UnitType.HydrogenBomb: this.onNukeEvent(unit, 160)
*
* Note: these are visual-only radii. The gameplay damage radii are separate
* and come from DefaultConfig.nukeMagnitudes() → { inner, outer }.
*/
export const NUKE_EXPLOSION_RADII: Readonly<Record<string, number>> = {
[UT_ATOM_BOMB]: 70,
[UT_HYDROGEN_BOMB]: 160,
[UT_MIRV_WARHEAD]: 70,
};
// ---------------------------------------------------------------------------
// SAM range formula
// ---------------------------------------------------------------------------
/**
* SAM Launcher coverage radius in tiles at a given upgrade level.
* Source: DefaultConfig.samRange(level):
* return this.maxSamRange() - 480 / (level + 5)
* where maxSamRange() → return 150
*/
export function samRange(level: number): number {
return 150 - 480 / (level + 5);
}
// ---------------------------------------------------------------------------
// Missile readiness formula
// ---------------------------------------------------------------------------
/**
* Fractional missile readiness [0, 1] for a Silo or SAM Launcher.
* Returns 1.0 when fully loaded, 0.0 when completely empty with no partial reload.
*
* Source: adapted from upstream readiness display logic (UILayer / FxLayer).
* Uses per-type cooldown: SAMCooldown() = 120, SiloCooldown() = 75.
*/
export function missileReadiness(
unitType: string,
level: number,
missileTimerQueue: number[],
gameTick: number,
): number {
const cooldown =
unitType === UT_SAM_LAUNCHER ? SAM_COOLDOWN_TICKS : SILO_COOLDOWN_TICKS;
const maxMissiles = level;
const reloading = missileTimerQueue.length;
if (reloading === 0) return 1;
const ready = maxMissiles - reloading;
if (ready === 0 && maxMissiles > 1) return 0;
let readiness = ready / maxMissiles;
for (const timer of missileTimerQueue) {
const progress = gameTick - timer;
const ratio = progress / cooldown;
readiness += ratio / maxMissiles;
}
return Math.max(0, Math.min(1, readiness));
}
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
fill="#fff"
viewBox="0 0 32 32"
version="1.1"
id="svg3"
sodipodi:docname="MissileSiloIconWhite.svg"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="36.78125"
inkscape:cx="15.986406"
inkscape:cy="16"
inkscape:window-width="2560"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg3"
showguides="true">
<sodipodi:guide
position="6.144435,8.9447749"
orientation="0,-1"
id="guide3"
inkscape:locked="false" />
</sodipodi:namedview>
<!-- Nose cone -->
<path
d="m 16,1.592183 c 0,0 -3,6 -3,8.999997 h 6 C 19,7.592183 16,1.592183 16,1.592183 Z"
id="path1" />
<!-- Body tube -->
<rect
x="13"
y="11"
width="6"
height="14.322554"
rx="0.5"
id="rect1"
style="stroke-width:1.04964" />
<!-- Left fin — swept-back old-school style -->
<path
d="M 13,19 9.0390824,23.079014 9,28 13,24 Z"
id="path2"
sodipodi:nodetypes="ccccc" />
<!-- Right fin -->
<path
d="M 19,19 23.015293,23.106202 23,28 19,24 Z"
id="path3"
sodipodi:nodetypes="ccccc" />
<!-- Nozzle -->
<rect
x="14"
y="25.322554"
width="4"
height="2.7543371"
rx="0.5"
id="rect3"
style="stroke-width:1.04964" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,68 @@
{
"width": 1280,
"height": 768,
"cellSize": 128,
"cols": 10,
"emojis": {
"😀": 0,
"😊": 1,
"🥰": 2,
"😇": 3,
"😎": 4,
"😞": 5,
"🥺": 6,
"😭": 7,
"😱": 8,
"😡": 9,
"😈": 10,
"🤡": 11,
"🥱": 12,
"🫡": 13,
"🖕": 14,
"👋": 15,
"👏": 16,
"✋": 17,
"🙏": 18,
"💪": 19,
"👍": 20,
"👎": 21,
"🫴": 22,
"🤌": 23,
"🤦‍♂️": 24,
"🤝": 25,
"🆘": 26,
"🕊️": 27,
"🏳️": 28,
"⌛": 29,
"🔥": 30,
"💥": 31,
"💀": 32,
"☢️": 33,
"⚠️": 34,
"↖️": 35,
"⬆️": 36,
"↗️": 37,
"👑": 38,
"🥇": 39,
"⬅️": 40,
"🎯": 41,
"➡️": 42,
"🥈": 43,
"🥉": 44,
"↙️": 45,
"⬇️": 46,
"↘️": 47,
"❤️": 48,
"💔": 49,
"💰": 50,
"⚓": 51,
"⛵": 52,
"🏡": 53,
"🛡️": 54,
"🏭": 55,
"🚂": 56,
"❓": 57,
"🐔": 58,
"🐀": 59
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

@@ -0,0 +1,568 @@
{
"width": 2048,
"height": 2975,
"cellW": 128,
"cellH": 85,
"cols": 16,
"flags": {
"1_Airgialla": 0,
"1_Connacht": 1,
"1_Dalriata": 2,
"1_Dumnonia": 3,
"1_Dyfed": 4,
"1_East Anglia": 5,
"1_Essex": 6,
"1_Fortriu": 7,
"1_Franks": 8,
"1_Gwent": 9,
"1_Gwynedd": 10,
"1_Kent": 11,
"1_Laigin": 12,
"1_Mercia": 13,
"1_Munster": 14,
"1_Northern Ui Neill": 15,
"1_Northumbria": 16,
"1_Occitania": 17,
"1_Powys": 18,
"1_Southern Ui Neill": 19,
"1_Strathclyde": 20,
"1_Sussex": 21,
"1_Ulaid": 22,
"1_Wessex": 23,
"Abbasid Caliphate": 24,
"Achaemenid Empire": 25,
"African union": 26,
"Alabama": 27,
"Alaska": 28,
"Alkebulan": 29,
"Amazigh flag": 30,
"American_Samoa": 31,
"Anarchist flag": 32,
"Apartheid South Africa": 33,
"Arabia": 34,
"Aram Damascus": 35,
"Arizona": 36,
"Arkansas": 37,
"Assyria": 38,
"Athens": 39,
"Australian Aboriginal Flag": 40,
"Aztec Empire": 41,
"Babylonia": 42,
"Burma": 43,
"Burma2": 44,
"Byelorussian SSR": 45,
"Byzantine Empire": 46,
"California": 47,
"Capybara": 48,
"Carthage": 49,
"Ceara": 50,
"Chinook": 51,
"Chuvashia": 52,
"Circassia": 53,
"Colchis": 54,
"Colorado": 55,
"Communist Romania": 56,
"Communist flag": 57,
"Confederate States": 58,
"Connecticut": 59,
"Corsica": 60,
"Cthulhu Republic": 61,
"Danzig": 62,
"Delaware": 63,
"Dilmun": 64,
"District_of_Columbia": 65,
"Dutch East India Company": 66,
"Elam": 67,
"Empire of Japan": 68,
"Empire of Japan1": 69,
"Essex": 70,
"Fascist Spain": 71,
"Flag_of_the_Trucial_States_(19681971)": 72,
"Flanders": 73,
"Florida": 74,
"Franks": 75,
"French foreign legion": 76,
"Garamant": 77,
"Georgia_US": 78,
"Georgian SSR": 79,
"German Empire": 80,
"Guam": 81,
"Habsburg Austria": 82,
"Hawaii": 83,
"Holy Roman Empire": 84,
"Hyrcania": 85,
"Idaho": 86,
"Illinois": 87,
"Imperial Ethiopia": 88,
"Indiana": 89,
"Iowa": 90,
"Kansas": 91,
"Kazakh SSR": 92,
"Kemet": 93,
"Kent": 94,
"Kentucky": 95,
"Khemet": 96,
"Kingdom of Egypt": 97,
"Kingdom of Iraq": 98,
"Kingdom of Jerusalem": 99,
"Kingdom of Judah": 100,
"Kingdom_of_Iraq": 101,
"Kingdom_of_Judah": 102,
"Kiwi": 103,
"Kush": 104,
"Laigin": 105,
"League of Nations": 106,
"Leinster": 107,
"Liberalism_flag": 108,
"Libyan Jamahiriya": 109,
"Lihyan": 110,
"Listenbourg": 111,
"Louisiana": 112,
"Lower Silesia": 113,
"Lydia": 114,
"Macedonia": 115,
"Maine": 116,
"Maori flag": 117,
"Maryland": 118,
"Massachusetts": 119,
"Mauritania": 120,
"Median Empire": 121,
"Michigan": 122,
"Minnesota": 123,
"Mississippi": 124,
"Missouri": 125,
"Mongol Empire": 126,
"Montana": 127,
"Munster": 128,
"NATO": 129,
"Nebraska": 130,
"Nevada": 131,
"New_Hampshire": 132,
"New_Jersey": 133,
"New_Mexico": 134,
"New_York": 135,
"Newfoundland": 136,
"North karelia": 137,
"North yemen": 138,
"North_Carolina": 139,
"North_Dakota": 140,
"Northern_Mariana_Islands": 141,
"Nunavut": 142,
"OFM": 143,
"Ohio": 144,
"Oklahoma": 145,
"Oregon": 146,
"Ottoman Empire": 147,
"Pahlavi Iran": 148,
"Palekh": 149,
"Para": 150,
"Pennsylvania": 151,
"Persia": 152,
"Phrygia": 153,
"Poland Lithuania": 154,
"PolishLithuanian Commonwealth": 155,
"Qing Dynasty": 156,
"Quebec": 157,
"Republic of China": 158,
"Republic of Egypt": 159,
"Republic of Formosa": 160,
"Republic of Korea": 161,
"Republic of Pirates": 162,
"Rhode_Island": 163,
"Rhodesia": 164,
"Romanov Russia": 165,
"Ror Empire": 166,
"Russian SSR": 167,
"SPQR": 168,
"Saba kingdom": 169,
"Sakhalin": 170,
"Sami flag": 171,
"Santa Cruz": 172,
"Sao Paulo": 173,
"Sassanid Empire": 174,
"Second Republic of Iraq": 175,
"Second Spanish Republic": 176,
"Siam": 177,
"Siberia": 178,
"Sicily": 179,
"Socialist_flag": 180,
"South Vietnam": 181,
"South_Carolina": 182,
"South_Dakota": 183,
"Sparta": 184,
"Sultanate of Nejd": 185,
"Sweden Norway Union": 186,
"Tennessee": 187,
"Texas": 188,
"Trucial States": 189,
"Turkmen SSR": 190,
"USA 1776": 191,
"Ukrainian SSR": 192,
"Ulaid": 193,
"Umayyad Caliphate": 194,
"United Arab Republic": 195,
"United_States_Virgin_Islands": 196,
"Upper Silesia": 197,
"Urartu": 198,
"Utah": 199,
"Vermont": 200,
"Virginia": 201,
"Wallonia": 202,
"Washington": 203,
"Wassex": 204,
"West Roman Empire": 205,
"West_Virginia": 206,
"Wisconsin": 207,
"Wyoming": 208,
"Yellow_Flag": 209,
"Yukon": 210,
"Zaire": 211,
"Zheleznogorsk": 212,
"ac": 213,
"ad": 214,
"ae": 215,
"af": 216,
"ag": 217,
"ai": 218,
"al": 219,
"am": 220,
"amazonas": 221,
"an_pe": 222,
"antipope": 223,
"ao": 224,
"aq": 225,
"aquitaine": 226,
"ar": 227,
"armagnac": 228,
"as": 229,
"asturias": 230,
"at": 231,
"au": 232,
"aus_norter": 233,
"aus_nsw": 234,
"aus_quelan": 235,
"aus_souaus": 236,
"aus_tas": 237,
"aus_vic": 238,
"aus_wesaus": 239,
"austria-hungary": 240,
"aw": 241,
"ax": 242,
"az": 243,
"ba": 244,
"baguette": 245,
"bahia": 246,
"bai_bur": 247,
"bai_irk": 248,
"bb": 249,
"bd": 250,
"be": 251,
"bf": 252,
"bg": 253,
"bh": 254,
"bi": 255,
"bj": 256,
"bl": 257,
"bm": 258,
"bn": 259,
"bo": 260,
"bq": 261,
"br": 262,
"brittany": 263,
"bs": 264,
"bt": 265,
"buenos_aires": 266,
"bulgaria": 267,
"burgundy": 268,
"bv": 269,
"bw": 270,
"by": 271,
"bz": 272,
"ca": 273,
"ca_nb": 274,
"ca_ns": 275,
"ca_pe": 276,
"castille": 277,
"catalonia": 278,
"catamarca": 279,
"cc": 280,
"cd": 281,
"cf": 282,
"cg": 283,
"ch": 284,
"ci": 285,
"ck": 286,
"cl": 287,
"cm": 288,
"cn": 289,
"co": 290,
"cordoba": 291,
"cp": 292,
"cr": 293,
"cu": 294,
"cv": 295,
"cw": 296,
"cx": 297,
"cy": 298,
"cz": 299,
"de": 300,
"denmark": 301,
"dg": 302,
"dj": 303,
"dk": 304,
"dm": 305,
"do": 306,
"dz": 307,
"east_germany": 308,
"ec": 309,
"ee": 310,
"eg": 311,
"eh": 312,
"eo": 313,
"er": 314,
"es-ct": 315,
"es-ga": 316,
"es-pv": 317,
"es": 318,
"estonia": 319,
"et": 320,
"eu": 321,
"fi": 322,
"finland": 323,
"fj": 324,
"fk": 325,
"fm": 326,
"fo": 327,
"fr": 328,
"frost_giant": 329,
"ga": 330,
"galapagos": 331,
"gb-eng": 332,
"gb-sct": 333,
"gb-wls": 334,
"gb": 335,
"gd": 336,
"ge": 337,
"gf": 338,
"gg": 339,
"gh": 340,
"gi": 341,
"gl": 342,
"gm": 343,
"gn": 344,
"gp": 345,
"gq": 346,
"gr": 347,
"granada": 348,
"greece": 349,
"gs": 350,
"gt": 351,
"gu": 352,
"gw": 353,
"gy": 354,
"ha_ma": 355,
"hk": 356,
"hm": 357,
"hn": 358,
"hr": 359,
"ht": 360,
"hu": 361,
"hungary": 362,
"ic": 363,
"iceland": 364,
"id": 365,
"ie": 366,
"il": 367,
"im": 368,
"in": 369,
"io": 370,
"iq": 371,
"ir": 372,
"iraq": 373,
"ireland": 374,
"is": 375,
"it": 376,
"italy": 377,
"je": 378,
"jm": 379,
"jo": 380,
"jp": 381,
"ke": 382,
"kg": 383,
"kh": 384,
"ki": 385,
"km": 386,
"kn": 387,
"kp": 388,
"kr": 389,
"kurdistan": 390,
"kw": 391,
"ky": 392,
"kz": 393,
"la": 394,
"latvia": 395,
"lb": 396,
"lc": 397,
"leon": 398,
"li": 399,
"lithuania": 400,
"lk": 401,
"lr": 402,
"ls": 403,
"lt": 404,
"lu": 405,
"lv": 406,
"ly": 407,
"ma": 408,
"mc": 409,
"md": 410,
"me": 411,
"mf": 412,
"mg": 413,
"mh": 414,
"minas_gerais": 415,
"mk": 416,
"ml": 417,
"mm": 418,
"mn": 419,
"mo": 420,
"mp": 421,
"mq": 422,
"mr": 423,
"ms": 424,
"mt": 425,
"mu": 426,
"mv": 427,
"mw": 428,
"mx": 429,
"my": 430,
"mz": 431,
"na": 432,
"nc": 433,
"ne": 434,
"netherlands": 435,
"neuragic_empire": 436,
"nf": 437,
"ng": 438,
"ni": 439,
"nl": 440,
"no": 441,
"normandy": 442,
"northern_ireland": 443,
"norway": 444,
"np": 445,
"nr": 446,
"nu": 447,
"nz": 448,
"om": 449,
"pa": 450,
"paris": 451,
"pe": 452,
"pf": 453,
"pg": 454,
"ph": 455,
"pk": 456,
"pl": 457,
"pm": 458,
"pn": 459,
"poland": 460,
"polar_bears": 461,
"portugal": 462,
"pr": 463,
"provence": 464,
"prussia": 465,
"ps": 466,
"pt": 467,
"pw": 468,
"py": 469,
"qa": 470,
"re": 471,
"rio_de_janeiro": 472,
"ro": 473,
"rs": 474,
"ru": 475,
"rw": 476,
"sa": 477,
"santa_claus": 478,
"santa_cruz": 479,
"sardines": 480,
"sb": 481,
"sc": 482,
"sd": 483,
"se": 484,
"seville": 485,
"sg": 486,
"sh-ac": 487,
"sh-hl": 488,
"sh-ta": 489,
"sh": 490,
"sh_yugo": 491,
"si": 492,
"sj": 493,
"sk": 494,
"sl": 495,
"sm": 496,
"sn": 497,
"so": 498,
"south yemen": 499,
"spain": 500,
"spanish_empire": 501,
"sr": 502,
"ss": 503,
"st": 504,
"sv": 505,
"sweden": 506,
"sx": 507,
"sy": 508,
"sz": 509,
"ta": 510,
"tc": 511,
"td": 512,
"tf": 513,
"tg": 514,
"th": 515,
"tibet": 516,
"tj": 517,
"tk": 518,
"tl": 519,
"tm": 520,
"tn": 521,
"to": 522,
"toki_pona": 523,
"tr": 524,
"tt": 525,
"tv": 526,
"tw": 527,
"tz": 528,
"ua": 529,
"ug": 530,
"uk": 531,
"uk_us_flag": 532,
"um": 533,
"un": 534,
"us": 535,
"ussr": 536,
"uy": 537,
"uz": 538,
"va": 539,
"valencia": 540,
"vc": 541,
"ve": 542,
"venice": 543,
"vg": 544,
"vi": 545,
"vn": 546,
"vu": 547,
"west_germany": 548,
"wf": 549,
"ws": 550,
"xk": 551,
"xx": 552,
"ye": 553,
"yt": 554,
"yugoslavia": 555,
"za": 556,
"zm": 557,
"zw": 558
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 995 KiB

@@ -0,0 +1,78 @@
{
"width": 540,
"height": 242,
"rows": [
{
"yOffset": 0,
"height": 60,
"worldWidth": 60,
"worldHeight": 60
},
{
"yOffset": 60,
"height": 38,
"worldWidth": 48,
"worldHeight": 38
},
{
"yOffset": 98,
"height": 15,
"worldWidth": 17,
"worldHeight": 15
},
{
"yOffset": 113,
"height": 19,
"worldWidth": 19,
"worldHeight": 19
},
{
"yOffset": 132,
"height": 12,
"worldWidth": 13,
"worldHeight": 12
},
{
"yOffset": 144,
"height": 14,
"worldWidth": 16,
"worldHeight": 14
},
{
"yOffset": 158,
"height": 13,
"worldWidth": 7,
"worldHeight": 13
},
{
"yOffset": 171,
"height": 12,
"worldWidth": 11,
"worldHeight": 12
},
{
"yOffset": 183,
"height": 16,
"worldWidth": 24,
"worldHeight": 16
},
{
"yOffset": 199,
"height": 16,
"worldWidth": 24,
"worldHeight": 16
},
{
"yOffset": 215,
"height": 7,
"worldWidth": 9,
"worldHeight": 7
},
{
"yOffset": 222,
"height": 20,
"worldWidth": 21,
"worldHeight": 20
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

@@ -0,0 +1,19 @@
{
"width": 768,
"height": 1024,
"cellSize": 256,
"cols": 3,
"pad": 16,
"icons": {
"crown": 0,
"traitor": 1,
"disconnected": 2,
"alliance": 3,
"allianceRequest": 4,
"target": 5,
"embargo": 6,
"nukeRed": 7,
"nukeWhite": 8,
"allianceFaded": 9
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

+198
View File
@@ -0,0 +1,198 @@
/**
* 2D camera: pan/zoom → column-major mat3 for WebGL2 vertex shaders.
*
* Pure viewport math — no DOM event listeners. Input handling lives
* in GameView, which calls panBy / zoomAtScreen / etc.
*
* Coordinate system:
* World: (0,0) top-left, (mapWidth, mapHeight) bottom-right, +Y down.
* Clip: (-1,-1) bottom-left, (1,1) top-right.
*
* The mat3 maps world → clip:
* sx = zoom * 2 / canvasWidth
* sy = zoom * -2 / canvasHeight (Y flip)
* tx = -offsetX * sx
* ty = -offsetY * sy
*/
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 20;
const DBLCLICK_MIN_ZOOM = 0.7;
const DBLCLICK_MAX_ZOOM = 3;
export class Camera {
offsetX: number;
offsetY: number;
zoom: number;
private mapW: number;
private mapH: number;
private canvasW = 1;
private canvasH = 1;
private mat = new Float32Array(9);
private dirty = true;
/** True until fitMap() has been called with valid canvas dimensions. */
private needsInitialFit = true;
constructor(mapWidth: number, mapHeight: number) {
this.mapW = mapWidth;
this.mapH = mapHeight;
this.offsetX = mapWidth / 2;
this.offsetY = mapHeight / 2;
this.zoom = 1;
}
/** Update canvas pixel dimensions. Triggers initial fitMap on first call. */
resize(cssWidth: number, cssHeight: number): void {
const dpr = window.devicePixelRatio || 1;
this.canvasW = Math.round(cssWidth * dpr);
this.canvasH = Math.round(cssHeight * dpr);
if (this.needsInitialFit) {
this.fitMap();
}
this.dirty = true;
}
/** Fit the map into the viewport (~90% fill). */
fitMap(): void {
this.offsetX = this.mapW / 2;
this.offsetY = this.mapH / 2;
const sx = this.canvasW / this.mapW;
const sy = this.canvasH / this.mapH;
this.zoom = Math.min(sx, sy) * 0.9;
this.dirty = true;
this.needsInitialFit = false;
}
/** Center the camera on a bounding box with padding (1.4 ≈ 71% fill). */
focusBBox(
minX: number,
minY: number,
maxX: number,
maxY: number,
padding = 1.4,
): void {
this.offsetX = (minX + maxX + 1) / 2;
this.offsetY = (minY + maxY + 1) / 2;
const bboxW = maxX - minX + 1;
const bboxH = maxY - minY + 1;
const sx = this.canvasW / bboxW;
const sy = this.canvasH / bboxH;
this.zoom = Math.max(
DBLCLICK_MIN_ZOOM,
Math.min(DBLCLICK_MAX_ZOOM, Math.min(sx, sy) / padding),
);
this.clampOffset();
this.dirty = true;
}
/** Set the camera center to a world position. */
panTo(worldX: number, worldY: number): void {
this.offsetX = worldX;
this.offsetY = worldY;
this.clampOffset();
this.dirty = true;
}
/** Shift the camera center by a world-space delta (used for drag panning). */
panBy(dx: number, dy: number): void {
this.offsetX += dx;
this.offsetY += dy;
this.clampOffset();
this.dirty = true;
}
/** Restore camera state, skipping the initial fitMap. */
setCameraState(x: number, y: number, z: number): void {
this.offsetX = x;
this.offsetY = y;
this.zoom = z;
this.needsInitialFit = false;
this.dirty = true;
}
/** Multiply zoom by a factor (centered on current view). */
zoomBy(factor: number): void {
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
this.clampOffset();
this.dirty = true;
}
/** Set absolute zoom level. */
zoomTo(level: number): void {
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
this.clampOffset();
this.dirty = true;
}
/**
* Zoom by a factor while keeping a screen point fixed in world space.
* Used for wheel-zoom: the world position under the cursor stays put.
*/
zoomAtScreen(factor: number, screenX: number, screenY: number): void {
const worldBefore = this.screenToWorld(screenX, screenY);
this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor));
const worldAfter = this.screenToWorld(screenX, screenY);
this.offsetX += worldBefore.x - worldAfter.x;
this.offsetY += worldBefore.y - worldAfter.y;
this.clampOffset();
this.dirty = true;
}
/** Return the column-major mat3 camera matrix (world → clip). */
getMatrix(): Float32Array {
if (this.dirty) {
const sx = (this.zoom * 2) / this.canvasW;
const sy = (this.zoom * -2) / this.canvasH; // Y flip
const tx = -this.offsetX * sx;
const ty = -this.offsetY * sy;
const m = this.mat;
m[0] = sx;
m[1] = 0;
m[2] = 0;
m[3] = 0;
m[4] = sy;
m[5] = 0;
m[6] = tx;
m[7] = ty;
m[8] = 1;
this.dirty = false;
}
return this.mat;
}
/** Convert screen pixel position to world coordinates. */
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const dpr = window.devicePixelRatio || 1;
const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1;
const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1);
const sx = (this.zoom * 2) / this.canvasW;
const sy = (this.zoom * -2) / this.canvasH;
return {
x: (ndcX - -this.offsetX * sx) / sx,
y: (ndcY - -this.offsetY * sy) / sy,
};
}
/** Convert world coordinates to screen pixel position (CSS pixels). */
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
const dpr = window.devicePixelRatio || 1;
return {
x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr),
y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr),
};
}
private clampOffset(): void {
const halfVpW = this.canvasW / (2 * this.zoom);
const halfVpH = this.canvasH / (2 * this.zoom);
this.offsetX = Math.max(
-halfVpW,
Math.min(this.mapW + halfVpW, this.offsetX),
);
this.offsetY = Math.max(
-halfVpH,
Math.min(this.mapH + halfVpH, this.offsetY),
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { Controller } from "lil-gui";
/**
* A single configurable property in the debug GUI.
* Each prop knows how to draw itself, report modification, and reset.
*/
export interface ConfigProp {
draw(folder: import("lil-gui").default): Controller;
isModified(): boolean;
resetToDefault(): void;
updateDisplay(): void;
}
+18
View File
@@ -0,0 +1,18 @@
import type { ConfigProp } from "./config-prop";
export interface FolderNode {
kind: "folder";
label: string;
closed: boolean;
children: DebugNode[];
}
export type DebugNode = ConfigProp | FolderNode;
export function folder(
label: string,
children: DebugNode[],
opts: { closed?: boolean } = {},
): FolderNode {
return { kind: "folder", label, closed: opts.closed ?? true, children };
}
+28
View File
@@ -0,0 +1,28 @@
import GUI from "lil-gui";
import type { RenderSettings } from "../render-settings";
import { createRenderSettings } from "../render-settings";
import { buildTree } from "./layout";
import { walkTree } from "./tree";
import { makeDraggable, wireActions, wireModifiedIndicators } from "./wiring";
export function createDebugGui(
settings: RenderSettings,
onSettingsChanged?: () => void,
): GUI {
const gui = new GUI({ title: "Render Settings", width: 320 });
gui.domElement.style.position = "fixed";
gui.domElement.style.top = "8px";
gui.domElement.style.right = "8px";
gui.domElement.style.zIndex = "100";
makeDraggable(gui);
const defaults = createRenderSettings();
const props = walkTree(buildTree(settings, defaults), gui);
wireActions(gui, settings, props, onSettingsChanged);
wireModifiedIndicators(gui, props, onSettingsChanged);
gui.close();
return gui;
}
+747
View File
@@ -0,0 +1,747 @@
import type { RenderSettings } from "../render-settings";
import type { DebugNode } from "./folder";
import { folder } from "./folder";
import { color } from "./props/color";
import { select } from "./props/select";
import { slider } from "./props/slider";
import { toggle } from "./props/toggle";
export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] {
return [
folder("Pass Enables", [
toggle(s.passEnabled, "terrain", d.passEnabled),
toggle(s.passEnabled, "mapOverlay", d.passEnabled),
toggle(s.passEnabled, "structure", d.passEnabled),
toggle(s.passEnabled, "unit", d.passEnabled),
toggle(s.passEnabled, "name", d.passEnabled),
toggle(s.passEnabled, "falloutBloom", d.passEnabled),
toggle(s.passEnabled, "railroad", d.passEnabled),
toggle(s.passEnabled, "fx", d.passEnabled),
toggle(s.passEnabled, "bar", d.passEnabled),
toggle(s.passEnabled, "dayNight", d.passEnabled),
toggle(s.passEnabled, "nameDebug", d.passEnabled, "Name Debug Boxes"),
]),
folder("Fallout Bloom", [
slider(s.falloutBloom, "broilSpeedCold", d.falloutBloom, 0, 0.05, 0.0001),
slider(s.falloutBloom, "broilSpeedHot", d.falloutBloom, 0, 0.05, 0.0001),
slider(s.falloutBloom, "noiseFreq1", d.falloutBloom, 0, 0.5, 0.001),
slider(s.falloutBloom, "noiseFreq2", d.falloutBloom, 0, 0.5, 0.001),
slider(s.falloutBloom, "contrastLoCold", d.falloutBloom, 0, 1, 0.01),
slider(s.falloutBloom, "contrastLoHot", d.falloutBloom, 0, 1, 0.01),
slider(s.falloutBloom, "contrastHiCold", d.falloutBloom, 0, 1, 0.01),
slider(s.falloutBloom, "contrastHiHot", d.falloutBloom, 0, 1, 0.01),
slider(s.falloutBloom, "metaFreq", d.falloutBloom, 0, 0.2, 0.001),
slider(s.falloutBloom, "intensityCold", d.falloutBloom, 0, 10, 0.05),
slider(s.falloutBloom, "intensityHot", d.falloutBloom, 0, 20, 0.1),
slider(s.falloutBloom, "metaInfluenceCold", d.falloutBloom, 0, 1, 0.01),
slider(s.falloutBloom, "metaInfluenceHot", d.falloutBloom, 0, 1, 0.01),
slider(s.falloutBloom, "opacityFadeEnd", d.falloutBloom, 0, 1, 0.01),
color(
s.falloutBloom,
"bloomR",
"bloomG",
"bloomB",
d.falloutBloom,
"Bloom Color",
),
slider(s.falloutBloom, "bloomCoverage", d.falloutBloom, 0, 10, 0.1),
slider(s.falloutBloom, "heatDecayPerTick", d.falloutBloom, 0, 5, 0.01),
]),
folder("Day / Night", [
select(
s.dayNight,
"mode",
d.dayNight,
["light", "dark", "cycle"],
"Mode",
),
slider(s.dayNight, "cycleTicks", d.dayNight, 60, 6000, 10),
slider(
s.dayNight,
"startPhase",
d.dayNight,
0,
1,
0.01,
"Start Phase (0=noon)",
),
slider(s.dayNight, "noonHold", d.dayNight, 0, 1, 0.01, "Noon Hold"),
slider(s.dayNight, "nightHold", d.dayNight, 0, 1, 0.01, "Night Hold"),
slider(s.dayNight, "nightAmbient", d.dayNight, 0, 1, 0.01),
slider(s.dayNight, "dayAmbient", d.dayNight, 0, 1, 0.01),
slider(s.dayNight, "falloffPower", d.dayNight, 0.5, 5, 0.1),
slider(s.dayNight, "falloutLightIntensity", d.dayNight, 0, 20, 0.1),
slider(s.dayNight, "falloutLightThreshold", d.dayNight, 0, 0.5, 0.001),
slider(s.dayNight, "blurZoomDivisor", d.dayNight, 1, 20, 0.5),
slider(s.dayNight, "lightRadiusMultiplier", d.dayNight, 0.1, 5, 0.1),
color(
s.dayNight,
"falloutLightR",
"falloutLightG",
"falloutLightB",
d.dayNight,
"Fallout Light Color",
),
slider(s.dayNight, "emberLightIntensity", d.dayNight, 0, 20, 0.1),
color(
s.dayNight,
"emberLightR",
"emberLightG",
"emberLightB",
d.dayNight,
"Ember Light Color",
),
]),
folder("Map Overlay", [
slider(s.mapOverlay, "trailAlpha", d.mapOverlay, 0, 1, 0.01),
slider(s.mapOverlay, "defenseCheckerDarken", d.mapOverlay, 0, 1, 0.01),
slider(s.mapOverlay, "charcoalBase", d.mapOverlay, 0, 0.3, 0.005),
slider(s.mapOverlay, "charcoalVariation", d.mapOverlay, 0, 0.3, 0.005),
slider(s.mapOverlay, "charcoalAlpha", d.mapOverlay, 0, 1, 0.01),
slider(
s.mapOverlay,
"emberThresholdUnowned",
d.mapOverlay,
0.5,
1,
0.005,
),
slider(s.mapOverlay, "emberThresholdOwned", d.mapOverlay, 0.5, 1, 0.005),
slider(s.mapOverlay, "emberFlickerSpeed", d.mapOverlay, 0, 2, 0.01),
color(
s.mapOverlay,
"emberColorDarkR",
"emberColorDarkG",
"emberColorDarkB",
d.mapOverlay,
"Ember Color Dark",
),
color(
s.mapOverlay,
"emberColorBrightR",
"emberColorBrightG",
"emberColorBrightB",
d.mapOverlay,
"Ember Color Bright",
),
slider(s.mapOverlay, "emberStrengthUnowned", d.mapOverlay, 0, 2, 0.01),
slider(
s.mapOverlay,
"highlightBrighten",
d.mapOverlay,
0,
1,
0.01,
"Highlight Brighten",
),
slider(
s.mapOverlay,
"highlightThicken",
d.mapOverlay,
0,
10,
1,
"Highlight Thicken (tiles)",
),
folder("Railroad", [
slider(s.railroad, "railMinZoom", d.railroad, 0, 10, 0.1, "Min Zoom"),
slider(
s.railroad,
"railDetailZoom",
d.railroad,
0,
20,
0.1,
"Detail Zoom",
),
slider(s.railroad, "railAlpha", d.railroad, 0, 1, 0.01, "Alpha"),
]),
]),
folder("Structure", [
slider(s.structure, "iconSize", d.structure, 10, 60, 1),
slider(s.structure, "dotsZoomThreshold", d.structure, 0.1, 2, 0.05),
slider(
s.structure,
"iconScaleFactorZoomedOut",
d.structure,
0.5,
3,
0.05,
),
slider(
s.structure,
"highlightOutlineWidth",
d.structure,
0,
0.2,
0.005,
"Highlight Outline W",
),
slider(
s.structure,
"highlightDimAlpha",
d.structure,
0,
1,
0.01,
"Highlight Dim Alpha",
),
folder(
"Per-Shape",
Object.entries(s.structure.shapes).map(([name, cfg]) =>
folder(name, [
slider(
cfg,
"scale",
d.structure.shapes[name],
0.5,
2,
0.05,
"Frame Scale",
),
slider(
cfg,
"iconFill",
d.structure.shapes[name],
0.2,
1.5,
0.05,
"Icon Fill",
),
]),
),
),
]),
folder("Bar", [
slider(s.bar, "healthBarW", d.bar, 3, 30, 1, "Health Width"),
slider(s.bar, "healthBarH", d.bar, 1, 10, 1, "Health Height"),
slider(s.bar, "healthBarOffsetY", d.bar, -20, 0, 1, "Health Offset Y"),
slider(s.bar, "progressBarW", d.bar, 3, 30, 1, "Progress Width"),
slider(s.bar, "progressBarH", d.bar, 1, 10, 1, "Progress Height"),
slider(s.bar, "progressBarOffsetY", d.bar, 0, 20, 1, "Progress Offset Y"),
slider(s.bar, "borderWidth", d.bar, 0, 3, 0.5, "Border Width"),
slider(s.bar, "threshold1", d.bar, 0, 1, 0.05, "Red→Orange"),
slider(s.bar, "threshold2", d.bar, 0, 1, 0.05, "Orange→Yellow"),
slider(s.bar, "threshold3", d.bar, 0, 1, 0.05, "Yellow→Green"),
color(s.bar, "colorRedR", "colorRedG", "colorRedB", d.bar, "Red"),
color(
s.bar,
"colorOrangeR",
"colorOrangeG",
"colorOrangeB",
d.bar,
"Orange",
),
color(
s.bar,
"colorYellowR",
"colorYellowG",
"colorYellowB",
d.bar,
"Yellow",
),
color(s.bar, "colorGreenR", "colorGreenG", "colorGreenB", d.bar, "Green"),
]),
folder("Unit", [
slider(s.unit, "unitSize", d.unit, 4, 64, 1),
slider(s.unit, "flickerSpeed", d.unit, 0, 2, 0.01),
color(s.unit, "angryR", "angryG", "angryB", d.unit, "Angry Color"),
]),
folder("Name", [
slider(s.name, "lerpSpeed", d.name, 1, 30, 0.5),
slider(s.name, "cullThreshold", d.name, 0, 0.05, 0.001),
slider(s.name, "nameScaleFactor", d.name, 0.1, 1, 0.05),
slider(s.name, "nameScaleCap", d.name, 1, 10, 0.5),
slider(s.name, "troopSizeMultiplier", d.name, 0.1, 2, 0.05),
slider(s.name, "outlineWidth", d.name, 0, 10, 0.1, "Outline Width (px)"),
color(
s.name,
"outlineR",
"outlineG",
"outlineB",
d.name,
"Outline Color",
),
toggle(s.name, "outlineUsePlayerColor", d.name, "Outline = Player Color"),
toggle(s.name, "fillUsePlayerColor", d.name, "Fill = Player Color"),
slider(s.name, "emojiRowOffset", d.name, 0, 5, 0.1, "Emoji Row Offset"),
slider(s.name, "statusRowOffset", d.name, 0, 5, 0.1, "Status Row Offset"),
]),
folder("FX", [
slider(s.fx, "shockwaveRingWidth", d.fx, 0.01, 0.2, 0.005),
slider(
s.fx,
"nukeShockwaveDurationMs",
d.fx,
200,
5000,
100,
"Nuke Shock Duration",
),
slider(
s.fx,
"nukeShockwaveRadiusFactor",
d.fx,
0.5,
3,
0.1,
"Nuke Shock Radius ×",
),
slider(
s.fx,
"samShockwaveDurationMs",
d.fx,
200,
3000,
50,
"SAM Shock Duration",
),
slider(s.fx, "samShockwaveRadius", d.fx, 10, 100, 5, "SAM Shock Radius"),
slider(
s.fx,
"debrisLifetimeMs",
d.fx,
1000,
15000,
500,
"Debris Lifetime",
),
slider(s.fx, "debrisFadeIn", d.fx, 0, 0.5, 0.01, "Debris Fade In"),
slider(s.fx, "debrisFadeOut", d.fx, 0.3, 1, 0.01, "Debris Fade Out"),
slider(
s.fx,
"conquestLifetimeMs",
d.fx,
500,
8000,
250,
"Conquest Lifetime",
),
slider(s.fx, "conquestFadeIn", d.fx, 0, 0.5, 0.01, "Conquest Fade In"),
slider(s.fx, "conquestFadeOut", d.fx, 0.3, 1, 0.01, "Conquest Fade Out"),
]),
folder("Nuke Trajectory", [
slider(
s.nukeTrajectory,
"lineWidth",
d.nukeTrajectory,
0.5,
5,
0.25,
"Line Width (px)",
),
slider(
s.nukeTrajectory,
"outlineWidth",
d.nukeTrajectory,
0,
4,
0.25,
"Outline Width (px)",
),
slider(
s.nukeTrajectory,
"dashTargetable",
d.nukeTrajectory,
1,
30,
1,
"Dash (targetable)",
),
slider(
s.nukeTrajectory,
"gapTargetable",
d.nukeTrajectory,
1,
20,
1,
"Gap (targetable)",
),
slider(
s.nukeTrajectory,
"dashUntargetable",
d.nukeTrajectory,
1,
20,
1,
"Dash (untargetable)",
),
slider(
s.nukeTrajectory,
"gapUntargetable",
d.nukeTrajectory,
1,
20,
1,
"Gap (untargetable)",
),
color(
s.nukeTrajectory,
"lineR",
"lineG",
"lineB",
d.nukeTrajectory,
"Line Color",
),
color(
s.nukeTrajectory,
"interceptR",
"interceptG",
"interceptB",
d.nukeTrajectory,
"Intercept Color",
),
color(
s.nukeTrajectory,
"outlineR",
"outlineG",
"outlineB",
d.nukeTrajectory,
"Outline Color",
),
color(
s.nukeTrajectory,
"interceptOutlineR",
"interceptOutlineG",
"interceptOutlineB",
d.nukeTrajectory,
"Intercept Outline",
),
slider(
s.nukeTrajectory,
"markerCircleRadius",
d.nukeTrajectory,
2,
16,
1,
"Circle Marker (px)",
),
slider(
s.nukeTrajectory,
"markerXRadius",
d.nukeTrajectory,
2,
16,
1,
"X Marker (px)",
),
]),
folder("Nuke Telegraph", [
slider(
s.nukeTelegraph,
"strokeWidth",
d.nukeTelegraph,
0.5,
5,
0.25,
"Stroke Width",
),
slider(
s.nukeTelegraph,
"dashLen",
d.nukeTelegraph,
2,
30,
1,
"Dash Length",
),
slider(
s.nukeTelegraph,
"gapLen",
d.nukeTelegraph,
1,
20,
1,
"Gap Length",
),
slider(
s.nukeTelegraph,
"rotationSpeed",
d.nukeTelegraph,
0,
60,
1,
"Rotation Speed",
),
slider(
s.nukeTelegraph,
"baseAlpha",
d.nukeTelegraph,
0,
1,
0.05,
"Base Alpha",
),
slider(
s.nukeTelegraph,
"pulseAmplitude",
d.nukeTelegraph,
0,
0.5,
0.01,
"Pulse Amplitude",
),
slider(
s.nukeTelegraph,
"pulseSpeed",
d.nukeTelegraph,
0,
10,
0.5,
"Pulse Speed",
),
slider(
s.nukeTelegraph,
"fillAlphaOffset",
d.nukeTelegraph,
0,
1,
0.05,
"Fill Alpha Offset",
),
color(
s.nukeTelegraph,
"colorR",
"colorG",
"colorB",
d.nukeTelegraph,
"Color",
),
]),
folder("Move Indicator", [
slider(
s.moveIndicator,
"startRadius",
d.moveIndicator,
1,
40,
1,
"Start Radius (px)",
),
slider(
s.moveIndicator,
"chevronSize",
d.moveIndicator,
1,
20,
0.5,
"Chevron Size (px)",
),
slider(
s.moveIndicator,
"lineWidth",
d.moveIndicator,
0.5,
6,
0.25,
"Line Width (px)",
),
slider(
s.moveIndicator,
"duration",
d.moveIndicator,
100,
3000,
50,
"Duration (ms)",
),
slider(
s.moveIndicator,
"converge",
d.moveIndicator,
0,
1,
0.05,
"Converge",
),
]),
folder("SAM Radius", [
slider(
s.samRadius,
"strokeWidth",
d.samRadius,
0.5,
5,
0.1,
"Stroke Width",
),
slider(s.samRadius, "dashLen", d.samRadius, 2, 30, 1, "Dash Length"),
slider(s.samRadius, "gapLen", d.samRadius, 1, 20, 1, "Gap Length"),
slider(
s.samRadius,
"rotationSpeed",
d.samRadius,
0,
40,
1,
"Rotation Speed",
),
slider(s.samRadius, "alpha", d.samRadius, 0, 1, 0.05, "Alpha"),
slider(
s.samRadius,
"outlineWidth",
d.samRadius,
0,
2,
0.05,
"Outline Width",
),
slider(
s.samRadius,
"outlineSoftness",
d.samRadius,
0,
1,
0.05,
"Outline Softness",
),
]),
folder("Bonus Popup", [
slider(s.bonusPopup, "scale", d.bonusPopup, 1, 12, 0.5, "Scale"),
slider(
s.bonusPopup,
"lifetimeMs",
d.bonusPopup,
500,
5000,
100,
"Lifetime (ms)",
),
slider(s.bonusPopup, "riseSpeed", d.bonusPopup, 0, 10, 0.5, "Rise Speed"),
slider(s.bonusPopup, "yOffset", d.bonusPopup, -10, 10, 0.5, "Y Offset"),
slider(
s.bonusPopup,
"outlineWidth",
d.bonusPopup,
0,
5,
0.1,
"Outline Width",
),
color(s.bonusPopup, "colorR", "colorG", "colorB", d.bonusPopup, "Color"),
slider(
s.bonusPopup,
"minScreenScale",
d.bonusPopup,
0,
1,
0.01,
"Min Screen Scale",
),
slider(s.bonusPopup, "cullZoom", d.bonusPopup, 0, 2, 0.05, "Cull Zoom"),
]),
folder("Spawn Overlay", [
slider(
s.spawnOverlay,
"highlightRadius",
d.spawnOverlay,
1,
20,
1,
"Highlight Radius",
),
slider(
s.spawnOverlay,
"highlightAlpha",
d.spawnOverlay,
0,
1,
0.05,
"Highlight Alpha",
),
slider(
s.spawnOverlay,
"selfMinRad",
d.spawnOverlay,
1,
30,
0.5,
"Self Min Radius",
),
slider(
s.spawnOverlay,
"selfMaxRad",
d.spawnOverlay,
5,
50,
0.5,
"Self Max Radius",
),
slider(
s.spawnOverlay,
"mateMinRad",
d.spawnOverlay,
1,
20,
0.5,
"Mate Min Radius",
),
slider(
s.spawnOverlay,
"mateMaxRad",
d.spawnOverlay,
5,
30,
0.5,
"Mate Max Radius",
),
slider(
s.spawnOverlay,
"animSpeed",
d.spawnOverlay,
0.001,
0.02,
0.001,
"Anim Speed",
),
slider(
s.spawnOverlay,
"gradientInnerEdge",
d.spawnOverlay,
0.001,
0.1,
0.001,
"Gradient Inner Edge",
),
slider(
s.spawnOverlay,
"gradientSolidEnd",
d.spawnOverlay,
0.01,
0.5,
0.01,
"Gradient Solid End",
),
]),
folder("Alt View", [
slider(s.altView, "gridFontSize", d.altView, 6, 32, 1, "Grid Font Size"),
toggle(s.altView, "recolorStructures", d.altView, "Recolor Structures"),
]),
folder(
"Light Configs",
Object.entries(s.lightConfigs).map(([name, cfg]) =>
folder(name, [
slider(cfg, "radius", d.lightConfigs[name], 1, 60, 1),
slider(cfg, "intensity", d.lightConfigs[name], 0, 10, 0.1),
]),
),
),
];
}
+67
View File
@@ -0,0 +1,67 @@
import type GUI from "lil-gui";
import type { ColorController, Controller } from "lil-gui";
import type { ConfigProp } from "../config-prop";
export function color<T extends Record<string, unknown>>(
target: T,
rKey: keyof T & string,
gKey: keyof T & string,
bKey: keyof T & string,
defaults: T,
label?: string,
): ConfigProp {
const defaultR = defaults[rKey] as number;
const defaultG = defaults[gKey] as number;
const defaultB = defaults[bKey] as number;
const proxy = {
color: {
r: target[rKey] as number,
g: target[gKey] as number,
b: target[bKey] as number,
},
};
let ctrl: Controller | undefined;
return {
draw(folder: GUI) {
ctrl = folder
.addColor(proxy, "color")
.onChange((v: { r: number; g: number; b: number }) => {
(target as Record<string, unknown>)[rKey] = v.r;
(target as Record<string, unknown>)[gKey] = v.g;
(target as Record<string, unknown>)[bKey] = v.b;
});
if (label) ctrl.name(label);
return ctrl;
},
isModified: () =>
(target[rKey] as number) !== defaultR ||
(target[gKey] as number) !== defaultG ||
(target[bKey] as number) !== defaultB,
resetToDefault() {
(target as Record<string, unknown>)[rKey] = defaultR;
(target as Record<string, unknown>)[gKey] = defaultG;
(target as Record<string, unknown>)[bKey] = defaultB;
proxy.color = { r: defaultR, g: defaultG, b: defaultB };
(ctrl as ColorController | undefined)?.load(
"#" +
[defaultR, defaultG, defaultB]
.map((v) =>
Math.round(v * 255)
.toString(16)
.padStart(2, "0"),
)
.join(""),
);
},
updateDisplay() {
proxy.color = {
r: target[rKey] as number,
g: target[gKey] as number,
b: target[bKey] as number,
};
ctrl?.updateDisplay();
},
};
}
@@ -0,0 +1,29 @@
import type GUI from "lil-gui";
import type { Controller } from "lil-gui";
import type { ConfigProp } from "../config-prop";
export function select<T extends Record<string, unknown>>(
target: T,
key: keyof T & string,
defaults: T,
options: string[],
label?: string,
): ConfigProp {
const defaultVal = defaults[key] as string;
let ctrl: Controller | undefined;
return {
draw(folder: GUI) {
ctrl = folder.add(target, key, options);
if (label) ctrl.name(label);
return ctrl;
},
isModified: () => (target[key] as string) !== defaultVal,
resetToDefault() {
(target as Record<string, unknown>)[key] = defaultVal;
ctrl?.updateDisplay();
},
updateDisplay() {
ctrl?.updateDisplay();
},
};
}
@@ -0,0 +1,31 @@
import type GUI from "lil-gui";
import type { Controller } from "lil-gui";
import type { ConfigProp } from "../config-prop";
export function slider<T extends Record<string, unknown>>(
target: T,
key: keyof T & string,
defaults: T,
min: number,
max: number,
step: number,
label?: string,
): ConfigProp {
const defaultVal = defaults[key] as number;
let ctrl: Controller | undefined;
return {
draw(folder: GUI) {
ctrl = folder.add(target, key, min, max, step);
if (label) ctrl.name(label);
return ctrl;
},
isModified: () => (target[key] as number) !== defaultVal,
resetToDefault() {
(target as Record<string, unknown>)[key] = defaultVal;
ctrl?.updateDisplay();
},
updateDisplay() {
ctrl?.updateDisplay();
},
};
}
@@ -0,0 +1,28 @@
import type GUI from "lil-gui";
import type { Controller } from "lil-gui";
import type { ConfigProp } from "../config-prop";
export function toggle<T extends Record<string, unknown>>(
target: T,
key: keyof T & string,
defaults: T,
label?: string,
): ConfigProp {
const defaultVal = defaults[key] as boolean;
let ctrl: Controller | undefined;
return {
draw(folder: GUI) {
ctrl = folder.add(target, key);
if (label) ctrl.name(label);
return ctrl;
},
isModified: () => (target[key] as boolean) !== defaultVal,
resetToDefault() {
(target as Record<string, unknown>)[key] = defaultVal;
ctrl?.updateDisplay();
},
updateDisplay() {
ctrl?.updateDisplay();
},
};
}
+23
View File
@@ -0,0 +1,23 @@
import GUI from "lil-gui";
import type { ConfigProp } from "./config-prop";
import type { DebugNode, FolderNode } from "./folder";
/** Walk the debug tree, drawing each node onto the GUI. Returns all leaf props. */
export function walkTree(nodes: DebugNode[], parent: GUI): ConfigProp[] {
const props: ConfigProp[] = [];
for (const node of nodes) {
if (isFolderNode(node)) {
const sub = parent.addFolder(node.label);
props.push(...walkTree(node.children, sub));
if (node.closed) sub.close();
} else {
node.draw(parent);
props.push(node);
}
}
return props;
}
function isFolderNode(node: DebugNode): node is FolderNode {
return (node as FolderNode).kind === "folder";
}
+215
View File
@@ -0,0 +1,215 @@
import GUI, { FunctionController } from "lil-gui";
import type { RenderSettings } from "../render-settings";
import { createRenderSettings, dumpSettings } from "../render-settings";
import { deepAssign } from "../settings-utils";
import type { ConfigProp } from "./config-prop";
// ---------------------------------------------------------------------------
// Draggable title bar
// ---------------------------------------------------------------------------
export function makeDraggable(gui: GUI): void {
const titleBar = gui.domElement.querySelector(
".title, .lil-title",
) as HTMLElement | null;
if (!titleBar) return;
titleBar.style.cursor = "grab";
let dragging = false;
let didDrag = false;
let startX = 0,
startY = 0,
startLeft = 0,
startTop = 0;
titleBar.addEventListener("mousedown", (e) => {
dragging = true;
didDrag = false;
titleBar.style.cursor = "grabbing";
const rect = gui.domElement.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
gui.domElement.style.left = rect.left + "px";
gui.domElement.style.right = "auto";
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
didDrag = true;
gui.domElement.style.left = startLeft + e.clientX - startX + "px";
gui.domElement.style.top = startTop + e.clientY - startY + "px";
});
window.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
titleBar.style.cursor = "grab";
});
titleBar.addEventListener(
"click",
(e) => {
if (didDrag) e.stopPropagation();
},
{ capture: true },
);
}
// ---------------------------------------------------------------------------
// Actions: Download JSON, Load JSON, Reset to Defaults
// ---------------------------------------------------------------------------
export function wireActions(
gui: GUI,
settings: RenderSettings,
props: ConfigProp[],
onSettingsChanged?: () => void,
): void {
gui.add({ dump: () => dumpSettings(settings) }, "dump").name("Download JSON");
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
deepAssign(settings, JSON.parse(reader.result as string));
props.forEach((p) => p.updateDisplay());
onSettingsChanged?.();
} catch (e) {
console.error("Failed to load render settings:", e);
}
};
reader.readAsText(file);
fileInput.value = "";
});
gui.add({ load: () => fileInput.click() }, "load").name("Load JSON");
gui
.add(
{
reset: () => {
deepAssign(settings, createRenderSettings());
props.forEach((p) => p.resetToDefault());
onSettingsChanged?.();
},
},
"reset",
)
.name("Reset to Defaults");
}
// ---------------------------------------------------------------------------
// Modified indicators: blue label + right-click reset context menu
// ---------------------------------------------------------------------------
const MODIFIED_CLASS = "lil-modified";
let stylesInjected = false;
function injectModifiedStyles(): void {
if (stylesInjected) return;
stylesInjected = true;
const style = document.createElement("style");
style.textContent = `
.${MODIFIED_CLASS} .lil-name { color: #5ba8d6; }
.lil-reset-menu {
position: fixed;
z-index: 10000;
background: #1a1a2e;
border: 1px solid #444;
border-radius: 4px;
padding: 4px 0;
font: 12px sans-serif;
color: #ccc;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
.lil-reset-menu div {
padding: 4px 16px;
cursor: pointer;
white-space: nowrap;
}
.lil-reset-menu div:hover {
background: #2a2a4e;
color: #fff;
}
`;
document.head.appendChild(style);
}
function createContextMenu(): HTMLDivElement {
const menu = document.createElement("div");
menu.className = "lil-reset-menu";
menu.style.display = "none";
document.body.appendChild(menu);
document.addEventListener("mousedown", (e) => {
if (!menu.contains(e.target as Node)) menu.style.display = "none";
});
return menu;
}
export function wireModifiedIndicators(
gui: GUI,
props: ConfigProp[],
onSettingsChanged?: () => void,
): void {
injectModifiedStyles();
const contextMenu = createContextMenu();
// Map each lil-gui Controller back to its ConfigProp
const allControllers = gui.controllersRecursive();
// Props were pushed in walk order, controllers are in the same order (minus FunctionControllers)
const propControllers = allControllers.filter(
(c) => !(c instanceof FunctionController),
);
propControllers.forEach((ctrl, i) => {
const prop = props[i];
const updateClass = () =>
ctrl.domElement.classList.toggle(MODIFIED_CLASS, prop.isModified());
updateClass();
const prev = ctrl._onChange;
ctrl.onChange(function (...args: unknown[]) {
prev?.apply(ctrl, args as any);
updateClass();
});
ctrl.$name.addEventListener("contextmenu", (e) => {
if (!prop.isModified()) return;
e.preventDefault();
e.stopPropagation();
contextMenu.innerHTML = "";
const item = document.createElement("div");
item.textContent = "Reset to default";
item.addEventListener("mousedown", (ev) => {
ev.stopPropagation();
prop.resetToDefault();
updateClass();
onSettingsChanged?.();
contextMenu.style.display = "none";
});
contextMenu.appendChild(item);
contextMenu.style.left = e.clientX + "px";
contextMenu.style.top = e.clientY + "px";
contextMenu.style.display = "";
});
});
// Wire onFinishChange for persistence
if (onSettingsChanged) {
allControllers.forEach((c) => c.onFinishChange(onSettingsChanged));
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* DynamicInstanceBuffer — manages grow-on-demand instance buffers.
*
* Encapsulates the pattern of doubling capacity when needed, allocating new
* Float32Array, copying old data, and rebinding the GL buffer.
*/
export class DynamicInstanceBuffer {
private data: Float32Array;
private bytes: Uint8Array;
private capacity: number;
constructor(
private gl: WebGL2RenderingContext,
private buf: WebGLBuffer,
initialCapacity: number,
private floatsPerInstance: number,
) {
this.capacity = initialCapacity;
this.data = new Float32Array(initialCapacity * floatsPerInstance);
this.bytes = new Uint8Array(this.data.buffer);
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
}
ensureCapacity(needed: number): void {
if (needed <= this.capacity) return;
while (this.capacity < needed) this.capacity *= 2;
const newData = new Float32Array(this.capacity * this.floatsPerInstance);
newData.set(this.data);
this.data = newData;
this.bytes = new Uint8Array(newData.buffer);
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.buf);
gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
}
get float32(): Float32Array {
return this.data;
}
get uint8(): Uint8Array {
return this.bytes;
}
get buffer(): WebGLBuffer {
return this.buf;
}
dispose(): void {
if (this.buf !== null && this.buf !== undefined) {
this.gl.deleteBuffer(this.buf);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { UnitState } from "../types";
/** Event data emitted by GameView for map interactions. */
export interface MapPointerEvent {
/** CSS pixel X relative to viewport (clientX). */
screenX: number;
/** CSS pixel Y relative to viewport (clientY). */
screenY: number;
/** World-space X (fractional; floor for tile column). */
worldX: number;
/** World-space Y (fractional; floor for tile row). */
worldY: number;
/** Tile column (integer, -1 if out of bounds). */
tileX: number;
/** Tile row (integer, -1 if out of bounds). */
tileY: number;
/** Territory owner at this tile (0 = unowned/OOB). */
ownerID: number;
/** Nearest mobile unit under cursor, or null. */
unit: UnitState | null;
/** Nearest structure under cursor, or null. */
structure: UnitState | null;
/** Mouse button: 0 = left, 1 = middle, 2 = right. */
button: number;
/** Shift key held. */
shiftKey: boolean;
/** Ctrl/Meta key held. */
ctrlKey: boolean;
/** Alt key held. */
altKey: boolean;
}
/** Scroll event data emitted by GameView. */
export interface MapScrollEvent {
deltaX: number;
deltaY: number;
shiftKey: boolean;
ctrlKey: boolean;
altKey: boolean;
}
/** Alt-view temporarily peeked (space hold — enables altview + gridview). */
export interface AltViewPeekEvent {
active: boolean;
}
/** Grid-view default toggled (persistent resting state changed via 'M'). */
export interface GridViewToggleEvent {
active: boolean;
}
/** Map of event names to their payload types. */
export interface GameViewEventMap {
/** Left-click (pointerdown + pointerup with < 10px movement). */
click: MapPointerEvent;
/** Double-click. */
dblclick: MapPointerEvent;
/** Middle-click (auxclick with button 1). */
middleclick: MapPointerEvent;
/** Right-click / context menu. */
contextmenu: MapPointerEvent;
/** Hovered entity changed (owner, unit, or structure differs from previous). */
hover: MapPointerEvent;
/** Scroll with modifier keys (unmodified scroll is consumed by zoom). */
scroll: MapScrollEvent;
/** User selected a radial menu item. */
menuselect: RadialMenuSelectEvent;
/** Alt-view temporarily peeked (space hold — enables altview + gridview). */
altviewpeek: AltViewPeekEvent;
/** Grid-view default toggled (M key). */
gridviewtoggle: GridViewToggleEvent;
}
/** A single item in the radial context menu. */
export interface RadialMenuItem {
/** Unique identifier for this action. */
id: string;
/** Emoji key into the atlas (e.g. "⚔️"), or empty string for no icon. */
icon: string;
/** RGB color [01]. */
color: [number, number, number];
/** Whether this action is currently available. */
enabled: boolean;
/** If present, clicking this item opens a submenu with these items. */
subItems?: RadialMenuItem[];
}
/** Emitted when the user selects a radial menu item. */
export interface RadialMenuSelectEvent {
/** Index of the selected segment. */
index: number;
/** The item's id. */
id: string;
}
export type GameViewEventType = keyof GameViewEventMap;
+410
View File
@@ -0,0 +1,410 @@
/**
* GameView — public facade for the openfront-gl renderer.
*
* Wraps GPURenderer (rendering) and Camera (viewport math) as private
* implementation details. Handles all user interaction: drag-to-pan,
* wheel-to-zoom, click detection, hover tracking, and hit-testing.
*
* Consumers only touch GameView — they never import GPURenderer or Camera.
*/
import type {
AttackRingInput,
BonusEvent,
ConquestFx,
DeadUnitFx,
GhostPreviewData,
NameEntry,
NukeTelegraphData,
NukeTrajectoryData,
PlayerState,
PlayerStatic,
PlayerStatusData,
RendererConfig,
TilePair,
UnitState,
} from "../types";
import type {
GameViewEventMap,
GameViewEventType,
RadialMenuItem,
} from "./events";
import type { MapKeyBindings } from "./map-interaction";
import { MapInteraction } from "./map-interaction";
import type { SpawnCenter } from "./passes/spawn-overlay-pass";
import type { RenderSettings } from "./render-settings";
import { GPURenderer } from "./renderer";
export class GameView {
private renderer: GPURenderer;
private resizeObs: ResizeObserver | null = null;
private interaction: MapInteraction;
private listeners = new Map<string, Set<(e: unknown) => void>>();
constructor(
canvas: HTMLCanvasElement,
header: RendererConfig,
terrainBytes: Uint8Array,
paletteData: Float32Array,
raf?: typeof requestAnimationFrame,
caf?: typeof cancelAnimationFrame,
keyBindings?: MapKeyBindings,
) {
this.renderer = new GPURenderer(
canvas,
header,
terrainBytes,
paletteData,
raf,
caf,
);
// Create interaction handler and wire DOM events
this.interaction = new MapInteraction({
renderer: this.renderer,
emit: this.emit.bind(this),
raf: raf ?? requestAnimationFrame.bind(window),
caf: caf ?? cancelAnimationFrame.bind(window),
keyBindings,
});
canvas.addEventListener("pointerdown", (e) =>
this.interaction.handlePointerDown(e),
);
canvas.addEventListener("pointermove", (e) =>
this.interaction.handlePointerMove(e),
);
canvas.addEventListener("pointerup", (e) =>
this.interaction.handlePointerUp(e),
);
canvas.addEventListener("pointercancel", (e) =>
this.interaction.handlePointerUp(e),
);
canvas.addEventListener("wheel", (e) => this.interaction.handleWheel(e), {
passive: false,
});
canvas.addEventListener("contextmenu", (e) =>
this.interaction.handleContextMenu(e),
);
canvas.addEventListener("dblclick", (e) =>
this.interaction.handleDblClick(e),
);
canvas.addEventListener("auxclick", (e) =>
this.interaction.handleAuxClick(e),
);
document.addEventListener("keydown", (e) =>
this.interaction.handleKeyDown(e),
);
document.addEventListener("keyup", (e) => this.interaction.handleKeyUp(e));
this.resizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) this.renderer.resize(width, height);
}
});
this.resizeObs.observe(canvas);
const rect = canvas.getBoundingClientRect();
if (rect.width > 0) this.renderer.resize(rect.width, rect.height);
}
// ---- Event system ----
on<K extends GameViewEventType>(
event: K,
handler: (e: GameViewEventMap[K]) => void,
): void {
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(handler as (e: unknown) => void);
}
off<K extends GameViewEventType>(
event: K,
handler: (e: GameViewEventMap[K]) => void,
): void {
this.listeners.get(event)?.delete(handler as (e: unknown) => void);
}
private emit<K extends GameViewEventType>(
event: K,
data: GameViewEventMap[K],
): void {
const set = this.listeners.get(event);
if (set)
for (const fn of set) (fn as (e: GameViewEventMap[K]) => void)(data);
}
// ---- Radial menu ----
showRadialMenu(
screenX: number,
screenY: number,
items: RadialMenuItem[],
centerItem?: RadialMenuItem,
): void {
this.renderer.showRadialMenu(screenX, screenY, items, centerItem);
// Cursor is at anchor — center starts hovered (synced with RadialMenuPass)
this.interaction.setMenuHoveredSeg(
this.renderer.radialMenuHitTest(screenX, screenY),
);
}
hideRadialMenu(): void {
this.renderer.hideRadialMenu();
this.interaction.setMenuHoveredSeg(-1);
}
openRadialSubMenu(subItems: RadialMenuItem[]): void {
this.renderer.openRadialSubMenu(subItems);
this.interaction.setMenuHoveredSeg(-1);
}
goBackRadialMenu(): void {
this.renderer.goBackRadialMenu();
this.interaction.setMenuHoveredSeg(-1);
}
get radialMenuVisible(): boolean {
return this.renderer.radialMenuVisible;
}
registerRadialMenuIcons(
icons: { key: string; img: CanvasImageSource }[],
): void {
this.renderer.registerRadialMenuIcons(icons);
}
// ---- Camera ----
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
return this.renderer.screenToWorld(screenX, screenY);
}
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
return this.renderer.worldToScreen(worldX, worldY);
}
panTo(worldX: number, worldY: number): void {
this.renderer.panTo(worldX, worldY);
}
zoomTo(level: number): void {
this.renderer.zoomTo(level);
}
fitMap(): void {
this.renderer.fitMap();
}
focusOwner(ownerID: number): void {
this.renderer.focusOwner(ownerID);
}
focusBBox(
minX: number,
minY: number,
maxX: number,
maxY: number,
padding?: number,
): void {
this.renderer.focusBBox(minX, minY, maxX, maxY, padding);
}
getCameraState(): { x: number; y: number; z: number } {
return this.renderer.getCameraState();
}
setCameraState(x: number, y: number, z: number): void {
this.renderer.setCameraState(x, y, z);
}
getOwnerAtWorld(worldX: number, worldY: number): number {
return this.renderer.getOwnerAtWorld(worldX, worldY);
}
// ---- Data upload ----
applyFullFrame(
tileState: Uint16Array,
trailState: Uint8Array,
nukeEvents?: Array<{ tick: number; tiles: number[] }>,
currentTick?: number,
): void {
this.renderer.applyFullFrame(
tileState,
trailState,
nukeEvents,
currentTick,
);
}
applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void {
this.renderer.applyFullTiles(tileState, trailState);
}
applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void {
this.renderer.applyDelta(changedTiles, trailState);
}
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
this.renderer.uploadLiveDelta(tileState, changedTiles);
}
uploadLiveTrailDelta(
trailState: Uint8Array,
dirtyRowMin: number,
dirtyRowMax: number,
): void {
this.renderer.uploadLiveTrailDelta(trailState, dirtyRowMin, dirtyRowMax);
}
/** Upload full tile + trail state without resetting bloom (for live play). */
uploadTileAndTrailState(
tileState: Uint16Array,
trailState: Uint8Array,
): void {
this.renderer.uploadTileAndTrailState(tileState, trailState);
}
updatePalette(paletteData: Float32Array): void {
this.renderer.updatePalette(paletteData);
}
addPlayers(players: PlayerStatic[], paletteData: Float32Array): void {
this.renderer.addPlayers(players, paletteData);
}
uploadRailroadState(data: Uint8Array): void {
this.renderer.uploadRailroadState(data);
}
updateUnits(units: Map<number, UnitState>, gameTick: number): void {
this.renderer.updateUnits(units, gameTick);
}
updateNames(
names: Map<string, NameEntry>,
players: Map<number, PlayerState>,
snap: boolean,
statusData?: Map<number, PlayerStatusData>,
): void {
this.renderer.updateNames(names, players, snap, statusData);
}
updateRelations(data: Uint8Array, size: number): void {
this.renderer.updateRelations(data, size);
}
updateStructures(units: Map<number, UnitState>): void {
this.renderer.updateStructures(units);
}
applyDeadUnits(deadUnits: DeadUnitFx[]): void {
this.renderer.applyDeadUnits(deadUnits);
}
applyConquestEvents(events: ConquestFx[]): void {
this.renderer.applyConquestEvents(events);
}
applyBonusEvents(events: BonusEvent[]): void {
this.renderer.applyBonusEvents(events);
}
applyRailroadDust(tileRefs: number[]): void {
this.renderer.applyRailroadDust(tileRefs);
}
updateAttackRings(rings: AttackRingInput[]): void {
this.renderer.updateAttackRings(rings);
}
clearFx(): void {
this.renderer.clearFx();
}
setFxTimeFn(fn: () => number): void {
this.renderer.setFxTimeFn(fn);
}
/** Update ghost structure preview (build-mode visualization). null = clear. */
updateGhostPreview(data: GhostPreviewData | null): void {
this.interaction.setHasGhostPreview(data !== null);
this.renderer.updateGhostPreview(data);
}
// ---- Nuke UI ----
/** Update nuke trajectory preview arc. null = hide. */
updateNukeTrajectory(data: NukeTrajectoryData | null): void {
this.renderer.updateNukeTrajectory(data);
}
/** Update in-flight nuke target telegraph circles. */
updateNukeTelegraphs(data: NukeTelegraphData[]): void {
this.renderer.updateNukeTelegraphs(data);
}
/** Update spawn phase overlay (tile highlights + breathing rings). */
updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
this.renderer.updateSpawnOverlay(inSpawnPhase, centers);
}
// ---- Selection box ----
/** Show/hide the stippled selection box around a unit (warship selection). */
setSelectedUnit(unitId: number | null): void {
this.renderer.setSelectedUnit(unitId);
}
/** Flash converging-chevron animation at a warship move target. */
showMoveIndicator(tileX: number, tileY: number, ownerID: number): void {
this.renderer.showMoveIndicator(tileX, tileY, ownerID);
}
// ---- SAM radius (replay) ----
setSAMRadiusVisible(visible: boolean): void {
this.renderer.setSAMRadiusVisible(visible);
}
setSAMPerspective(playerID: number, allies: Set<number>): void {
this.renderer.setSAMPerspective(playerID, allies);
}
setSAMColorMode(mode: "perspective" | "owner"): void {
this.renderer.setSAMColorMode(mode);
}
setSAMAllianceClusters(clusters: Map<number, number>): void {
this.renderer.setSAMAllianceClusters(clusters);
}
// ---- Other ----
setFitZoomOnDoubleClick(v: boolean): void {
this.interaction.fitZoomOnDoubleClick = v;
}
setDefaultGridView(v: boolean): void {
this.interaction.setDefaultGridView(v);
}
setLocalPlayerID(id: number): void {
this.renderer.setLocalPlayerID(id);
this.interaction.setLocalPlayerID(id);
}
setPanSpeed(speed: number): void {
this.interaction.setPanSpeed(speed);
}
setZoomSpeed(speed: number): void {
this.interaction.setZoomSpeed(speed);
}
setHighlightOwner(ownerID: number): void {
this.renderer.setHighlightOwner(ownerID);
}
setHighlightStructureTypes(unitTypes: string[] | null): void {
this.renderer.setHighlightStructureTypes(unitTypes);
}
getSettings(): RenderSettings {
return this.renderer.getSettings();
}
get fps(): number {
return this.renderer.fps;
}
set onFrame(cb: ((ms: number) => void) | null) {
this.renderer.onFrame = cb;
}
set afterRender(cb: ((canvas: HTMLCanvasElement) => void) | null) {
this.renderer.afterRender = cb;
}
// ---- Lifecycle ----
dispose(): void {
this.interaction.dispose();
this.resizeObs?.disconnect();
this.resizeObs = null;
this.listeners.clear();
this.renderer.dispose();
}
}
+30
View File
@@ -0,0 +1,30 @@
export type { AttackRingInput } from "../types";
export { createDebugGui } from "./debug/index";
export type {
GameViewEventMap,
GameViewEventType,
MapPointerEvent,
MapScrollEvent,
RadialMenuItem,
RadialMenuSelectEvent,
} from "./events";
export { GameView } from "./game-view";
export { REPLAY_KEY_BINDINGS } from "./map-interaction";
export type { MapKeyBindings } from "./map-interaction";
export type { SpawnCenter } from "./passes/spawn-overlay-pass";
export { createRenderSettings, dumpSettings } from "./render-settings";
export type { RenderSettings } from "./render-settings";
export { deepAssign, deepDiff } from "./settings-utils";
export { buildTerrainRGBA, getPaletteSize } from "./utils/color-utils";
export { buildNukeTrajectory, samRange } from "./utils/nuke-trajectory";
export type { SAMInfo } from "./utils/nuke-trajectory";
// Re-export shared types used in the public API
export type {
NameEntry,
PlayerState,
PlayerStatic,
RendererConfig,
TilePair,
UnitState,
} from "../types";
+141
View File
@@ -0,0 +1,141 @@
/**
* KeyboardPan — WASD camera panning, Q/E smooth zoom, and C fit-zoom.
*
* Tracks held keys and runs a requestAnimationFrame loop while any
* direction or zoom key is pressed. All movement is frame-rate
* independent. Pan speed is zoom-adaptive (faster when zoomed out).
*
* Skips all input when the user is typing in an input/textarea.
*/
const WASD = new Set(["w", "a", "s", "d"]);
const ZOOM_KEYS = new Set(["q", "e"]);
const DEFAULT_PAN_SPEED = 800; // tiles per second at zoom = 1
const DEFAULT_ZOOM_SPEED = 2.0; // zoom multiplier per second (e.g. 2× per second held)
interface KeyboardPanDeps {
panBy(dx: number, dy: number): void;
zoomBy(factor: number): void;
focusOwner(ownerID: number): void;
fitMap(): void;
readonly zoom: number;
}
export class KeyboardPan {
private deps: KeyboardPanDeps;
private raf: typeof requestAnimationFrame;
private caf: typeof cancelAnimationFrame;
private held = new Set<string>();
private animId: number | null = null;
private lastTime = 0;
private localPlayerID = 0;
private panSpeed = DEFAULT_PAN_SPEED;
private zoomSpeed = DEFAULT_ZOOM_SPEED;
constructor(
deps: KeyboardPanDeps,
raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window),
caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window),
) {
this.deps = deps;
this.raf = raf;
this.caf = caf;
}
handleKeyDown(e: KeyboardEvent): boolean {
if (isTyping()) return false;
const key = e.key.toLowerCase();
if (key === "c" && !e.repeat) {
if (this.localPlayerID > 0) this.deps.focusOwner(this.localPlayerID);
else this.deps.fitMap();
return true;
}
if (WASD.has(key) || ZOOM_KEYS.has(key)) {
this.held.add(key);
if (this.animId === null) this.startLoop();
return true;
}
return false;
}
handleKeyUp(e: KeyboardEvent): boolean {
const key = e.key.toLowerCase();
if (WASD.has(key) || ZOOM_KEYS.has(key)) {
this.held.delete(key);
if (this.held.size === 0) this.stopLoop();
return true;
}
return false;
}
setLocalPlayerID(id: number): void {
this.localPlayerID = id;
}
setPanSpeed(speed: number): void {
this.panSpeed = speed;
}
setZoomSpeed(speed: number): void {
this.zoomSpeed = speed;
}
dispose(): void {
this.stopLoop();
this.held.clear();
}
// ---- Animation loop ----
private startLoop(): void {
this.lastTime = performance.now();
this.animId = this.raf(this.loop);
}
private stopLoop(): void {
if (this.animId !== null) {
this.caf(this.animId);
this.animId = null;
}
}
private loop = (): void => {
const now = performance.now();
const dt = Math.min((now - this.lastTime) / 1000, 0.1); // cap at 100ms
this.lastTime = now;
const speed = this.panSpeed / this.deps.zoom;
let dx = 0;
let dy = 0;
if (this.held.has("a")) dx -= speed * dt;
if (this.held.has("d")) dx += speed * dt;
if (this.held.has("w")) dy -= speed * dt;
if (this.held.has("s")) dy += speed * dt;
if (dx !== 0 || dy !== 0) this.deps.panBy(dx, dy);
// Q/E smooth zoom: compute multiplicative factor for this frame
let zoomDir = 0;
if (this.held.has("e")) zoomDir += 1;
if (this.held.has("q")) zoomDir -= 1;
if (zoomDir !== 0) {
const factor = this.zoomSpeed ** (zoomDir * dt);
this.deps.zoomBy(factor);
}
if (this.held.size > 0) this.animId = this.raf(this.loop);
else this.animId = null;
};
}
function isTyping(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
+418
View File
@@ -0,0 +1,418 @@
/**
* MapInteraction — handles all DOM pointer and keyboard events for GameView.
*
* Owns:
* - Drag state: dragging, lastX/Y, downX/Y
* - Menu hover state: menuHoveredSeg
* - Timing guards: lastMenuDismissAt, lastGhostClickAt
* - Ghost preview flag: hasGhostPreview
* - Alt-view flag: altView (affiliation recoloring, configurable hold key)
* - Grid-view flag: gridView (coordinate grid, configurable toggle key)
* - Hover tracking: lastHoverOwner, lastHoverUnitId, lastHoverStructureId, lastHoverTileX/Y
*
* All handler methods (pointerdown, pointermove, pointerup, keydown, keyup, wheel, contextmenu, auxclick, dblclick)
* are defined here and bound by GameView.
*/
import type {
GameViewEventMap,
GameViewEventType,
MapPointerEvent,
} from "./events";
import { KeyboardPan } from "./keyboard-pan";
import type { GPURenderer } from "./renderer";
const HIT_RADIUS_PX = 16;
const CLICK_THRESHOLD_SQ = 100;
/** Describes a hold-key binding (key held = active, released = inactive). */
export interface HoldKeyBinding {
/** KeyboardEvent.code to match (e.g. "Space", "KeyM"). */
code: string;
/** Require shift modifier. Default false. */
shift?: boolean;
}
/** Describes a toggle-key binding (each press toggles). */
export interface ToggleKeyBinding {
/** KeyboardEvent.key to match (e.g. "m", "g"). */
key: string;
}
/** Configurable keybindings for MapInteraction. */
export interface MapKeyBindings {
/** Hold to peek alt-view (affiliation recoloring) + grid. */
altViewPeek: HoldKeyBinding;
/** Toggle grid overlay on/off. */
gridToggle: ToggleKeyBinding;
}
/** Extension default: Space hold for altView peek, 'm' toggle for grid. */
export const DEFAULT_KEY_BINDINGS: MapKeyBindings = {
altViewPeek: { code: "Space" },
gridToggle: { key: "m" },
};
/** Replay default: Shift+M hold for altView peek, 'm' toggle for grid. */
export const REPLAY_KEY_BINDINGS: MapKeyBindings = {
altViewPeek: { code: "KeyG", shift: true },
gridToggle: { key: "g" },
};
interface InteractionDeps {
renderer: GPURenderer;
emit: <K extends GameViewEventType>(
event: K,
payload: GameViewEventMap[K],
) => void;
raf: typeof requestAnimationFrame;
caf: typeof cancelAnimationFrame;
keyBindings?: MapKeyBindings;
}
export class MapInteraction {
private deps: InteractionDeps;
private keys: MapKeyBindings;
// Drag state
private dragging = false;
private lastX = 0;
private lastY = 0;
private downX = 0;
private downY = 0;
// Hover tracking
private lastHoverOwner = 0;
private lastHoverUnitId: number | null = null;
private lastHoverStructureId: number | null = null;
private lastHoverTileX = -1;
private lastHoverTileY = -1;
// Timing guards
private hasGhostPreview = false;
private lastGhostClickAt = 0;
private lastMenuDismissAt = 0;
// Menu hover
private menuHoveredSeg = -1;
// Grid-view: coordinate grid overlay. Toggled by configured key, persisted.
private gridViewBase = false;
private gridView = false;
// Alt-view: affiliation recoloring (no persistent toggle).
private altView = false;
// Alt-view peek hold state.
private peekHeld = false;
// Interaction settings (mutable — updated live by extension)
fitZoomOnDoubleClick = true;
// Keyboard camera control (WASD pan + C fit-zoom)
private keyboardPan: KeyboardPan;
constructor(deps: InteractionDeps) {
this.deps = deps;
this.keys = deps.keyBindings ?? DEFAULT_KEY_BINDINGS;
this.keyboardPan = new KeyboardPan(deps.renderer, deps.raf, deps.caf);
}
// ---- Pointer event handlers ----
handlePointerDown(e: PointerEvent): void {
if (e.button !== 0) return;
// If radial menu is open, clicking outside dismisses it
if (this.deps.renderer.radialMenuVisible) {
const hit = this.deps.renderer.radialMenuHitTest(e.clientX, e.clientY);
if (hit === -1) {
this.deps.renderer.hideRadialMenu();
this.menuHoveredSeg = -1;
this.lastMenuDismissAt = performance.now();
}
return; // consume the event either way — don't start dragging
}
if (this.hasGhostPreview) this.lastGhostClickAt = performance.now();
this.dragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.downX = e.clientX;
this.downY = e.clientY;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
handlePointerMove(e: PointerEvent): void {
// Update radial menu hover
if (this.deps.renderer.radialMenuVisible) {
const hit = this.deps.renderer.radialMenuHitTest(e.clientX, e.clientY);
if (hit !== this.menuHoveredSeg) {
this.menuHoveredSeg = hit;
this.deps.renderer.setRadialMenuHover(hit);
}
return; // don't pan or update game hover while menu is open
}
if (this.dragging) {
const dx = e.clientX - this.lastX;
const dy = e.clientY - this.lastY;
this.lastX = e.clientX;
this.lastY = e.clientY;
const dpr = window.devicePixelRatio || 1;
this.deps.renderer.panBy(
-(dx * dpr) / this.deps.renderer.zoom,
-(dy * dpr) / this.deps.renderer.zoom,
);
return;
}
this.updateHover(e);
}
handlePointerUp(e: PointerEvent): void {
if (e.button !== 0) return;
// If radial menu is open, a click on a segment or center selects it.
// Don't hide the menu here — the menuselect handler decides whether to
// close or open a submenu.
if (this.deps.renderer.radialMenuVisible) {
if (this.menuHoveredSeg !== -1) {
const item = this.deps.renderer.getRadialMenuItemAt(
this.menuHoveredSeg,
);
if (item && item.enabled) {
this.deps.emit("menuselect", {
index: this.menuHoveredSeg,
id: item.id,
});
}
if (!this.deps.renderer.radialMenuVisible) {
this.lastMenuDismissAt = performance.now();
}
this.menuHoveredSeg = -1;
}
return;
}
if (!this.dragging) return;
this.dragging = false;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
const dx = e.clientX - this.downX;
const dy = e.clientY - this.downY;
if (dx * dx + dy * dy < CLICK_THRESHOLD_SQ) {
this.deps.emit("click", this.buildEvent(e, 0));
}
}
// ---- Keyboard event handlers ----
handleKeyDown(e: KeyboardEvent): void {
if (e.key === "Escape" && this.deps.renderer.radialMenuVisible) {
this.deps.renderer.hideRadialMenu();
this.menuHoveredSeg = -1;
this.lastMenuDismissAt = performance.now();
}
if (
this.matchesHold(e, this.keys.altViewPeek) &&
!e.repeat &&
!this.peekHeld
) {
e.preventDefault();
this.peekHeld = true;
this.applyAltView(true);
this.applyGridView(true);
this.deps.emit("altviewpeek", { active: true });
}
if (e.key === this.keys.gridToggle.key && !e.shiftKey && !e.repeat) {
this.gridViewBase = !this.gridViewBase;
this.applyGridView(this.gridViewBase);
this.deps.emit("gridviewtoggle", { active: this.gridViewBase });
}
this.keyboardPan.handleKeyDown(e);
}
handleKeyUp(e: KeyboardEvent): void {
if (e.code === this.keys.altViewPeek.code && this.peekHeld) {
e.preventDefault();
this.peekHeld = false;
this.applyAltView(false);
this.applyGridView(this.gridViewBase);
this.deps.emit("altviewpeek", { active: false });
}
this.keyboardPan.handleKeyUp(e);
}
private matchesHold(e: KeyboardEvent, binding: HoldKeyBinding): boolean {
return e.code === binding.code && (!binding.shift || e.shiftKey);
}
// ---- Other event handlers ----
handleWheel(e: WheelEvent): void {
e.preventDefault();
if (e.shiftKey || e.ctrlKey || e.altKey) {
this.deps.emit("scroll", {
deltaX: e.deltaX,
deltaY: e.deltaY,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
});
return;
}
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
this.deps.renderer.zoomAtScreen(factor, e.clientX, e.clientY);
}
handleContextMenu(e: MouseEvent): void {
e.preventDefault();
// Dismiss any open menu first — the external manager will decide whether to reopen
if (this.deps.renderer.radialMenuVisible) {
this.deps.renderer.hideRadialMenu();
this.menuHoveredSeg = -1;
this.lastMenuDismissAt = performance.now();
}
this.deps.emit("contextmenu", this.buildEvent(e, 2));
}
handleAuxClick(e: MouseEvent): void {
if (e.button !== 1) return;
e.preventDefault();
this.deps.emit("middleclick", this.buildEvent(e, 1));
}
handleDblClick(e: MouseEvent): void {
// Suppress fitzoom if menu is open or was recently open
if (this.deps.renderer.radialMenuVisible) return;
const now = performance.now();
if (now - this.lastMenuDismissAt < 500) return;
const evt = this.buildEvent(e, 0);
if (this.fitZoomOnDoubleClick && now - this.lastGhostClickAt > 500) {
if (evt.ownerID !== 0) this.deps.renderer.focusOwner(evt.ownerID);
else this.deps.renderer.fitMap();
}
this.deps.emit("dblclick", evt);
}
// ---- Hover tracking ----
private updateHover(e: PointerEvent): void {
const world = this.deps.renderer.screenToWorld(e.clientX, e.clientY);
const tileX = Math.floor(world.x);
const tileY = Math.floor(world.y);
const ownerID = this.deps.renderer.getOwnerAtWorld(world.x, world.y);
const hitRadius = HIT_RADIUS_PX / this.deps.renderer.zoom;
const unit = this.deps.renderer.getUnitAtWorld(world.x, world.y, hitRadius);
const structure = this.deps.renderer.getStructureAtWorld(
world.x,
world.y,
hitRadius,
);
const unitId = unit?.id ?? null;
const structureId = structure?.id ?? null;
if (
ownerID !== this.lastHoverOwner ||
unitId !== this.lastHoverUnitId ||
structureId !== this.lastHoverStructureId ||
tileX !== this.lastHoverTileX ||
tileY !== this.lastHoverTileY
) {
this.lastHoverOwner = ownerID;
this.lastHoverUnitId = unitId;
this.lastHoverStructureId = structureId;
this.lastHoverTileX = tileX;
this.lastHoverTileY = tileY;
this.deps.renderer.setHighlightOwner(ownerID);
this.deps.emit("hover", {
screenX: e.clientX,
screenY: e.clientY,
worldX: world.x,
worldY: world.y,
tileX,
tileY,
ownerID,
unit,
structure,
button: 0,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
altKey: e.altKey,
});
}
}
private buildEvent(e: MouseEvent, button: number): MapPointerEvent {
const world = this.deps.renderer.screenToWorld(e.clientX, e.clientY);
const hitRadius = HIT_RADIUS_PX / this.deps.renderer.zoom;
return {
screenX: e.clientX,
screenY: e.clientY,
worldX: world.x,
worldY: world.y,
tileX: Math.floor(world.x),
tileY: Math.floor(world.y),
ownerID: this.deps.renderer.getOwnerAtWorld(world.x, world.y),
unit: this.deps.renderer.getUnitAtWorld(world.x, world.y, hitRadius),
structure: this.deps.renderer.getStructureAtWorld(
world.x,
world.y,
hitRadius,
),
button,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
altKey: e.altKey,
};
}
// ---- View helpers ----
private applyAltView(active: boolean): void {
if (active === this.altView) return;
this.altView = active;
this.deps.renderer.setAltView(active);
}
private applyGridView(active: boolean): void {
if (active === this.gridView) return;
this.gridView = active;
this.deps.renderer.setGridView(active);
}
// ---- Public API ----
setDefaultGridView(v: boolean): void {
this.gridViewBase = v;
if (!this.peekHeld) this.applyGridView(v);
}
setHasGhostPreview(v: boolean): void {
this.hasGhostPreview = v;
}
getMenuHoveredSeg(): number {
return this.menuHoveredSeg;
}
setMenuHoveredSeg(v: number): void {
this.menuHoveredSeg = v;
}
setLocalPlayerID(id: number): void {
this.keyboardPan.setLocalPlayerID(id);
}
setPanSpeed(speed: number): void {
this.keyboardPan.setPanSpeed(speed);
}
setZoomSpeed(speed: number): void {
this.keyboardPan.setZoomSpeed(speed);
}
dispose(): void {
this.keyboardPan.dispose();
}
}
+262
View File
@@ -0,0 +1,262 @@
/**
* BarPass — instanced health/progress bars above units and below structures.
*
* Two draw calls per frame:
* 1. Health bars (11x3 tiles, above warships)
* 2. Progress bars (14x3 tiles, below structures — construction + missile readiness)
*
* Data flow:
* UnitState.health / .missileTimerQueue / .constructionStartTick → CPU progress
* → instance VBO (x, y, progress) → GPU colored rectangle
*/
import {
CONSTRUCTION_DURATIONS,
DELETION_MARK_DURATION,
missileReadiness,
WARSHIP_MAX_HEALTH,
} from "../../game-constants";
import type { RendererConfig, UnitState } from "../../types";
import { UT_MISSILE_SILO, UT_SAM_LAUNCHER } from "../../types";
import type { RenderSettings } from "../render-settings";
import { createProgram } from "../utils/gl-utils";
import barFragSrc from "../shaders/bar/bar.frag.glsl?raw";
import barVertSrc from "../shaders/bar/bar.vert.glsl?raw";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FLOATS_PER_INSTANCE = 3; // x, y, progress
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
// ---------------------------------------------------------------------------
// BarPass
// ---------------------------------------------------------------------------
export class BarPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private maxBars = 2048;
private uCamera: WebGLUniformLocation;
private uBarSize: WebGLUniformLocation;
private uBarOffset: WebGLUniformLocation;
private uBorderWidth: WebGLUniformLocation;
private uThresholds: WebGLUniformLocation;
private uColorRed: WebGLUniformLocation;
private uColorOrange: WebGLUniformLocation;
private uColorYellow: WebGLUniformLocation;
private uColorGreen: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: WebGLBuffer;
private healthData: Float32Array;
private healthCount = 0;
private progressData: Float32Array;
private progressCount = 0;
private mapW: number;
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = header.mapWidth;
// --- Shader program ---
this.program = createProgram(gl, barVertSrc, barFragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uBarSize = gl.getUniformLocation(this.program, "uBarSize")!;
this.uBarOffset = gl.getUniformLocation(this.program, "uBarOffset")!;
this.uBorderWidth = gl.getUniformLocation(this.program, "uBorderWidth")!;
this.uThresholds = gl.getUniformLocation(this.program, "uThresholds")!;
this.uColorRed = gl.getUniformLocation(this.program, "uColorRed")!;
this.uColorOrange = gl.getUniformLocation(this.program, "uColorOrange")!;
this.uColorYellow = gl.getUniformLocation(this.program, "uColorYellow")!;
this.uColorGreen = gl.getUniformLocation(this.program, "uColorGreen")!;
// --- Instance data buffers (CPU-side) ---
this.healthData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);
this.progressData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE);
// --- VAO: unit quad + instanced data ---
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Quad vertices (2 triangles)
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Instance buffer (dynamic)
this.instanceBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
this.maxBars * BYTES_PER_INSTANCE,
gl.DYNAMIC_DRAW,
);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribDivisor(1, 1);
gl.bindVertexArray(null);
}
/** Rebuild bar instance data from current unit state. */
updateBars(
mobileUnits: Map<number, UnitState>,
structures: Map<number, UnitState>,
gameTick: number,
): void {
this.healthCount = 0;
this.progressCount = 0;
// --- Health bars (warships) ---
for (const unit of mobileUnits.values()) {
if (
unit.health === null ||
unit.health <= 0 ||
unit.health >= WARSHIP_MAX_HEALTH
)
continue;
this.pushHealth(unit, unit.health / WARSHIP_MAX_HEALTH);
}
// --- Progress bars (structures) ---
for (const unit of structures.values()) {
const progress = this.computeStructureProgress(unit, gameTick);
if (progress !== null) this.pushProgress(unit, progress);
}
}
/** Render bars. Call once per frame after FX, before names. */
draw(cameraMat: Float32Array): void {
if (this.healthCount === 0 && this.progressCount === 0) return;
const gl = this.gl;
const b = this.settings.bar;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMat);
gl.uniform1f(this.uBorderWidth, b.borderWidth);
gl.uniform3f(this.uThresholds, b.threshold1, b.threshold2, b.threshold3);
gl.uniform3f(this.uColorRed, b.colorRedR, b.colorRedG, b.colorRedB);
gl.uniform3f(
this.uColorOrange,
b.colorOrangeR,
b.colorOrangeG,
b.colorOrangeB,
);
gl.uniform3f(
this.uColorYellow,
b.colorYellowR,
b.colorYellowG,
b.colorYellowB,
);
gl.uniform3f(this.uColorGreen, b.colorGreenR, b.colorGreenG, b.colorGreenB);
gl.bindVertexArray(this.vao);
// Health bars
if (this.healthCount > 0) {
gl.uniform2f(this.uBarSize, b.healthBarW, b.healthBarH);
gl.uniform2f(this.uBarOffset, -b.healthBarW / 2, b.healthBarOffsetY);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.healthData.subarray(0, this.healthCount * FLOATS_PER_INSTANCE),
);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.healthCount);
}
// Progress bars
if (this.progressCount > 0) {
gl.uniform2f(this.uBarSize, b.progressBarW, b.progressBarH);
gl.uniform2f(this.uBarOffset, -b.progressBarW / 2, b.progressBarOffsetY);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.progressData.subarray(0, this.progressCount * FLOATS_PER_INSTANCE),
);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.progressCount);
}
gl.bindVertexArray(null);
}
dispose(): void {
this.gl.deleteProgram(this.program);
this.gl.deleteBuffer(this.instanceBuf);
this.gl.deleteVertexArray(this.vao);
}
// ---- Private ----
private pushHealth(unit: UnitState, progress: number): void {
if (this.healthCount >= this.maxBars) return;
const off = this.healthCount * FLOATS_PER_INSTANCE;
this.healthData[off] = unit.pos % this.mapW;
this.healthData[off + 1] = (unit.pos - this.healthData[off]) / this.mapW;
this.healthData[off + 2] = progress;
this.healthCount++;
}
private pushProgress(unit: UnitState, progress: number): void {
if (this.progressCount >= this.maxBars) return;
const off = this.progressCount * FLOATS_PER_INSTANCE;
const x = unit.pos % this.mapW;
this.progressData[off] = x;
this.progressData[off + 1] = (unit.pos - x) / this.mapW;
this.progressData[off + 2] = progress;
this.progressCount++;
}
private computeStructureProgress(
unit: UnitState,
gameTick: number,
): number | null {
// Deletion progress (reverse countdown — takes priority over other bars)
if (unit.markedForDeletion !== false) {
const remaining = unit.markedForDeletion - gameTick;
return Math.max(0, Math.min(1, remaining / DELETION_MARK_DURATION));
}
// Construction progress
if (unit.underConstruction && unit.constructionStartTick !== null) {
const duration = CONSTRUCTION_DURATIONS[unit.unitType] ?? 50;
const elapsed = gameTick - unit.constructionStartTick;
return Math.min(1, Math.max(0, elapsed / duration));
}
// Missile readiness (Silo / SAM)
if (
unit.unitType === UT_MISSILE_SILO ||
unit.unitType === UT_SAM_LAUNCHER
) {
const readiness = missileReadiness(
unit.unitType,
unit.level,
unit.missileTimerQueue,
gameTick,
);
if (readiness < 1) return readiness;
}
return null;
}
}
@@ -0,0 +1,265 @@
/**
* BorderComputePass — tile-resolution pass that computes per-tile border flags.
*
* Runs a fullscreen quad at tile resolution (mapW × mapH) and writes to an
* RGBA8 texture:
* R = border type: 0 = interior, 0.5 = normal border, 1.0 = highlight border
* G = ember intensity: 0255 (pre-computed flicker value, 0 = no ember)
* B = defense proximity: 1.0 if border tile is within range of same-owner defense post
*
* Both MapOverlayPass (daytime) and the night stamp overlay read this buffer
* instead of independently computing neighbor checks. Border thickening is
* computed once here via an N-tile Chebyshev radius expansion.
*/
import type { RenderSettings } from "../render-settings";
import borderComputeFragSrc from "../shaders/border-compute/border-compute.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
import {
createFullscreenQuad,
createProgram,
createTexture2D,
shaderSrc,
} from "../utils/gl-utils";
import { TILE_DEFINES } from "../utils/tile-codec";
const MAX_DEFENSE_POSTS = 64;
/** Max player smallID supported by the relationship texture. */
const RELATION_TEX_SIZE = 1024;
// ---------------------------------------------------------------------------
// BorderComputePass
// ---------------------------------------------------------------------------
export class BorderComputePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private borderTex: WebGLTexture;
private borderFbo: WebGLFramebuffer;
private mapW: number;
private mapH: number;
private relationTex: WebGLTexture;
private uMapSize: WebGLUniformLocation;
private uHighlightOwner: WebGLUniformLocation;
private uHighlightThicken: WebGLUniformLocation;
private uTick: WebGLUniformLocation;
private uEmberThresholdUnowned: WebGLUniformLocation;
private uEmberThresholdOwned: WebGLUniformLocation;
private uEmberFlickerSpeed: WebGLUniformLocation;
private uDefensePosts: WebGLUniformLocation;
private uDefensePostCount: WebGLUniformLocation;
private uDefensePostRange: WebGLUniformLocation;
private highlightOwner = 0;
/** True when any input has changed since last draw. Starts true so first frame computes. */
private dirty = true;
/** Packed defense post data: [x, y, ownerID, 0, x, y, ownerID, 0, ...] */
private defensePostData = new Float32Array(MAX_DEFENSE_POSTS * 4);
private defensePostCount = 0;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.program = createProgram(
gl,
fullscreenNoUvVertSrc,
shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES, MAX_DEFENSE_POSTS }),
);
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uHighlightOwner = gl.getUniformLocation(
this.program,
"uHighlightOwner",
)!;
this.uHighlightThicken = gl.getUniformLocation(
this.program,
"uHighlightThicken",
)!;
this.uTick = gl.getUniformLocation(this.program, "uTick")!;
this.uEmberThresholdUnowned = gl.getUniformLocation(
this.program,
"uEmberThresholdUnowned",
)!;
this.uEmberThresholdOwned = gl.getUniformLocation(
this.program,
"uEmberThresholdOwned",
)!;
this.uEmberFlickerSpeed = gl.getUniformLocation(
this.program,
"uEmberFlickerSpeed",
)!;
this.uDefensePosts = gl.getUniformLocation(this.program, "uDefensePosts")!;
this.uDefensePostCount = gl.getUniformLocation(
this.program,
"uDefensePostCount",
)!;
this.uDefensePostRange = gl.getUniformLocation(
this.program,
"uDefensePostRange",
)!;
// Texture unit binding
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uRelationTex"), 1);
// --- Relationship texture (R8UI, RELATION_TEX_SIZE × RELATION_TEX_SIZE) ---
this.relationTex = createTexture2D(gl, {
width: RELATION_TEX_SIZE,
height: RELATION_TEX_SIZE,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.NEAREST,
});
// --- RGBA8 border buffer at tile resolution ---
// R = border type, G = ember intensity, B = defense proximity flag
this.borderTex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.RGBA8,
format: gl.RGBA,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.NEAREST,
});
// FBO
this.borderFbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.borderFbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.borderTex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// Fullscreen quad VAO [0,1]
this.vao = createFullscreenQuad(gl);
// Store tileTex reference for binding
this._tileTex = tileTex;
}
private _tileTex: WebGLTexture;
/** Set the highlighted player's ownerID (0 = no highlight). */
setHighlightOwner(ownerID: number): void {
if (ownerID === this.highlightOwner) return;
this.highlightOwner = ownerID;
this.dirty = true;
}
/**
* Upload a relationship matrix (R8UI, size × size).
* Values: 0 = neutral, 1 = friendly, 2 = embargo.
* Indexed by [ownerA, ownerB]. Size must be ≤ RELATION_TEX_SIZE.
*/
updateRelations(data: Uint8Array, size: number): void {
const gl = this.gl;
const s = Math.min(size, RELATION_TEX_SIZE);
gl.bindTexture(gl.TEXTURE_2D, this.relationTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
s,
s,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
data,
);
this.dirty = true;
}
/** Update defense post positions for checkerboard proximity. */
updateDefensePosts(posts: { x: number; y: number; ownerID: number }[]): void {
const count = Math.min(posts.length, MAX_DEFENSE_POSTS);
const data = this.defensePostData;
for (let i = 0; i < count; i++) {
const p = posts[i];
const off = i * 4;
data[off] = p.x;
data[off + 1] = p.y;
data[off + 2] = p.ownerID;
data[off + 3] = 0;
}
this.defensePostCount = count;
this.dirty = true;
}
/** Notify that the tile texture has been updated (ownership may have changed). */
notifyTilesChanged(): void {
this.dirty = true;
}
/** The border buffer texture (RG8, tile resolution). */
getBorderTex(): WebGLTexture {
return this.borderTex;
}
/**
* Compute border flags for the current frame. Call before MapOverlayPass and stamp overlay.
* Leaves the GL state with its own FBO bound — caller must restore FBO and viewport.
*/
draw(tick: number): void {
if (!this.dirty) return;
this.dirty = false;
const gl = this.gl;
const mo = this.settings.mapOverlay;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.borderFbo);
gl.viewport(0, 0, this.mapW, this.mapH);
gl.disable(gl.BLEND);
gl.useProgram(this.program);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1ui(this.uHighlightOwner, this.highlightOwner);
gl.uniform1i(this.uHighlightThicken, Math.floor(mo.highlightThicken));
gl.uniform1f(this.uTick, tick);
gl.uniform1f(this.uEmberThresholdUnowned, mo.emberThresholdUnowned);
gl.uniform1f(this.uEmberThresholdOwned, mo.emberThresholdOwned);
gl.uniform1f(this.uEmberFlickerSpeed, mo.emberFlickerSpeed);
gl.uniform4fv(this.uDefensePosts, this.defensePostData);
gl.uniform1i(this.uDefensePostCount, this.defensePostCount);
gl.uniform1f(this.uDefensePostRange, mo.defensePostRange);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._tileTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.relationTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteTexture(this.borderTex);
gl.deleteTexture(this.relationTex);
gl.deleteFramebuffer(this.borderFbo);
}
}
@@ -0,0 +1,162 @@
/**
* BorderStampPass — territory borders + defense checkerboard + embers.
*
* Always draws at full brightness (after the optional night composite).
* Reads pre-computed border flags, ember intensity, and defense proximity
* from the BorderComputePass RGBA8 buffer.
*/
import type { RenderSettings } from "../render-settings";
import { getPaletteSize } from "../utils/color-utils";
import { createMapQuad, createProgram, shaderSrc } from "../utils/gl-utils";
import { TILE_DEFINES } from "../utils/tile-codec";
import borderStampFragSrc from "../shaders/day-night/border-stamp.frag.glsl?raw";
import borderStampVertSrc from "../shaders/day-night/border-stamp.vert.glsl?raw";
export class BorderStampPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private program: WebGLProgram;
private uCam: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uHighlightBrighten: WebGLUniformLocation;
private uDefenseCheckerDarken: WebGLUniformLocation;
private uEmbargoTintRatio: WebGLUniformLocation;
private uFriendlyTintRatio: WebGLUniformLocation;
private uEmberColorDark: WebGLUniformLocation;
private uEmberColorBright: WebGLUniformLocation;
private uEmberStrengthUnowned: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private tileTex: WebGLTexture;
private paletteTex: WebGLTexture;
private borderTex: WebGLTexture;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
paletteTex: WebGLTexture,
borderTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.paletteTex = paletteTex;
this.borderTex = borderTex;
this.program = createProgram(
gl,
borderStampVertSrc,
shaderSrc(borderStampFragSrc, {
PALETTE_SIZE: getPaletteSize(),
...TILE_DEFINES,
}),
);
this.uCam = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uHighlightBrighten = gl.getUniformLocation(
this.program,
"uHighlightBrighten",
)!;
this.uDefenseCheckerDarken = gl.getUniformLocation(
this.program,
"uDefenseCheckerDarken",
)!;
this.uEmbargoTintRatio = gl.getUniformLocation(
this.program,
"uEmbargoTintRatio",
)!;
this.uFriendlyTintRatio = gl.getUniformLocation(
this.program,
"uFriendlyTintRatio",
)!;
this.uEmberColorDark = gl.getUniformLocation(
this.program,
"uEmberColorDark",
)!;
this.uEmberColorBright = gl.getUniformLocation(
this.program,
"uEmberColorBright",
)!;
this.uEmberStrengthUnowned = gl.getUniformLocation(
this.program,
"uEmberStrengthUnowned",
)!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uBorderTex"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 3);
this.vao = createMapQuad(gl, mapW, mapH);
}
setAltView(active: boolean): void {
this.altView = active;
}
setAffiliationTex(tex: WebGLTexture): void {
this.affiliationTex = tex;
}
/** Draw borders + defense checkerboard + embers. Blending must be enabled. */
draw(cameraMatrix: Float32Array): void {
const gl = this.gl;
const mo = this.settings.mapOverlay;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCam, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uHighlightBrighten, mo.highlightBrighten);
gl.uniform1f(this.uDefenseCheckerDarken, mo.defenseCheckerDarken);
gl.uniform1f(this.uEmbargoTintRatio, mo.embargoTintRatio);
gl.uniform1f(this.uFriendlyTintRatio, mo.friendlyTintRatio);
gl.uniform3f(
this.uEmberColorDark,
mo.emberColorDarkR,
mo.emberColorDarkG,
mo.emberColorDarkB,
);
gl.uniform3f(
this.uEmberColorBright,
mo.emberColorBrightR,
mo.emberColorBrightG,
mo.emberColorBrightB,
);
gl.uniform1f(this.uEmberStrengthUnowned, mo.emberStrengthUnowned);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.borderTex);
if (this.affiliationTex) {
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,412 @@
/**
* ConquestPopupPass — MSDF-rendered floating text popups.
*
* Renders two kinds of popups using the same MSDF atlas as NamePass:
* - Conquest popups: "+ 500" gold text at conquered player locations (static position, fade only)
* - Bonus popups: "+ 45K" income text at port tiles (rises upward + fades)
*/
import type { BonusEvent, ConquestFx } from "../../types";
import type { RenderSettings } from "../render-settings";
import { createProgram } from "../utils/gl-utils";
import type { GlyphTables } from "./name-pass/atlas-data";
import { buildGlyphTables, parseAtlasData } from "./name-pass/atlas-data";
import { buildGlyphMetricsTex } from "./name-pass/data-textures";
import { layoutString } from "./name-pass/text-layout";
import { CHAR_RANGE, MAX_CHARS } from "./name-pass/types";
import atlasUrl from "../assets/msdf-atlas.png?url";
import fragSrc from "../shaders/conquest-popup/conquest-popup.frag.glsl?raw";
import vertSrc from "../shaders/conquest-popup/conquest-popup.vert.glsl?raw";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// worldX, worldY, cursorX, charCode, alpha, colorR, colorG, colorB, scale, outlineWidth
const FLOATS_PER_INSTANCE = 10;
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
const CONQUEST_LIFETIME_MS = 2500;
/** Nominal game tick rate — 100ms per tick. */
const MS_PER_TICK = 100;
/** Tiles below conquered name location (matches upstream DynamicUILayer). */
const CONQUEST_Y_OFFSET = 8;
/** World-space font size for conquest popups. */
const CONQUEST_SCALE = 6;
const CONQUEST_OUTLINE_WIDTH = 2.0;
// ---------------------------------------------------------------------------
// Active popup tracking
// ---------------------------------------------------------------------------
interface ActivePopup {
x: number;
y: number;
text: string;
startMs: number;
lifetimeMs: number;
riseSpeed: number; // world units per second (0 = no rise)
colorR: number;
colorG: number;
colorB: number;
scale: number;
outlineWidth: number;
}
function formatGold(gold: number): string {
if (gold >= 1_000_000) return (gold / 1_000_000).toFixed(1) + "M";
if (gold >= 1_000) return (gold / 1_000).toFixed(1) + "K";
return gold.toString();
}
// ---------------------------------------------------------------------------
// ConquestPopupPass
// ---------------------------------------------------------------------------
export class ConquestPopupPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private maxInstances = 512;
// Uniform locations
private uCamera: WebGLUniformLocation;
private uZoom: WebGLUniformLocation;
private uMinScreenScale: WebGLUniformLocation;
private uDistRange: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: WebGLBuffer;
private instanceData: Float32Array;
private instanceCount = 0;
private glyphMetricsTex: WebGLTexture;
private atlasTex: WebGLTexture | null = null;
private atlasReady = false;
// CPU-side glyph tables for layoutString
private glyph: GlyphTables;
private kernTable: Int8Array;
// Reusable buffers for layoutString
private charCodes = new Uint8Array(MAX_CHARS);
private cursors = new Float32Array(MAX_CHARS);
private distanceRange: number;
private fontSize: number;
private atlasScaleH: number;
private base: number;
// Active popups (both conquest and bonus, unified)
private active: ActivePopup[] = [];
// Settings reference
private settings: RenderSettings;
// Map width for tile→x/y conversion
private mapW = 0;
// Pluggable time source (same pattern as FxPass)
private timeFn: () => number = () => performance.now();
private now(): number {
return this.timeFn();
}
constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
this.gl = gl;
this.settings = settings;
// Parse atlas data (shared with NamePass/StructureLevelPass)
const atlas = parseAtlasData();
this.glyph = buildGlyphTables(atlas.chars);
this.kernTable = new Int8Array(CHAR_RANGE * CHAR_RANGE);
this.distanceRange = atlas.distanceRange;
this.fontSize = atlas.fontSize;
this.atlasScaleH = atlas.scaleH;
this.base = atlas.base;
// Compile shaders
this.program = createProgram(gl, vertSrc, fragSrc);
// Texture unit bindings
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1);
// Static uniforms
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
this.fontSize,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uAtlasScaleH")!,
this.atlasScaleH,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, this.base);
// Dynamic uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uZoom = gl.getUniformLocation(this.program, "uZoom")!;
this.uMinScreenScale = gl.getUniformLocation(
this.program,
"uMinScreenScale",
)!;
this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!;
// Glyph metrics data texture
this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas);
// Start async MSDF atlas load
this.loadAtlas();
// Instance buffer
this.instanceData = new Float32Array(
this.maxInstances * FLOATS_PER_INSTANCE,
);
this.instanceBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
this.instanceData.byteLength,
gl.DYNAMIC_DRAW,
);
// VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Attribute 0: unit quad [0,1]²
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Per-instance attributes from instance buffer
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
// Attribute 1: vec4 (worldX, worldY, cursorX, charCode) at offset 0
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: vec4 (alpha, colorR, colorG, colorB) at offset 16
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 16);
gl.vertexAttribDivisor(2, 1);
// Attribute 3: vec2 (scale, outlineWidth) at offset 32
gl.enableVertexAttribArray(3);
gl.vertexAttribPointer(3, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 32);
gl.vertexAttribDivisor(3, 1);
gl.bindVertexArray(null);
}
private loadAtlas(): void {
const img = new Image();
img.onload = () => {
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
this.atlasTex = tex;
this.atlasReady = true;
};
img.src = atlasUrl;
}
setMapWidth(w: number): void {
this.mapW = w;
}
// -------------------------------------------------------------------------
// Event input
// -------------------------------------------------------------------------
applyConquestEvents(events: ConquestFx[]): void {
const now = this.now();
for (const evt of events) {
const startMs = now - (evt.tickAge ?? 0) * MS_PER_TICK;
if (now - startMs >= CONQUEST_LIFETIME_MS) continue;
this.active.push({
x: evt.x,
y: evt.y + CONQUEST_Y_OFFSET,
text: "+ " + formatGold(evt.gold),
startMs,
lifetimeMs: CONQUEST_LIFETIME_MS,
riseSpeed: 0,
colorR: 1,
colorG: 1,
colorB: 1,
scale: CONQUEST_SCALE,
outlineWidth: CONQUEST_OUTLINE_WIDTH,
});
}
}
applyBonusEvents(events: BonusEvent[]): void {
if (this.mapW === 0) return;
const now = this.now();
const s = this.settings.bonusPopup;
for (const evt of events) {
if (evt.gold === 0) continue;
const x = evt.tile % this.mapW;
const y = Math.floor(evt.tile / this.mapW);
const sign = evt.gold >= 0 ? "+" : "-";
this.active.push({
x,
y: y + s.yOffset,
text: sign + " " + formatGold(Math.abs(evt.gold)),
startMs: now,
lifetimeMs: s.lifetimeMs,
riseSpeed: s.riseSpeed,
colorR: s.colorR,
colorG: s.colorG,
colorB: s.colorB,
scale: s.scale,
outlineWidth: s.outlineWidth,
});
}
}
// -------------------------------------------------------------------------
// Tick — cull expired, rebuild instance buffer
// -------------------------------------------------------------------------
tick(): void {
if (this.active.length === 0) return;
const now = this.now();
// Remove expired popups (swap-remove)
for (let i = this.active.length - 1; i >= 0; i--) {
if (now - this.active[i].startMs >= this.active[i].lifetimeMs) {
this.active[i] = this.active[this.active.length - 1];
this.active.pop();
}
}
this.rebuildInstances(now);
}
private rebuildInstances(now: number): void {
let count = 0;
for (const popup of this.active) {
const elapsed = now - popup.startMs;
const alpha = Math.max(0, 1 - elapsed / popup.lifetimeMs);
if (alpha <= 0) continue;
// Rise animation: move upward over time
const riseY =
popup.riseSpeed > 0
? popup.y - (elapsed / 1000) * popup.riseSpeed
: popup.y;
layoutString(
popup.text,
this.glyph,
this.kernTable,
this.charCodes,
this.cursors,
);
const len = Math.min(popup.text.length, MAX_CHARS);
for (let i = 0; i < len; i++) {
if (this.charCodes[i] === 0) continue;
if (count >= this.maxInstances) {
this.growBuffer();
}
const off = count * FLOATS_PER_INSTANCE;
this.instanceData[off + 0] = popup.x;
this.instanceData[off + 1] = riseY;
this.instanceData[off + 2] = this.cursors[i];
this.instanceData[off + 3] = this.charCodes[i];
this.instanceData[off + 4] = alpha;
this.instanceData[off + 5] = popup.colorR;
this.instanceData[off + 6] = popup.colorG;
this.instanceData[off + 7] = popup.colorB;
this.instanceData[off + 8] = popup.scale;
this.instanceData[off + 9] = popup.outlineWidth;
count++;
}
}
this.instanceCount = count;
}
private growBuffer(): void {
this.maxInstances *= 2;
const newData = new Float32Array(this.maxInstances * FLOATS_PER_INSTANCE);
newData.set(this.instanceData);
this.instanceData = newData;
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
this.instanceData.byteLength,
gl.DYNAMIC_DRAW,
);
}
// -------------------------------------------------------------------------
// Draw
// -------------------------------------------------------------------------
draw(cameraMatrix: Float32Array, zoom: number): void {
if (!this.atlasReady || this.instanceCount === 0) return;
if (zoom < this.settings.bonusPopup.cullZoom) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uZoom, zoom);
gl.uniform1f(this.uMinScreenScale, this.settings.bonusPopup.minScreenScale);
gl.uniform1f(this.uDistRange, this.distanceRange);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.glyphMetricsTex);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceData,
0,
this.instanceCount * FLOATS_PER_INSTANCE,
);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount);
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/** Override the time source. Default: performance.now (wall clock). */
setTimeFn(fn: () => number): void {
this.timeFn = fn;
}
clear(): void {
this.active.length = 0;
this.instanceCount = 0;
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteBuffer(this.instanceBuf);
gl.deleteVertexArray(this.vao);
gl.deleteTexture(this.glyphMetricsTex);
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
}
}
@@ -0,0 +1,135 @@
/**
* CoordinateGridPass — procedural grid overlay with cell labels.
*
* Draws white grid lines at cell boundaries and alphanumeric labels
* (A1, B2, ...) at the top-left of each cell. Grid computation matches
* the upstream game's CoordinateGridLayer.
*/
import type { RenderSettings } from "../render-settings";
import { createMapQuad, createProgram } from "../utils/gl-utils";
import gridFragSrc from "../shaders/grid/grid.frag.glsl?raw";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
const BASE_CELL_COUNT = 10;
const MAX_COLUMNS = 50;
const MIN_ROWS = 2;
const GLYPH_W = 24;
const GLYPH_H = 36;
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
export class CoordinateGridPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private glyphTex: WebGLTexture;
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uCellSize: WebGLUniformLocation;
private uZoom: WebGLUniformLocation;
private uFontSize: WebGLUniformLocation;
private mapW: number;
private mapH: number;
private cellSize: number;
private settings: RenderSettings;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
settings: RenderSettings,
) {
this.gl = gl;
this.mapW = mapW;
this.mapH = mapH;
this.cellSize = computeCellSize(mapW, mapH);
this.settings = settings;
this.glyphTex = this.createGlyphAtlas();
this.program = createProgram(gl, overlayVertSrc, gridFragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uCellSize = gl.getUniformLocation(this.program, "uCellSize")!;
this.uZoom = gl.getUniformLocation(this.program, "uZoom")!;
this.uFontSize = gl.getUniformLocation(this.program, "uFontSize")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphTex"), 0);
this.vao = createMapQuad(gl, mapW, mapH);
}
draw(cameraMatrix: Float32Array, zoom: number): void {
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uCellSize, this.cellSize);
gl.uniform1f(this.uZoom, zoom);
gl.uniform1f(this.uFontSize, this.settings.altView.gridFontSize);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.glyphTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
gl.deleteTexture(this.glyphTex);
}
/** Render A-Z, 0-9 glyphs into a single-row texture atlas. */
private createGlyphAtlas(): WebGLTexture {
const canvas = document.createElement("canvas");
canvas.width = CHARS.length * GLYPH_W;
canvas.height = GLYPH_H;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "white";
ctx.font = `bold ${GLYPH_H - 8}px monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let i = 0; i < CHARS.length; i++) {
ctx.fillText(CHARS[i], i * GLYPH_W + GLYPH_W / 2, GLYPH_H / 2);
}
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
return tex;
}
}
/** Compute cell size matching upstream CoordinateGridLayer.computeGrid(). */
function computeCellSize(mapW: number, mapH: number): number {
const raw = Math.min(mapW, mapH) / BASE_CELL_COUNT;
let rows = Math.max(1, Math.round(mapH / raw));
let cols = Math.max(1, Math.round(mapW / raw));
if (cols > MAX_COLUMNS) {
const maxRows = Math.floor((MAX_COLUMNS * mapH) / mapW);
rows = Math.max(MIN_ROWS, Math.min(rows, maxRows));
cols = MAX_COLUMNS;
}
return Math.min(mapW / cols, mapH / rows);
}
@@ -0,0 +1,96 @@
/**
* CrosshairPass — renders a red crosshair at the cursor position during
* warship or MIRV placement (ghost preview).
*
* Screen-space quad with a crosshair SDF in the fragment shader.
* Darker red when placement is invalid.
*/
import type { GhostPreviewData } from "../../types";
import { UT_MIRV, UT_WARSHIP } from "../../types";
import { createProgram } from "../utils/gl-utils";
import fragSrc from "../shaders/crosshair/crosshair.frag.glsl?raw";
import vertSrc from "../shaders/crosshair/crosshair.vert.glsl?raw";
/** Half-size of the crosshair quad in screen pixels. */
const CROSSHAIR_PX = 20;
export class CrosshairPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private uCamera: WebGLUniformLocation;
private uCenter: WebGLUniformLocation;
private uHalfSize: WebGLUniformLocation;
private uViewport: WebGLUniformLocation;
private uColor: WebGLUniformLocation;
private active = false;
private centerX = 0;
private centerY = 0;
private canBuild = false;
constructor(gl: WebGL2RenderingContext) {
this.gl = gl;
this.program = createProgram(gl, vertSrc, fragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uCenter = gl.getUniformLocation(this.program, "uCenter")!;
this.uHalfSize = gl.getUniformLocation(this.program, "uHalfSize")!;
this.uViewport = gl.getUniformLocation(this.program, "uViewport")!;
this.uColor = gl.getUniformLocation(this.program, "uColor")!;
// Unit quad [0,1]
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
}
updateGhostPreview(data: GhostPreviewData | null): void {
if (data && (data.ghostType === UT_WARSHIP || data.ghostType === UT_MIRV)) {
this.active = true;
this.centerX = data.tileX;
this.centerY = data.tileY;
this.canBuild = data.canBuild || data.canUpgrade;
} else {
this.active = false;
}
}
draw(cameraMatrix: Float32Array): void {
if (!this.active) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uCenter, this.centerX, this.centerY);
gl.uniform1f(this.uHalfSize, CROSSHAIR_PX);
gl.uniform2f(this.uViewport, gl.drawingBufferWidth, gl.drawingBufferHeight);
if (this.canBuild) {
gl.uniform3f(this.uColor, 0.9, 0.15, 0.15); // red crosshair
} else {
gl.uniform3f(this.uColor, 0.4, 0.1, 0.1); // dark red = can't build
}
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,320 @@
/**
* FalloutBloomPass — soft radioactive glow around irradiated tiles.
*
* Tile-space pipeline (camera-independent, zero shimmer):
* 1. Extract — compute per-tile bloom at map resolution (mapW x mapH)
* 2. Blur — two iterations of separable 9-tap Gaussian in tile space
* 3. Composite — camera-projected map quad samples blurred texture (LINEAR)
*
* Heat management is handled by HeatManager (shared with LightmapPass).
*/
import type { RenderSettings } from "../render-settings";
import {
createFullscreenQuad,
createMapQuad,
createProgram,
shaderSrc,
} from "../utils/gl-utils";
import type { HeatManager } from "../utils/heat-manager";
import { TILE_DEFINES } from "../utils/tile-codec";
import compositeFragSrc from "../shaders/fallout-bloom/composite.frag.glsl?raw";
import compositeVertSrc from "../shaders/fallout-bloom/composite.vert.glsl?raw";
import extractFragSrc from "../shaders/fallout-bloom/extract.frag.glsl?raw";
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
export class FalloutBloomPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private tileTex: WebGLTexture;
private heatManager: HeatManager;
// Programs
private extractProg: WebGLProgram;
private blurProg: WebGLProgram;
private compositeProg: WebGLProgram;
// Uniforms — extract
private uExtractMapSize: WebGLUniformLocation;
private uExtractTick: WebGLUniformLocation;
private uBroilSpeedCold: WebGLUniformLocation;
private uBroilSpeedHot: WebGLUniformLocation;
private uNoiseFreq1: WebGLUniformLocation;
private uNoiseFreq2: WebGLUniformLocation;
private uContrastLoCold: WebGLUniformLocation;
private uContrastLoHot: WebGLUniformLocation;
private uContrastHiCold: WebGLUniformLocation;
private uContrastHiHot: WebGLUniformLocation;
private uMetaFreq: WebGLUniformLocation;
private uIntensityCold: WebGLUniformLocation;
private uIntensityHot: WebGLUniformLocation;
private uMetaInfluenceCold: WebGLUniformLocation;
private uMetaInfluenceHot: WebGLUniformLocation;
private uOpacityFadeEnd: WebGLUniformLocation;
private uBloomColor: WebGLUniformLocation;
// Uniforms — composite
private uCompositeCam: WebGLUniformLocation;
private uCompositeMapSize: WebGLUniformLocation;
private uBloomCoverage: WebGLUniformLocation;
// Uniforms — blur
private uBlurDir: WebGLUniformLocation;
// FBOs (map resolution — fixed size)
private fboA: WebGLFramebuffer;
private fboB: WebGLFramebuffer;
private texA: WebGLTexture;
private texB: WebGLTexture;
// Geometry
private mapVao: WebGLVertexArrayObject;
private quadVao: WebGLVertexArrayObject;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
heatManager: HeatManager,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.heatManager = heatManager;
// --- Extract program (tile-space, no camera) ---
this.extractProg = createProgram(
gl,
fullscreenNoUvVertSrc,
shaderSrc(extractFragSrc, TILE_DEFINES),
);
this.uExtractMapSize = gl.getUniformLocation(this.extractProg, "uMapSize")!;
this.uExtractTick = gl.getUniformLocation(this.extractProg, "uTick")!;
this.uBroilSpeedCold = gl.getUniformLocation(
this.extractProg,
"uBroilSpeedCold",
)!;
this.uBroilSpeedHot = gl.getUniformLocation(
this.extractProg,
"uBroilSpeedHot",
)!;
this.uNoiseFreq1 = gl.getUniformLocation(this.extractProg, "uNoiseFreq1")!;
this.uNoiseFreq2 = gl.getUniformLocation(this.extractProg, "uNoiseFreq2")!;
this.uContrastLoCold = gl.getUniformLocation(
this.extractProg,
"uContrastLoCold",
)!;
this.uContrastLoHot = gl.getUniformLocation(
this.extractProg,
"uContrastLoHot",
)!;
this.uContrastHiCold = gl.getUniformLocation(
this.extractProg,
"uContrastHiCold",
)!;
this.uContrastHiHot = gl.getUniformLocation(
this.extractProg,
"uContrastHiHot",
)!;
this.uMetaFreq = gl.getUniformLocation(this.extractProg, "uMetaFreq")!;
this.uIntensityCold = gl.getUniformLocation(
this.extractProg,
"uIntensityCold",
)!;
this.uIntensityHot = gl.getUniformLocation(
this.extractProg,
"uIntensityHot",
)!;
this.uMetaInfluenceCold = gl.getUniformLocation(
this.extractProg,
"uMetaInfluenceCold",
)!;
this.uMetaInfluenceHot = gl.getUniformLocation(
this.extractProg,
"uMetaInfluenceHot",
)!;
this.uOpacityFadeEnd = gl.getUniformLocation(
this.extractProg,
"uOpacityFadeEnd",
)!;
this.uBloomColor = gl.getUniformLocation(this.extractProg, "uBloomColor")!;
gl.useProgram(this.extractProg);
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uHeatTex"), 1);
// --- Blur program ---
this.blurProg = createProgram(gl, fullscreenVertSrc, blurFragSrc);
this.uBlurDir = gl.getUniformLocation(this.blurProg, "uDir")!;
gl.useProgram(this.blurProg);
gl.uniform1i(gl.getUniformLocation(this.blurProg, "uTex"), 0);
// --- Composite program (camera-projected map quad) ---
this.compositeProg = createProgram(gl, compositeVertSrc, compositeFragSrc);
this.uCompositeCam = gl.getUniformLocation(this.compositeProg, "uCamera")!;
this.uCompositeMapSize = gl.getUniformLocation(
this.compositeProg,
"uMapSize",
)!;
this.uBloomCoverage = gl.getUniformLocation(
this.compositeProg,
"uBloomCoverage",
)!;
gl.useProgram(this.compositeProg);
gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uTex"), 0);
// --- FBO textures (map resolution) ---
this.texA = this.createBloomTex(mapW, mapH);
this.texB = this.createBloomTex(mapW, mapH);
this.fboA = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.texA,
0,
);
this.fboB = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboB);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.texB,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// --- Geometry ---
this.mapVao = createMapQuad(gl, mapW, mapH);
this.quadVao = createFullscreenQuad(gl);
}
private createBloomTex(w: number, h: number): WebGLTexture {
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
w,
h,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
return tex;
}
/** Run the full extract → blur → composite pipeline. */
draw(cameraMatrix: Float32Array, tick: number): void {
const gl = this.gl;
const canvas = gl.canvas as HTMLCanvasElement;
const cw = canvas.width;
const ch = canvas.height;
const mw = this.mapW;
const mh = this.mapH;
// --- 1. Extract: tile-space bloom ---
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA);
gl.viewport(0, 0, mw, mh);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.BLEND);
gl.useProgram(this.extractProg);
gl.uniform2f(this.uExtractMapSize, mw, mh);
gl.uniform1f(this.uExtractTick, tick);
const fb = this.settings.falloutBloom;
gl.uniform1f(this.uBroilSpeedCold, fb.broilSpeedCold);
gl.uniform1f(this.uBroilSpeedHot, fb.broilSpeedHot);
gl.uniform1f(this.uNoiseFreq1, fb.noiseFreq1);
gl.uniform1f(this.uNoiseFreq2, fb.noiseFreq2);
gl.uniform1f(this.uContrastLoCold, fb.contrastLoCold);
gl.uniform1f(this.uContrastLoHot, fb.contrastLoHot);
gl.uniform1f(this.uContrastHiCold, fb.contrastHiCold);
gl.uniform1f(this.uContrastHiHot, fb.contrastHiHot);
gl.uniform1f(this.uMetaFreq, fb.metaFreq);
gl.uniform1f(this.uIntensityCold, fb.intensityCold);
gl.uniform1f(this.uIntensityHot, fb.intensityHot);
gl.uniform1f(this.uMetaInfluenceCold, fb.metaInfluenceCold);
gl.uniform1f(this.uMetaInfluenceHot, fb.metaInfluenceHot);
gl.uniform1f(this.uOpacityFadeEnd, fb.opacityFadeEnd);
gl.uniform3f(this.uBloomColor, fb.bloomR, fb.bloomG, fb.bloomB);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.heatManager.getHeatTex());
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// --- 2. Blur: 2 iterations of separable H+V Gaussian ---
gl.useProgram(this.blurProg);
gl.bindVertexArray(this.quadVao);
for (let iter = 0; iter < 2; iter++) {
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboB);
gl.viewport(0, 0, mw, mh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, 1.0 / mw, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texA);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA);
gl.viewport(0, 0, mw, mh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, 0, 1.0 / mh);
gl.bindTexture(gl.TEXTURE_2D, this.texB);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
// --- 3. Composite: camera-projected map quad → screen ---
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, cw, ch);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(this.compositeProg);
gl.uniformMatrix3fv(this.uCompositeCam, false, cameraMatrix);
gl.uniform2f(this.uCompositeMapSize, mw, mh);
gl.uniform1f(this.uBloomCoverage, fb.bloomCoverage);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texA);
gl.bindVertexArray(this.mapVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// Restore standard alpha blending
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.extractProg);
gl.deleteProgram(this.blurProg);
gl.deleteProgram(this.compositeProg);
gl.deleteFramebuffer(this.fboA);
gl.deleteFramebuffer(this.fboB);
gl.deleteTexture(this.texA);
gl.deleteTexture(this.texB);
gl.deleteVertexArray(this.mapVao);
gl.deleteVertexArray(this.quadVao);
}
}
@@ -0,0 +1,235 @@
/**
* FalloutLightPass — tile-space fallout light extraction + composite.
*
* Extracted from LightmapPass. Two-step:
* 1. Extract fallout light at tile resolution (mapW x mapH) — reads heat + embers
* 2. Composite into the target lightmap FBO via camera-projected map quad (additive)
*/
import type { RenderSettings } from "../render-settings";
import {
createFullscreenQuad,
createMapQuad,
createProgram,
shaderSrc,
} from "../utils/gl-utils";
import type { HeatManager } from "../utils/heat-manager";
import { TILE_DEFINES } from "../utils/tile-codec";
import falloutCompositeFragSrc from "../shaders/day-night/fallout-composite.frag.glsl?raw";
import falloutCompositeVertSrc from "../shaders/day-night/fallout-composite.vert.glsl?raw";
import falloutLightFragSrc from "../shaders/day-night/fallout-light.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
export class FalloutLightPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private heatManager: HeatManager;
private tileTex: WebGLTexture;
private borderTex: WebGLTexture;
// Fallout light extraction
private falloutLightProg: WebGLProgram;
private uFalloutMapSize: WebGLUniformLocation;
private uFalloutLightColor: WebGLUniformLocation;
private uFalloutLightIntensity: WebGLUniformLocation;
private uFalloutLightThreshold: WebGLUniformLocation;
private uEmberLightColor: WebGLUniformLocation;
private uEmberLightIntensity: WebGLUniformLocation;
// Fallout composite (tile-space → lightmap)
private falloutCompositeProg: WebGLProgram;
private uFalloutCompositeCam: WebGLUniformLocation;
private uFalloutCompositeMapSize: WebGLUniformLocation;
// Tile-space FBO
private falloutFbo: WebGLFramebuffer;
private falloutTex: WebGLTexture;
// Geometry
private quadVao: WebGLVertexArrayObject;
private mapQuadVao: WebGLVertexArrayObject;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
borderTex: WebGLTexture,
heatManager: HeatManager,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.borderTex = borderTex;
this.heatManager = heatManager;
// Fallout light extraction program
this.falloutLightProg = createProgram(
gl,
fullscreenNoUvVertSrc,
shaderSrc(falloutLightFragSrc, TILE_DEFINES),
);
this.uFalloutMapSize = gl.getUniformLocation(
this.falloutLightProg,
"uMapSize",
)!;
this.uFalloutLightColor = gl.getUniformLocation(
this.falloutLightProg,
"uFalloutLightColor",
)!;
this.uFalloutLightIntensity = gl.getUniformLocation(
this.falloutLightProg,
"uFalloutLightIntensity",
)!;
this.uFalloutLightThreshold = gl.getUniformLocation(
this.falloutLightProg,
"uFalloutLightThreshold",
)!;
this.uEmberLightColor = gl.getUniformLocation(
this.falloutLightProg,
"uEmberLightColor",
)!;
this.uEmberLightIntensity = gl.getUniformLocation(
this.falloutLightProg,
"uEmberLightIntensity",
)!;
gl.useProgram(this.falloutLightProg);
gl.uniform1i(gl.getUniformLocation(this.falloutLightProg, "uHeatTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.falloutLightProg, "uTileTex"), 1);
gl.uniform1i(gl.getUniformLocation(this.falloutLightProg, "uBorderTex"), 2);
// Fallout composite program
this.falloutCompositeProg = createProgram(
gl,
falloutCompositeVertSrc,
falloutCompositeFragSrc,
);
this.uFalloutCompositeCam = gl.getUniformLocation(
this.falloutCompositeProg,
"uCamera",
)!;
this.uFalloutCompositeMapSize = gl.getUniformLocation(
this.falloutCompositeProg,
"uMapSize",
)!;
gl.useProgram(this.falloutCompositeProg);
gl.uniform1i(gl.getUniformLocation(this.falloutCompositeProg, "uTex"), 0);
// Tile-space FBO (map resolution)
this.falloutTex = this.createRGBA8Tex(mapW, mapH);
this.falloutFbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.falloutFbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.falloutTex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// Geometry
this.quadVao = createFullscreenQuad(gl);
this.mapQuadVao = createMapQuad(gl, mapW, mapH);
}
private createRGBA8Tex(w: number, h: number): WebGLTexture {
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
w,
h,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
return tex;
}
/**
* Extract fallout light in tile space, then composite into the target FBO.
* Caller must bind the target FBO and set additive blending before calling.
*/
draw(
cameraMatrix: Float32Array,
targetFbo: WebGLFramebuffer,
targetW: number,
targetH: number,
): void {
const gl = this.gl;
const dn = this.settings.dayNight;
// Step 1: Extract fallout light in tile space
gl.bindFramebuffer(gl.FRAMEBUFFER, this.falloutFbo);
gl.viewport(0, 0, this.mapW, this.mapH);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.BLEND);
gl.useProgram(this.falloutLightProg);
gl.uniform2f(this.uFalloutMapSize, this.mapW, this.mapH);
gl.uniform3f(
this.uFalloutLightColor,
dn.falloutLightR,
dn.falloutLightG,
dn.falloutLightB,
);
gl.uniform1f(this.uFalloutLightIntensity, dn.falloutLightIntensity);
gl.uniform1f(this.uFalloutLightThreshold, dn.falloutLightThreshold);
gl.uniform3f(
this.uEmberLightColor,
dn.emberLightR,
dn.emberLightG,
dn.emberLightB,
);
gl.uniform1f(this.uEmberLightIntensity, dn.emberLightIntensity);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.heatManager.getHeatTex());
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.borderTex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// Step 2: Composite tile-space fallout into target lightmap
gl.bindFramebuffer(gl.FRAMEBUFFER, targetFbo);
gl.viewport(0, 0, targetW, targetH);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE); // additive
gl.useProgram(this.falloutCompositeProg);
gl.uniformMatrix3fv(this.uFalloutCompositeCam, false, cameraMatrix);
gl.uniform2f(this.uFalloutCompositeMapSize, this.mapW, this.mapH);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.falloutTex);
gl.bindVertexArray(this.mapQuadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.falloutLightProg);
gl.deleteProgram(this.falloutCompositeProg);
gl.deleteFramebuffer(this.falloutFbo);
gl.deleteTexture(this.falloutTex);
gl.deleteVertexArray(this.quadVao);
gl.deleteVertexArray(this.mapQuadVao);
}
}
@@ -0,0 +1,212 @@
/**
* FxAttackRingPass — persistent animated rings at transport ship target tiles.
*
* Rings fade in when a transport acquires a target, and fade out when the
* target is lost. Uses a rotating dashed-ring shader (attack-ring.vert/frag).
*/
import type { AttackRingInput } from "../../../types";
import { DynamicInstanceBuffer } from "../../dynamic-buffer";
import type { RenderSettings } from "../../render-settings";
import { createProgram } from "../../utils/gl-utils";
import attackRingFragSrc from "../../shaders/fx/attack-ring.frag.glsl?raw";
import attackRingVertSrc from "../../shaders/fx/attack-ring.vert.glsl?raw";
export type { AttackRingInput } from "../../../types";
// ---------------------------------------------------------------------------
// Active state
// ---------------------------------------------------------------------------
interface ActiveAttackRing {
unitId: number;
x: number;
y: number;
/** performance.now() when fade-in started or fade-out began. */
transitionMs: number;
fadingOut: boolean;
}
// ---------------------------------------------------------------------------
// Instance data layout: x, y, alpha
// ---------------------------------------------------------------------------
const ATTACK_RING_FLOATS = 3;
const FADE_IN_MS = 200;
const FADE_OUT_MS = 300;
// ---------------------------------------------------------------------------
// FxAttackRingPass
// ---------------------------------------------------------------------------
export class FxAttackRingPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private uCamera: WebGLUniformLocation;
private uTilesPerPx: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uRingWidth: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private ringCount = 0;
private active: ActiveAttackRing[] = [];
constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
this.gl = gl;
this.settings = settings;
this.program = createProgram(gl, attackRingVertSrc, attackRingFragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTilesPerPx = gl.getUniformLocation(this.program, "uTilesPerPx")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uRingWidth = gl.getUniformLocation(this.program, "uRingWidth")!;
const glBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
glBuf,
8,
ATTACK_RING_FLOATS,
);
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, glBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
gl.vertexAttribDivisor(1, 1);
gl.bindVertexArray(null);
}
// -------------------------------------------------------------------------
// Update
// -------------------------------------------------------------------------
update(rings: AttackRingInput[]): void {
const now = performance.now();
const incoming = new Set<number>();
for (const r of rings) incoming.add(r.unitId);
// Mark removed rings as fading out
for (const ar of this.active) {
if (!ar.fadingOut && !incoming.has(ar.unitId)) {
ar.fadingOut = true;
ar.transitionMs = now;
}
}
// Add or refresh rings
for (const r of rings) {
const existing = this.active.find((a) => a.unitId === r.unitId);
if (existing) {
existing.x = r.x;
existing.y = r.y;
if (existing.fadingOut) {
existing.fadingOut = false;
existing.transitionMs = now;
}
} else {
this.active.push({
unitId: r.unitId,
x: r.x,
y: r.y,
transitionMs: now,
fadingOut: false,
});
}
}
}
// -------------------------------------------------------------------------
// Tick
// -------------------------------------------------------------------------
tick(): void {
if (this.active.length === 0) return;
const now = performance.now();
// Remove fully faded rings
for (let i = this.active.length - 1; i >= 0; i--) {
const ar = this.active[i];
if (ar.fadingOut && now - ar.transitionMs >= FADE_OUT_MS) {
this.active[i] = this.active[this.active.length - 1];
this.active.pop();
}
}
const count = this.active.length;
this.instanceBuf.ensureCapacity(count);
const data = this.instanceBuf.float32;
for (let i = 0; i < count; i++) {
const ar = this.active[i];
const elapsed = now - ar.transitionMs;
const alpha = ar.fadingOut
? Math.max(0, 1 - elapsed / FADE_OUT_MS)
: Math.min(1, elapsed / FADE_IN_MS);
const off = i * ATTACK_RING_FLOATS;
data[off + 0] = ar.x;
data[off + 1] = ar.y;
data[off + 2] = alpha;
}
this.ringCount = count;
}
// -------------------------------------------------------------------------
// Draw
// -------------------------------------------------------------------------
draw(cameraMatrix: Float32Array, zoom: number): void {
if (this.ringCount === 0) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTilesPerPx, 1 / zoom);
gl.uniform1f(this.uTime, performance.now() / 1000);
gl.uniform1f(this.uRingWidth, this.settings.fx.shockwaveRingWidth);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
this.ringCount * ATTACK_RING_FLOATS,
);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.ringCount);
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
clear(): void {
this.active.length = 0;
this.ringCount = 0;
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,191 @@
/**
* FxShockwavePass — instanced procedural ring quads.
*
* Spawned alongside sprite FX for nuke and SAM interception events.
* Uses an SDF circle rendered in a unit quad, no texture required.
*/
import { DynamicInstanceBuffer } from "../../dynamic-buffer";
import type { RenderSettings } from "../../render-settings";
import { createProgram } from "../../utils/gl-utils";
import shockwaveFragSrc from "../../shaders/fx/shockwave.frag.glsl?raw";
import shockwaveVertSrc from "../../shaders/fx/shockwave.vert.glsl?raw";
// ---------------------------------------------------------------------------
// Active state
// ---------------------------------------------------------------------------
interface ActiveShockwave {
x: number;
y: number;
startMs: number;
durationMs: number;
maxRadius: number;
}
// ---------------------------------------------------------------------------
// Instance data layout: x, y, radius, alpha
// ---------------------------------------------------------------------------
const SHOCKWAVE_FLOATS = 4;
// ---------------------------------------------------------------------------
// FxShockwavePass
// ---------------------------------------------------------------------------
export class FxShockwavePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private uCamera: WebGLUniformLocation;
private uRingWidth: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private shockwaveCount = 0;
private active: ActiveShockwave[] = [];
private timeFn: () => number = () => performance.now();
constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
this.gl = gl;
this.settings = settings;
this.program = createProgram(gl, shockwaveVertSrc, shockwaveFragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uRingWidth = gl.getUniformLocation(this.program, "uRingWidth")!;
const glBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
glBuf,
16,
SHOCKWAVE_FLOATS,
);
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, glBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0);
gl.vertexAttribDivisor(1, 1);
gl.bindVertexArray(null);
}
// -------------------------------------------------------------------------
// Spawning
// -------------------------------------------------------------------------
pushNukeShockwave(x: number, y: number, nukeRadius: number): void {
const fx = this.settings.fx;
this.active.push({
x,
y,
startMs: this.timeFn(),
durationMs: fx.nukeShockwaveDurationMs,
maxRadius: nukeRadius * fx.nukeShockwaveRadiusFactor,
});
}
pushSAMShockwave(x: number, y: number): void {
const fx = this.settings.fx;
this.active.push({
x,
y,
startMs: this.timeFn(),
durationMs: fx.samShockwaveDurationMs,
maxRadius: fx.samShockwaveRadius,
});
}
// -------------------------------------------------------------------------
// Tick
// -------------------------------------------------------------------------
tick(): void {
if (this.active.length === 0) return;
const now = this.timeFn();
for (let i = this.active.length - 1; i >= 0; i--) {
if (now - this.active[i].startMs >= this.active[i].durationMs) {
this.active[i] = this.active[this.active.length - 1];
this.active.pop();
}
}
this.rebuildInstances(now);
}
private rebuildInstances(now: number): void {
const count = this.active.length;
this.instanceBuf.ensureCapacity(count);
const data = this.instanceBuf.float32;
for (let i = 0; i < count; i++) {
const sw = this.active[i];
const t = (now - sw.startMs) / sw.durationMs;
const off = i * SHOCKWAVE_FLOATS;
data[off + 0] = sw.x;
data[off + 1] = sw.y;
data[off + 2] = t * sw.maxRadius;
data[off + 3] = 1 - t;
}
this.shockwaveCount = count;
}
// -------------------------------------------------------------------------
// Draw
// -------------------------------------------------------------------------
draw(cameraMatrix: Float32Array): void {
if (this.shockwaveCount === 0) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uRingWidth, this.settings.fx.shockwaveRingWidth);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
this.shockwaveCount * SHOCKWAVE_FLOATS,
);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.shockwaveCount);
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
setTimeFn(fn: () => number): void {
this.timeFn = fn;
}
clear(): void {
this.active.length = 0;
this.shockwaveCount = 0;
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,526 @@
/**
* FxSpritePass — instanced textured quads sampling an animated sprite atlas.
*
* Manages: sprite FX state (explosions, dust, conquest, debris).
* Atlas layout: 12 horizontal sprite strips stacked vertically.
* Pre-built by generate-sprite-atlases.mjs.
*/
import { MS_PER_TICK, NUKE_EXPLOSION_RADII } from "../../../game-constants";
import type { ConquestFx, DeadUnitFx, RendererConfig } from "../../../types";
import {
STRUCTURE_TYPES,
UT_SHELL,
UT_TRAIN,
UT_WARSHIP,
} from "../../../types";
import { DynamicInstanceBuffer } from "../../dynamic-buffer";
import type { RenderSettings } from "../../render-settings";
import { createProgram, shaderSrc } from "../../utils/gl-utils";
import fxAtlasMeta from "../../assets/fx-atlas-meta.json";
import fxAtlasUrl from "../../assets/fx-atlas.png?url";
import spriteFragSrc from "../../shaders/fx/sprite.frag.glsl?raw";
import spriteVertSrc from "../../shaders/fx/sprite.vert.glsl?raw";
// ---------------------------------------------------------------------------
// FX type indices (atlas row)
// ---------------------------------------------------------------------------
export const FX_NUKE = 0;
export const FX_SAM_EXPLOSION = 1;
export const FX_BUILDING_EXPLOSION = 2;
export const FX_UNIT_EXPLOSION = 3;
export const FX_MINI_EXPLOSION = 4;
export const FX_SINKING_SHIP = 5;
export const FX_MINI_FIRE = 6;
export const FX_MINI_SMOKE = 7;
export const FX_MINI_BIG_SMOKE = 8;
export const FX_MINI_SMOKE_FIRE = 9;
export const FX_DUST = 10;
export const FX_CONQUEST = 11;
const FX_TYPE_COUNT = 12;
// ---------------------------------------------------------------------------
// FX sprite config (matches AnimatedSpriteLoader)
// ---------------------------------------------------------------------------
interface FxTypeConfig {
frameWidth: number;
frameCount: number;
frameDurationMs: number;
looping: boolean;
}
const FX_CONFIG: FxTypeConfig[] = [
/* 0 Nuke */ {
frameWidth: 60,
frameCount: 9,
frameDurationMs: 70,
looping: false,
},
/* 1 SAMExplosion */ {
frameWidth: 48,
frameCount: 9,
frameDurationMs: 70,
looping: false,
},
/* 2 BuildingExpl */ {
frameWidth: 17,
frameCount: 10,
frameDurationMs: 70,
looping: false,
},
/* 3 UnitExplosion */ {
frameWidth: 19,
frameCount: 4,
frameDurationMs: 70,
looping: false,
},
/* 4 MiniExplosion */ {
frameWidth: 13,
frameCount: 4,
frameDurationMs: 70,
looping: false,
},
/* 5 SinkingShip */ {
frameWidth: 16,
frameCount: 14,
frameDurationMs: 90,
looping: false,
},
/* 6 MiniFire */ {
frameWidth: 7,
frameCount: 6,
frameDurationMs: 100,
looping: true,
},
/* 7 MiniSmoke */ {
frameWidth: 11,
frameCount: 4,
frameDurationMs: 120,
looping: true,
},
/* 8 MiniBigSmoke */ {
frameWidth: 24,
frameCount: 5,
frameDurationMs: 120,
looping: true,
},
/* 9 MiniSmokeFire */ {
frameWidth: 24,
frameCount: 5,
frameDurationMs: 120,
looping: true,
},
/* 10 Dust */ {
frameWidth: 9,
frameCount: 3,
frameDurationMs: 100,
looping: false,
},
/* 11 Conquest */ {
frameWidth: 21,
frameCount: 10,
frameDurationMs: 90,
looping: false,
},
];
// ---------------------------------------------------------------------------
// Nuke debris plan
// ---------------------------------------------------------------------------
const DEBRIS_PLAN = [
{ type: FX_MINI_FIRE, radiusFactor: 1.0, density: 1 / 25 },
{ type: FX_MINI_SMOKE, radiusFactor: 1.0, density: 1 / 28 },
{ type: FX_MINI_BIG_SMOKE, radiusFactor: 0.9, density: 1 / 70 },
{ type: FX_MINI_SMOKE_FIRE, radiusFactor: 0.9, density: 1 / 70 },
];
/** Deterministic float in [0,1) from an integer seed (mulberry32). */
function seededRandom(seed: number): number {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// ---------------------------------------------------------------------------
// Active FX state
// ---------------------------------------------------------------------------
interface ActiveFx {
x: number;
y: number;
fxType: number;
startMs: number;
lifetimeMs: number;
fadeIn: number; // fraction 01 (start of full alpha)
fadeOut: number; // fraction 01 (start of fade out)
}
// ---------------------------------------------------------------------------
// Instance data layout
// ---------------------------------------------------------------------------
const SPRITE_FLOATS = 4; // x, y, fxType, [frameIdx u8, alpha u8, pad, pad]
const SPRITE_BYTES = 16;
// ---------------------------------------------------------------------------
// FxSpritePass
// ---------------------------------------------------------------------------
export class FxSpritePass {
private gl: WebGL2RenderingContext;
private mapW: number;
private settings: RenderSettings;
private program: WebGLProgram;
private uCamera: WebGLUniformLocation;
private uFxUV: WebGLUniformLocation;
private uFxWorld: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private spriteCount = 0;
private atlasTex: WebGLTexture;
private atlasReady = false;
private activeFx: ActiveFx[] = [];
private timeFn: () => number = () => performance.now();
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
settings: RenderSettings,
) {
this.gl = gl;
this.mapW = header.mapWidth;
this.settings = settings;
this.program = createProgram(
gl,
shaderSrc(spriteVertSrc, { FX_TYPE_COUNT }),
spriteFragSrc,
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uFxUV = gl.getUniformLocation(this.program, "uFxUV")!;
this.uFxWorld = gl.getUniformLocation(this.program, "uFxWorld")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0);
// Placeholder atlas (1x1 transparent)
this.atlasTex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0]),
);
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);
// Instance buffer
const glBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(gl, glBuf, 256, SPRITE_FLOATS);
// VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, glBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, SPRITE_BYTES, 0);
gl.vertexAttribDivisor(1, 1);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, SPRITE_BYTES, 12);
gl.vertexAttribDivisor(2, 1);
gl.bindVertexArray(null);
this.loadAtlas();
}
// -------------------------------------------------------------------------
// Atlas loading
// -------------------------------------------------------------------------
private async loadAtlas(): Promise<void> {
const img = new Image();
img.src = fxAtlasUrl;
await img.decode();
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const meta = fxAtlasMeta;
const uvData = new Float32Array(FX_TYPE_COUNT * 4);
const worldData = new Float32Array(FX_TYPE_COUNT * 4);
for (let i = 0; i < FX_TYPE_COUNT; i++) {
const row = meta.rows[i];
uvData[i * 4 + 0] = row.yOffset / meta.height;
uvData[i * 4 + 1] = row.height / meta.height;
uvData[i * 4 + 2] = row.worldWidth / meta.width;
uvData[i * 4 + 3] = 0;
worldData[i * 4 + 0] = row.worldWidth;
worldData[i * 4 + 1] = row.worldHeight;
worldData[i * 4 + 2] = 0;
worldData[i * 4 + 3] = 0;
}
gl.useProgram(this.program);
gl.uniform4fv(this.uFxUV, uvData);
gl.uniform4fv(this.uFxWorld, worldData);
this.atlasReady = true;
}
// -------------------------------------------------------------------------
// Spawning
// -------------------------------------------------------------------------
applyRailroadDust(tileRefs: number[]): void {
const now = this.timeFn();
for (const ref of tileRefs) {
if (Math.random() > 0.33) continue;
const x = ref % this.mapW;
const y = (ref - x) / this.mapW;
this.pushFx(x, y, FX_DUST, now);
}
}
applyConquestEvents(events: ConquestFx[]): void {
const now = this.timeFn();
const fx = this.settings.fx;
for (const evt of events) {
const startMs = now - (evt.tickAge ?? 0) * MS_PER_TICK;
if (now - startMs >= fx.conquestLifetimeMs) continue;
this.activeFx.push({
x: evt.x,
y: evt.y,
fxType: FX_CONQUEST,
startMs,
lifetimeMs: fx.conquestLifetimeMs,
fadeIn: fx.conquestFadeIn,
fadeOut: fx.conquestFadeOut,
});
}
}
/**
* Spawn sprite FX for a dead unit. Returns the nuke radius if a nuke
* exploded (so the orchestrator can also spawn a shockwave), or null.
*/
spawnFxForUnit(unit: DeadUnitFx, now: number): void {
const typeName = unit.unitType;
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
const nukeRadius = NUKE_EXPLOSION_RADII[typeName];
if (nukeRadius !== undefined) {
if (unit.reachedTarget) {
this.spawnNukeSprites(x, y, nukeRadius, now, unit.pos);
} else {
this.pushFx(x, y, FX_SAM_EXPLOSION, now);
}
return;
}
if (typeName === UT_WARSHIP) {
this.pushFx(x, y, FX_UNIT_EXPLOSION, now);
this.pushFx(x, y, FX_SINKING_SHIP, now);
return;
}
if (typeName === UT_SHELL && unit.reachedTarget) {
this.pushFx(x, y, FX_MINI_EXPLOSION, now);
return;
}
if (typeName === UT_TRAIN && !unit.reachedTarget) {
this.pushFx(x, y, FX_MINI_EXPLOSION, now);
return;
}
if (STRUCTURE_TYPES.has(typeName)) {
this.pushFx(x, y, FX_BUILDING_EXPLOSION, now);
}
}
private spawnNukeSprites(
x: number,
y: number,
radius: number,
now: number,
pos: number,
): void {
this.pushFx(x, y, FX_NUKE, now);
let debrisIdx = 0;
for (const { type, radiusFactor, density } of DEBRIS_PLAN) {
const count = Math.max(0, Math.floor(radius * density));
const r = radius * radiusFactor;
for (let i = 0; i < count; i++) {
const seed = pos * 997 + debrisIdx++;
const angle = seededRandom(seed) * Math.PI * 2;
const dist = seededRandom(seed + 0x10000) * (r / 2);
const dx = Math.floor(Math.cos(angle) * dist);
const dy = Math.floor(Math.sin(angle) * dist);
this.pushDebris(x + dx, y + dy, type, now);
}
}
}
pushFx(x: number, y: number, fxType: number, now: number): void {
const cfg = FX_CONFIG[fxType];
this.activeFx.push({
x,
y,
fxType,
startMs: now,
lifetimeMs: cfg.frameDurationMs * cfg.frameCount,
fadeIn: 0,
fadeOut: 1,
});
}
private pushDebris(x: number, y: number, fxType: number, now: number): void {
const fx = this.settings.fx;
this.activeFx.push({
x,
y,
fxType,
startMs: now,
lifetimeMs: fx.debrisLifetimeMs,
fadeIn: fx.debrisFadeIn,
fadeOut: fx.debrisFadeOut,
});
}
// -------------------------------------------------------------------------
// Tick
// -------------------------------------------------------------------------
tick(): void {
if (this.activeFx.length === 0) return;
const now = this.timeFn();
for (let i = this.activeFx.length - 1; i >= 0; i--) {
if (now - this.activeFx[i].startMs >= this.activeFx[i].lifetimeMs) {
this.activeFx[i] = this.activeFx[this.activeFx.length - 1];
this.activeFx.pop();
}
}
this.rebuildInstances(now);
}
private rebuildInstances(now: number): void {
const count = this.activeFx.length;
this.instanceBuf.ensureCapacity(count);
for (let i = 0; i < count; i++) {
const fx = this.activeFx[i];
const cfg = FX_CONFIG[fx.fxType];
const elapsed = now - fx.startMs;
let frameIdx: number;
if (cfg.looping) {
const cycle = cfg.frameDurationMs * cfg.frameCount;
frameIdx = Math.floor((elapsed % cycle) / cfg.frameDurationMs);
} else {
frameIdx = Math.min(
Math.floor(elapsed / cfg.frameDurationMs),
cfg.frameCount - 1,
);
}
let alpha = 255;
if (fx.fadeIn > 0 || fx.fadeOut < 1) {
const t = elapsed / fx.lifetimeMs;
if (t < fx.fadeIn) {
alpha = Math.floor((t / fx.fadeIn) * 255);
} else if (t > fx.fadeOut) {
alpha = Math.floor(((1 - t) / (1 - fx.fadeOut)) * 255);
}
}
const off = i * SPRITE_FLOATS;
this.instanceBuf.float32[off + 0] = fx.x;
this.instanceBuf.float32[off + 1] = fx.y;
this.instanceBuf.float32[off + 2] = fx.fxType;
const byteOff = i * SPRITE_BYTES;
this.instanceBuf.uint8[byteOff + 12] = frameIdx;
this.instanceBuf.uint8[byteOff + 13] = alpha;
}
this.spriteCount = count;
}
// -------------------------------------------------------------------------
// Draw
// -------------------------------------------------------------------------
draw(cameraMatrix: Float32Array): void {
if (this.spriteCount === 0 || !this.atlasReady) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
this.spriteCount * SPRITE_FLOATS,
);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.spriteCount);
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
setTimeFn(fn: () => number): void {
this.timeFn = fn;
}
clear(): void {
this.activeFx.length = 0;
this.spriteCount = 0;
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
gl.deleteTexture(this.atlasTex);
}
}
@@ -0,0 +1,126 @@
/**
* FxPass — orchestrates three independent GPU effect sub-passes:
* 1. FxSpritePass — animated sprite atlas (explosions, dust, conquest)
* 2. FxShockwavePass — procedural rings for nuke/SAM events
* 3. FxAttackRingPass — persistent rings at transport ship targets
*
* Spawn events that produce both a sprite and a shockwave (nukes, SAM
* interceptions) are coordinated here so each sub-pass stays self-contained.
*/
import { MS_PER_TICK, NUKE_EXPLOSION_RADII } from "../../../game-constants";
import type {
AttackRingInput,
ConquestFx,
DeadUnitFx,
RendererConfig,
} from "../../../types";
import type { RenderSettings } from "../../render-settings";
import { FxAttackRingPass } from "./fx-attack-ring-pass";
import { FxShockwavePass } from "./fx-shockwave-pass";
import { FxSpritePass } from "./fx-sprite-pass";
export type { AttackRingInput } from "../../../types";
export class FxPass {
private spritePass: FxSpritePass;
private shockwavePass: FxShockwavePass;
private attackRingPass: FxAttackRingPass;
private mapW: number;
private timeFn: () => number = () => performance.now();
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
settings: RenderSettings,
) {
this.mapW = header.mapWidth;
this.spritePass = new FxSpritePass(gl, header, settings);
this.shockwavePass = new FxShockwavePass(gl, settings);
this.attackRingPass = new FxAttackRingPass(gl, settings);
}
// -------------------------------------------------------------------------
// Spawning — coordinated across sub-passes
// -------------------------------------------------------------------------
applyDeadUnits(deadUnits: DeadUnitFx[]): void {
const now = this.timeFn();
for (const unit of deadUnits) {
const startMs = now - (unit.tickAge ?? 0) * MS_PER_TICK;
this.spawnUnit(unit, startMs);
}
}
private spawnUnit(unit: DeadUnitFx, now: number): void {
const typeName = unit.unitType;
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
const nukeRadius = NUKE_EXPLOSION_RADII[typeName];
if (nukeRadius !== undefined) {
if (unit.reachedTarget) {
this.spritePass.spawnFxForUnit(unit, now);
this.shockwavePass.pushNukeShockwave(x, y, nukeRadius);
} else {
// SAM interception: sprite pass handles the SAM explosion sprite
this.spritePass.spawnFxForUnit(unit, now);
this.shockwavePass.pushSAMShockwave(x, y);
}
return;
}
// All other units: sprite-only effects
this.spritePass.spawnFxForUnit(unit, now);
}
applyRailroadDust(tileRefs: number[]): void {
this.spritePass.applyRailroadDust(tileRefs);
}
applyConquestEvents(events: ConquestFx[]): void {
this.spritePass.applyConquestEvents(events);
}
updateAttackRings(rings: AttackRingInput[]): void {
this.attackRingPass.update(rings);
}
// -------------------------------------------------------------------------
// Per-frame
// -------------------------------------------------------------------------
tick(): void {
this.spritePass.tick();
this.shockwavePass.tick();
this.attackRingPass.tick();
}
draw(cameraMatrix: Float32Array, zoom: number): void {
this.spritePass.draw(cameraMatrix);
this.shockwavePass.draw(cameraMatrix);
this.attackRingPass.draw(cameraMatrix, zoom);
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
setTimeFn(fn: () => number): void {
this.timeFn = fn;
this.spritePass.setTimeFn(fn);
this.shockwavePass.setTimeFn(fn);
}
clear(): void {
this.spritePass.clear();
this.shockwavePass.clear();
this.attackRingPass.clear();
}
dispose(): void {
this.spritePass.dispose();
this.shockwavePass.dispose();
this.attackRingPass.dispose();
}
}
@@ -0,0 +1,206 @@
/**
* LightmapPass — orchestrator: point lights + fallout lights → blur → final texture.
*
* Owns the quarter-resolution lightmap ping-pong FBOs and the blur shader.
* Delegates light rendering to PointLightPass and FalloutLightPass.
*/
import type { RenderSettings } from "../render-settings";
import { createFullscreenQuad, createProgram } from "../utils/gl-utils";
import type { FalloutLightPass } from "./fallout-light-pass";
import type { PointLightPass } from "./point-light-pass";
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
export class LightmapPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private pointLightPass: PointLightPass;
private falloutLightPass: FalloutLightPass;
// Blur program
private blurProg: WebGLProgram;
private uBlurDir: WebGLUniformLocation;
// Quarter-res lightmap ping-pong
private lightFboA: WebGLFramebuffer;
private lightFboB: WebGLFramebuffer;
private lightTexA: WebGLTexture;
private lightTexB: WebGLTexture;
private lightW = 0;
private lightH = 0;
// Geometry
private quadVao: WebGLVertexArrayObject;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
pointLightPass: PointLightPass,
falloutLightPass: FalloutLightPass,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.pointLightPass = pointLightPass;
this.falloutLightPass = falloutLightPass;
// Blur program
this.blurProg = createProgram(gl, fullscreenVertSrc, blurFragSrc);
this.uBlurDir = gl.getUniformLocation(this.blurProg, "uDir")!;
gl.useProgram(this.blurProg);
gl.uniform1i(gl.getUniformLocation(this.blurProg, "uTex"), 0);
// Lightmap FBOs (1×1 placeholder, resized lazily)
this.lightTexA = this.createRGBA8Tex();
this.lightTexB = this.createRGBA8Tex();
this.lightFboA = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboA);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.lightTexA,
0,
);
this.lightFboB = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboB);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
this.lightTexB,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
this.quadVao = createFullscreenQuad(gl);
}
private createRGBA8Tex(): WebGLTexture {
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
return tex;
}
private ensureLightSize(w: number, h: number): void {
if (w === this.lightW && h === this.lightH) return;
this.lightW = w;
this.lightH = h;
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.lightTexA);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
w,
h,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.bindTexture(gl.TEXTURE_2D, this.lightTexB);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
w,
h,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
}
/** Generate the lightmap and return the final blurred texture. */
draw(
cameraMatrix: Float32Array,
sceneW: number,
sceneH: number,
): WebGLTexture {
const gl = this.gl;
const lw = Math.max(1, sceneW >> 1);
const lh = Math.max(1, sceneH >> 1);
this.ensureLightSize(lw, lh);
// --- 1. Point lights → FBO A (additive) ---
gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboA);
gl.viewport(0, 0, lw, lh);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE); // additive
this.pointLightPass.draw(cameraMatrix);
// --- 2. Fallout light → extract at tile res, composite into FBO A (additive) ---
this.falloutLightPass.draw(cameraMatrix, this.lightFboA, lw, lh);
// --- 3. Blur: 2 iterations separable H+V Gaussian ---
const zoom = Math.abs(cameraMatrix[0]);
const mapSize = Math.max(this.mapW, this.mapH);
const blurScale = Math.min(
(zoom * mapSize) / this.settings.dayNight.blurZoomDivisor,
1.0,
);
gl.disable(gl.BLEND);
gl.useProgram(this.blurProg);
gl.bindVertexArray(this.quadVao);
for (let iter = 0; iter < 2; iter++) {
gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboB);
gl.viewport(0, 0, lw, lh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, blurScale / lw, 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.lightTexA);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboA);
gl.viewport(0, 0, lw, lh);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this.uBlurDir, 0, blurScale / lh);
gl.bindTexture(gl.TEXTURE_2D, this.lightTexB);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
return this.lightTexA;
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.blurProg);
gl.deleteFramebuffer(this.lightFboA);
gl.deleteFramebuffer(this.lightFboB);
gl.deleteTexture(this.lightTexA);
gl.deleteTexture(this.lightTexB);
gl.deleteVertexArray(this.quadVao);
// pointLightPass and falloutLightPass disposed by renderer
}
}
@@ -0,0 +1,115 @@
/**
* MoveIndicatorPass — converging chevron animation at a warship's
* move-target location. Matches the upstream game's MoveIndicatorUI
* but rendered via SDF in a fragment shader.
*/
import type { RenderSettings } from "../render-settings";
import { createProgram } from "../utils/gl-utils";
import fragSrc from "../shaders/move-indicator/move-indicator.frag.glsl?raw";
import vertSrc from "../shaders/move-indicator/move-indicator.vert.glsl?raw";
export class MoveIndicatorPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private uCamera: WebGLUniformLocation;
private uCenter: WebGLUniformLocation;
private uElapsed: WebGLUniformLocation;
private uColor: WebGLUniformLocation;
private uPxPerTile: WebGLUniformLocation;
private uStartRadius: WebGLUniformLocation;
private uChevronSize: WebGLUniformLocation;
private uLineWidth: WebGLUniformLocation;
private uDuration: WebGLUniformLocation;
private uConverge: WebGLUniformLocation;
private active = false;
private centerX = 0;
private centerY = 0;
private colorR = 1;
private colorG = 0;
private colorB = 0;
private startTime = 0;
constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
this.gl = gl;
this.settings = settings;
this.program = createProgram(gl, vertSrc, fragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uCenter = gl.getUniformLocation(this.program, "uCenter")!;
this.uElapsed = gl.getUniformLocation(this.program, "uElapsed")!;
this.uColor = gl.getUniformLocation(this.program, "uColor")!;
this.uPxPerTile = gl.getUniformLocation(this.program, "uPxPerTile")!;
this.uStartRadius = gl.getUniformLocation(this.program, "uStartRadius")!;
this.uChevronSize = gl.getUniformLocation(this.program, "uChevronSize")!;
this.uLineWidth = gl.getUniformLocation(this.program, "uLineWidth")!;
this.uDuration = gl.getUniformLocation(this.program, "uDuration")!;
this.uConverge = gl.getUniformLocation(this.program, "uConverge")!;
// Unit quad [0,1]
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const buf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
}
/**
* Trigger the move indicator at world tile (x, y) with player color.
* Each call replaces the previous indicator.
*/
show(x: number, y: number, r: number, g: number, b: number): void {
this.active = true;
this.centerX = x;
this.centerY = y;
this.colorR = r;
this.colorG = g;
this.colorB = b;
this.startTime = performance.now();
}
draw(cameraMatrix: Float32Array, zoom: number): void {
if (!this.active) return;
const s = this.settings.moveIndicator;
const elapsed = performance.now() - this.startTime;
if (elapsed >= s.duration) {
this.active = false;
return;
}
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uCenter, this.centerX, this.centerY);
gl.uniform1f(this.uElapsed, elapsed);
gl.uniform3f(this.uColor, this.colorR, this.colorG, this.colorB);
gl.uniform1f(this.uPxPerTile, zoom);
gl.uniform1f(this.uStartRadius, s.startRadius);
gl.uniform1f(this.uChevronSize, s.chevronSize);
gl.uniform1f(this.uLineWidth, s.lineWidth);
gl.uniform1f(this.uDuration, s.duration);
gl.uniform1f(this.uConverge, s.converge);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,86 @@
/**
* Atlas data parsing — extracts font metrics, glyph lookup tables,
* kerning data, and icon atlas index maps from static JSON assets.
*/
import emojiAtlasMeta from "../../assets/emoji-atlas-meta.json";
import flagAtlasMeta from "../../assets/flag-atlas-meta.json";
import atlasData from "../../assets/msdf-atlas.json";
import type { BMChar, BMKerning, ParsedAtlas } from "./types";
import { CHAR_RANGE } from "./types";
// ---------------------------------------------------------------------------
// Atlas parsing
// ---------------------------------------------------------------------------
export function parseAtlasData(): ParsedAtlas {
return {
fontSize: atlasData.info.size,
base: atlasData.common.base,
scaleW: atlasData.common.scaleW,
scaleH: atlasData.common.scaleH,
distanceRange: (atlasData as any).distanceField?.distanceRange ?? 4,
chars: atlasData.chars as BMChar[],
kernings: (atlasData.kernings ?? []) as BMKerning[],
};
}
// ---------------------------------------------------------------------------
// CPU-side glyph lookup tables
// ---------------------------------------------------------------------------
export interface GlyphTables {
advance: Float32Array; // [CHAR_RANGE] — xadvance per char ID
xOffset: Float32Array; // [CHAR_RANGE] — xoffset (left bearing) per char ID
visW: Float32Array; // [CHAR_RANGE] — visible glyph width per char ID
}
export function buildGlyphTables(chars: BMChar[]): GlyphTables {
const advance = new Float32Array(CHAR_RANGE);
const xOffset = new Float32Array(CHAR_RANGE);
const visW = new Float32Array(CHAR_RANGE);
for (const ch of chars) {
if (ch.id < CHAR_RANGE) {
advance[ch.id] = ch.xadvance;
xOffset[ch.id] = ch.xoffset;
visW[ch.id] = ch.width;
}
}
return { advance, xOffset, visW };
}
// ---------------------------------------------------------------------------
// Kerning table (amounts are small integers: typically -7 to +4)
// ---------------------------------------------------------------------------
export function buildKernTable(kernings: BMKerning[]): Int8Array {
const table = new Int8Array(CHAR_RANGE * CHAR_RANGE);
for (const k of kernings) {
if (k.first < CHAR_RANGE && k.second < CHAR_RANGE) {
table[k.first * CHAR_RANGE + k.second] = k.amount;
}
}
return table;
}
// ---------------------------------------------------------------------------
// Icon atlas lookups
// ---------------------------------------------------------------------------
export function buildFlagLookup(): Map<string, number> {
const map = new Map<string, number>();
const meta = flagAtlasMeta as { flags: Record<string, number> };
for (const [code, idx] of Object.entries(meta.flags)) {
map.set(code, idx);
}
return map;
}
export function buildEmojiLookup(): Map<string, number> {
const map = new Map<string, number>();
const meta = emojiAtlasMeta as { emojis: Record<string, number> };
for (const [ch, idx] of Object.entries(meta.emojis)) {
map.set(ch, idx);
}
return map;
}
@@ -0,0 +1,89 @@
/**
* Data texture factories for the NamePass subsystem.
* Uses createTexture2D from gl-utils to eliminate boilerplate.
*/
import { createTexture2D } from "../../utils/gl-utils";
import type { ParsedAtlas } from "./types";
import { CHAR_RANGE, LINES_PER_PLAYER, MAX_CHARS } from "./types";
/** Glyph metrics: CHAR_RANGE x 2, RGBA32F. Static — uploaded once. */
export function buildGlyphMetricsTex(
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
): WebGLTexture {
const data = new Float32Array(CHAR_RANGE * 2 * 4);
for (const ch of atlas.chars) {
if (ch.id >= CHAR_RANGE) continue;
// Row 0: xadvance, xoffset, yoffset, width
const r0 = ch.id * 4;
data[r0 + 0] = ch.xadvance;
data[r0 + 1] = ch.xoffset;
data[r0 + 2] = ch.yoffset;
data[r0 + 3] = ch.width;
// Row 1: height, atlasU0, atlasV0, atlasU1
const r1 = (CHAR_RANGE + ch.id) * 4;
data[r1 + 0] = ch.height;
data[r1 + 1] = ch.x / atlas.scaleW;
data[r1 + 2] = ch.y / atlas.scaleH;
data[r1 + 3] = (ch.x + ch.width) / atlas.scaleW;
// v1 is computed in shader as v0 + height/scaleH
}
return createTexture2D(gl, {
width: CHAR_RANGE,
height: 2,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data,
});
}
/** Cursor positions: MAX_CHARS x (maxPlayers * LINES_PER_PLAYER), R32F. Dynamic. */
export function buildCursorTex(
gl: WebGL2RenderingContext,
maxPlayers: number,
): WebGLTexture {
const height = maxPlayers * LINES_PER_PLAYER;
return createTexture2D(gl, {
width: MAX_CHARS,
height,
internalFormat: gl.R32F,
format: gl.RED,
type: gl.FLOAT,
data: new Float32Array(MAX_CHARS * height),
});
}
/** String data: MAX_CHARS x (maxPlayers * LINES_PER_PLAYER), R8UI. Dynamic. */
export function buildStringTex(
gl: WebGL2RenderingContext,
maxPlayers: number,
): WebGLTexture {
const height = maxPlayers * LINES_PER_PLAYER;
return createTexture2D(gl, {
width: MAX_CHARS,
height,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: new Uint8Array(MAX_CHARS * height),
});
}
/** Player data: 8 x maxPlayers, RGBA32F. Dynamic. */
export function buildPlayerDataTex(
gl: WebGL2RenderingContext,
maxPlayers: number,
): WebGLTexture {
return createTexture2D(gl, {
width: 8,
height: maxPlayers,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data: new Float32Array(8 * maxPlayers * 4),
});
}
@@ -0,0 +1,91 @@
/**
* DebugProgram — wireframe bounding boxes for name/flag layout debugging.
*
* Owns: shader program, uniform locations.
* The shared playerDataTex is passed in but not owned/deleted.
*/
import flagAtlasMeta from "../../assets/flag-atlas-meta.json";
import type { RenderSettings } from "../../render-settings";
import debugBoxFragSrc from "../../shaders/name/debug-box.frag.glsl?raw";
import debugBoxVertSrc from "../../shaders/name/debug-box.vert.glsl?raw";
import { createProgram } from "../../utils/gl-utils";
import type { ParsedAtlas } from "./types";
export class DebugProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private playerDataTex: WebGLTexture;
private maxPlayers: number;
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
private uNameScaleCap: WebGLUniformLocation;
constructor(
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
playerDataTex: WebGLTexture,
maxPlayers: number,
) {
this.gl = gl;
this.playerDataTex = playerDataTex;
this.maxPlayers = maxPlayers;
const fm = flagAtlasMeta as any;
this.program = createProgram(gl, debugBoxVertSrc, debugBoxFragSrc);
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0);
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
atlas.fontSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!;
this.uCullThreshold = gl.getUniformLocation(
this.program,
"uCullThreshold",
)!;
this.uNameScaleFactor = gl.getUniformLocation(
this.program,
"uNameScaleFactor",
)!;
this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!;
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
vao: WebGLVertexArrayObject,
): void {
const gl = this.gl;
const ns = settings.name;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, performance.now() / 1000);
gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed);
gl.uniform1f(this.uCullThreshold, ns.cullThreshold);
gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor);
gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.bindVertexArray(vao);
// 3 instances per player: name box, flag box, center dot
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.maxPlayers * 3);
}
dispose(): void {
this.gl.deleteProgram(this.program);
}
}
@@ -0,0 +1,186 @@
/**
* IconProgram — instanced flag + emoji icons beside player names.
*
* Owns: shader program, uniform locations, flag atlas + emoji atlas textures.
* The shared playerDataTex is passed in but not owned/deleted.
*/
import emojiAtlasMeta from "../../assets/emoji-atlas-meta.json";
import emojiAtlasUrl from "../../assets/emoji-atlas.png?url";
import flagAtlasMeta from "../../assets/flag-atlas-meta.json";
import flagAtlasUrl from "../../assets/flag-atlas.png?url";
import type { RenderSettings } from "../../render-settings";
import iconFragSrc from "../../shaders/name/icon.frag.glsl?raw";
import iconVertSrc from "../../shaders/name/icon.vert.glsl?raw";
import { createProgram } from "../../utils/gl-utils";
import type { ParsedAtlas } from "./types";
export class IconProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private playerDataTex: WebGLTexture;
private maxPlayers: number;
private flagAtlasTex: WebGLTexture | null = null;
private emojiAtlasTex: WebGLTexture | null = null;
private iconsReady = false;
// Dynamic uniform locations
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
private uNameScaleCap: WebGLUniformLocation;
private uEmojiRowOffset: WebGLUniformLocation;
constructor(
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
playerDataTex: WebGLTexture,
maxPlayers: number,
) {
this.gl = gl;
this.playerDataTex = playerDataTex;
this.maxPlayers = maxPlayers;
this.program = createProgram(gl, iconVertSrc, iconFragSrc);
gl.useProgram(this.program);
// Texture unit bindings
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uFlagAtlas"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uEmojiAtlas"), 2);
// Static uniforms from atlas metadata
const fm = flagAtlasMeta as any;
const em = emojiAtlasMeta as any;
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
atlas.fontSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCols")!, fm.cols);
gl.uniform1f(gl.getUniformLocation(this.program, "uFlagAtlasW")!, fm.width);
gl.uniform1f(
gl.getUniformLocation(this.program, "uFlagAtlasH")!,
fm.height,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uEmojiCell")!,
em.cellSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uEmojiCols")!, em.cols);
gl.uniform1f(
gl.getUniformLocation(this.program, "uEmojiAtlasW")!,
em.width,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uEmojiAtlasH")!,
em.height,
);
// Dynamic uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!;
this.uCullThreshold = gl.getUniformLocation(
this.program,
"uCullThreshold",
)!;
this.uNameScaleFactor = gl.getUniformLocation(
this.program,
"uNameScaleFactor",
)!;
this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!;
this.uEmojiRowOffset = gl.getUniformLocation(
this.program,
"uEmojiRowOffset",
)!;
this.loadAtlases();
}
get ready(): boolean {
return this.iconsReady;
}
private loadAtlases(): void {
const gl = this.gl;
const load = (url: string, cb: (tex: WebGLTexture) => void) => {
const img = new Image();
img.onload = () => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
img,
);
gl.generateMipmap(gl.TEXTURE_2D);
cb(tex);
};
img.src = url;
};
load(flagAtlasUrl, (tex) => {
this.flagAtlasTex = tex;
this.iconsReady =
this.flagAtlasTex !== null && this.emojiAtlasTex !== null;
});
load(emojiAtlasUrl, (tex) => {
this.emojiAtlasTex = tex;
this.iconsReady =
this.flagAtlasTex !== null && this.emojiAtlasTex !== null;
});
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
vao: WebGLVertexArrayObject,
): void {
if (!this.iconsReady) return;
const gl = this.gl;
const ns = settings.name;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, performance.now() / 1000);
gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed);
gl.uniform1f(this.uCullThreshold, ns.cullThreshold);
gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor);
gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap);
gl.uniform1f(this.uEmojiRowOffset, ns.emojiRowOffset);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.flagAtlasTex!);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.emojiAtlasTex!);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.maxPlayers * 2);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.flagAtlasTex) gl.deleteTexture(this.flagAtlasTex);
if (this.emojiAtlasTex) gl.deleteTexture(this.emojiAtlasTex);
}
}
@@ -0,0 +1,573 @@
/**
* NamePass — GPU-rendered player names + troop counts using MSDF text.
*
* All text layout, interpolation, and sizing runs on the GPU via instanced
* rendering. CPU cost per frame is effectively zero: one uniform update and
* one instanced draw call. Data changes (position/size targets, troop counts)
* are pushed as tiny texture sub-updates.
*
* Submodules:
* - text-program — MSDF text shader (names + troop counts)
* - icon-program — instanced flag + emoji icons
* - debug-program — wireframe bounding boxes for layout debugging
* - atlas-data — font/atlas parsing + glyph lookup tables
* - text-layout — pure CPU text shaping (cursor positions)
* - data-textures — GL data texture factories
* - types — shared interfaces + constants
*/
import type {
NameEntry,
PlayerState,
PlayerStatic,
PlayerStatusData,
RendererConfig,
} from "../../../types";
import { PlayerTypeEnum } from "../../../types";
import type { RenderSettings } from "../../render-settings";
import { createFullscreenQuad } from "../../utils/gl-utils";
import type { GlyphTables } from "./atlas-data";
import {
buildEmojiLookup,
buildFlagLookup,
buildGlyphTables,
buildKernTable,
parseAtlasData,
} from "./atlas-data";
import {
buildCursorTex,
buildGlyphMetricsTex,
buildPlayerDataTex,
buildStringTex,
} from "./data-textures";
import { DebugProgram } from "./debug-program";
import { IconProgram } from "./icon-program";
import { StatusIconProgram } from "./status-icon-program";
import { formatTroops, layoutString } from "./text-layout";
import { TextProgram } from "./text-program";
import type { PlayerSlot } from "./types";
import { LINES_PER_PLAYER, MAX_CHARS } from "./types";
export class NamePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
// Shared geometry
private vao: WebGLVertexArrayObject;
// Shared data textures
private glyphMetricsTex: WebGLTexture;
private cursorTex: WebGLTexture;
private stringTex: WebGLTexture;
private playerDataTex: WebGLTexture;
// Sub-programs
private textProgram: TextProgram;
private iconProgram: IconProgram;
private statusIconProgram: StatusIconProgram;
private debugProgram: DebugProgram;
// Atlas + glyph data
private glyph: GlyphTables;
private kernTable: Int8Array;
// Player management
private playerByID: Map<string, PlayerStatic>;
private smallIDToPlayerID: Map<number, string>;
private slots: Map<string, PlayerSlot> = new Map();
private maxPlayers: number;
private playerColors: Map<string, [number, number, number]> = new Map();
private flagCodeToIndex: Map<string, number>;
private emojiCharToIndex: Map<string, number>;
// CPU-side mirrors — batched upload in draw()
private cpuPlayerData: Float32Array;
private cpuStringData: Uint8Array;
private cpuCursorData: Float32Array;
private playerDataDirty = false;
private stringDataDirty = false;
private cursorDataDirty = false;
// Reusable buffers for text layout
private stringRow: Uint8Array;
private cursorRow: Float32Array;
// Reusable per-tick lookup maps (avoid allocation + GC)
private alivePlayerIDs = new Set<string>();
private troopsByPlayerID = new Map<string, number>();
private playerStateByID = new Map<string, PlayerState>();
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
paletteData: Float32Array,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.maxPlayers = header.maxPlayers ?? header.players.length;
// Parse atlas + build CPU lookup tables
const atlas = parseAtlasData();
this.glyph = buildGlyphTables(atlas.chars);
this.kernTable = buildKernTable(atlas.kernings);
this.flagCodeToIndex = buildFlagLookup();
this.emojiCharToIndex = buildEmojiLookup();
// Build player lookups and extract territory colors from palette
this.playerByID = new Map();
this.smallIDToPlayerID = new Map();
for (const p of header.players) {
this.playerByID.set(p.id, p);
this.smallIDToPlayerID.set(p.smallID, p.id);
const off = p.smallID * 4;
this.playerColors.set(p.id, [
paletteData[off],
paletteData[off + 1],
paletteData[off + 2],
]);
}
// CPU-side texture mirrors + reusable layout buffers
const textRows = this.maxPlayers * LINES_PER_PLAYER;
this.cpuPlayerData = new Float32Array(8 * this.maxPlayers * 4);
this.cpuStringData = new Uint8Array(MAX_CHARS * textRows);
this.cpuCursorData = new Float32Array(MAX_CHARS * textRows);
this.stringRow = new Uint8Array(MAX_CHARS);
this.cursorRow = new Float32Array(MAX_CHARS);
// Shared VAO (unit [0,1]² quad)
this.vao = createFullscreenQuad(gl);
// Data textures
this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas);
this.cursorTex = buildCursorTex(gl, this.maxPlayers);
this.stringTex = buildStringTex(gl, this.maxPlayers);
this.playerDataTex = buildPlayerDataTex(gl, this.maxPlayers);
// Sub-programs
this.textProgram = new TextProgram(gl, atlas, {
glyphMetrics: this.glyphMetricsTex,
cursor: this.cursorTex,
strings: this.stringTex,
playerData: this.playerDataTex,
});
this.iconProgram = new IconProgram(
gl,
atlas,
this.playerDataTex,
this.maxPlayers,
);
this.statusIconProgram = new StatusIconProgram(
gl,
atlas,
this.playerDataTex,
this.maxPlayers,
);
this.debugProgram = new DebugProgram(
gl,
atlas,
this.playerDataTex,
this.maxPlayers,
);
}
// -------------------------------------------------------------------------
// Late player registration (bots arrive on tick 1)
// -------------------------------------------------------------------------
/** Register players that arrived after construction (palette already updated). */
addPlayers(players: PlayerStatic[], paletteData: Float32Array): void {
for (const p of players) {
if (this.playerByID.has(p.id)) continue;
this.playerByID.set(p.id, p);
this.smallIDToPlayerID.set(p.smallID, p.id);
const off = p.smallID * 4;
this.playerColors.set(p.id, [
paletteData[off],
paletteData[off + 1],
paletteData[off + 2],
]);
}
}
// -------------------------------------------------------------------------
// Name updates — called by GPURenderer
// -------------------------------------------------------------------------
updateNames(
names: Map<string, NameEntry>,
players: Map<number, PlayerState>,
snap: boolean,
statusData?: Map<number, PlayerStatusData>,
): void {
const now = performance.now() / 1000;
// Build alive set and emoji lookup from smallID → playerID
const alivePlayerIDs = this.alivePlayerIDs;
alivePlayerIDs.clear();
const troopsByPlayerID = this.troopsByPlayerID;
troopsByPlayerID.clear();
const playerStateByID = this.playerStateByID;
playerStateByID.clear();
for (const [, ps] of players) {
const pid = this.smallIDToPlayerID.get(ps.smallID);
if (!pid) continue;
if (ps.isAlive) alivePlayerIDs.add(pid);
troopsByPlayerID.set(pid, ps.troops ?? 0);
playerStateByID.set(pid, ps);
}
// Assign slot indices to players (stable ordering by header index)
let nextSlotIndex = 0;
for (const p of this.playerByID.values()) {
if (!this.slots.has(p.id)) {
const flagCode = p.flag;
this.slots.set(p.id, {
index: nextSlotIndex++,
playerID: p.id,
static: p,
srcX: 0,
srcY: 0,
srcScale: 0,
tgtX: 0,
tgtY: 0,
tgtScale: 0,
startTime: now,
alive: false,
nameLen: 0,
troopLen: 0,
lastTroopStr: "",
flagAtlasIdx: flagCode
? (this.flagCodeToIndex.get(flagCode) ?? -1)
: -1,
emojiAtlasIdx: -1,
nameHalfWidth: 0,
crown: false,
traitor: false,
disconnected: false,
alliance: false,
allianceReq: false,
target: false,
embargo: false,
nukeActive: false,
nukeTargetsMe: false,
traitorRemainingTicks: 0,
allianceFraction: 0,
});
} else {
nextSlotIndex = Math.max(
nextSlotIndex,
this.slots.get(p.id)!.index + 1,
);
}
}
for (const [playerID, entry] of names) {
const slot = this.slots.get(playerID);
if (!slot) continue;
const alive = alivePlayerIDs.has(playerID);
// Skip dead players already marked dead — no work needed
if (!alive && !slot.alive) continue;
// Newly dead: mark and write once, then skip expensive work
if (!alive && slot.alive) {
slot.alive = false;
this.writePlayerDataRow(slot);
continue;
}
// Track whether anything changed that requires a GPU write
let dirty = !slot.alive; // first time alive → must write
slot.alive = alive;
// Write name string (only on first encounter)
if (slot.nameLen === 0) {
const name = slot.static.displayName;
slot.nameLen = Math.min(name.length, MAX_CHARS);
slot.nameHalfWidth = this.uploadStringRow(
slot.index * LINES_PER_PLAYER,
name,
);
dirty = true;
}
// Write troop count string (only if changed)
const troops = troopsByPlayerID.get(playerID) ?? 0;
const troopStr = formatTroops(troops);
if (troopStr !== slot.lastTroopStr) {
slot.troopLen = Math.min(troopStr.length, MAX_CHARS);
slot.lastTroopStr = troopStr;
this.uploadStringRow(slot.index * LINES_PER_PLAYER + 1, troopStr);
dirty = true;
}
// Check if target position changed — only then recompute lerp source
if (
entry.x !== slot.tgtX ||
entry.y !== slot.tgtY ||
entry.size !== slot.tgtScale
) {
if (!snap) {
const elapsed = now - slot.startTime;
const t = Math.min(
1 - Math.exp(-this.settings.name.lerpSpeed * elapsed),
1,
);
slot.srcX = slot.srcX + (slot.tgtX - slot.srcX) * t;
slot.srcY = slot.srcY + (slot.tgtY - slot.srcY) * t;
slot.srcScale = slot.srcScale + (slot.tgtScale - slot.srcScale) * t;
} else {
slot.srcX = entry.x;
slot.srcY = entry.y;
slot.srcScale = entry.size;
}
slot.tgtX = entry.x;
slot.tgtY = entry.y;
slot.tgtScale = entry.size;
slot.startTime = now;
dirty = true;
}
// Resolve active broadcast emoji for this player
let newEmoji = -1;
const ps = playerStateByID.get(playerID);
if (ps?.outgoingEmojis && ps.outgoingEmojis.length > 0) {
for (const e of ps.outgoingEmojis) {
if (e.recipientID === "AllPlayers") {
const idx = this.emojiCharToIndex.get(e.message);
if (idx !== undefined) {
newEmoji = idx;
break;
}
}
}
}
if (newEmoji !== slot.emojiAtlasIdx) {
slot.emojiAtlasIdx = newEmoji;
dirty = true;
}
// Resolve status data from per-player map — diff each field
const sd = statusData?.get(slot.static.smallID);
const crown = sd?.crown ?? false;
const traitor = sd?.traitor ?? false;
const disconnected = sd?.disconnected ?? false;
const alliance = sd?.alliance ?? false;
const allianceReq = sd?.allianceReq ?? false;
const target = sd?.target ?? false;
const embargo = sd?.embargo ?? false;
const nukeActive = sd?.nukeActive ?? false;
const nukeTargetsMe = sd?.nukeTargetsMe ?? false;
const traitorRemainingTicks = sd?.traitorRemainingTicks ?? 0;
const allianceFraction = sd?.allianceFraction ?? 0;
if (
crown !== slot.crown ||
traitor !== slot.traitor ||
disconnected !== slot.disconnected ||
alliance !== slot.alliance ||
allianceReq !== slot.allianceReq ||
target !== slot.target ||
embargo !== slot.embargo ||
nukeActive !== slot.nukeActive ||
nukeTargetsMe !== slot.nukeTargetsMe ||
traitorRemainingTicks !== slot.traitorRemainingTicks ||
allianceFraction !== slot.allianceFraction
) {
slot.crown = crown;
slot.traitor = traitor;
slot.disconnected = disconnected;
slot.alliance = alliance;
slot.allianceReq = allianceReq;
slot.target = target;
slot.embargo = embargo;
slot.nukeActive = nukeActive;
slot.nukeTargetsMe = nukeTargetsMe;
slot.traitorRemainingTicks = traitorRemainingTicks;
slot.allianceFraction = allianceFraction;
dirty = true;
}
if (dirty) this.writePlayerDataRow(slot);
}
// Update alive/dead status for players not in the names map
for (const [pid, slot] of this.slots) {
if (!names.has(pid) && slot.alive) {
slot.alive = false;
this.writePlayerDataRow(slot);
}
}
}
// -------------------------------------------------------------------------
// Texture sub-update helpers
// -------------------------------------------------------------------------
/** Lay out a string into CPU buffers (flushed to GPU in draw). Returns halfWidth. */
private uploadStringRow(row: number, text: string): number {
const halfWidth = layoutString(
text,
this.glyph,
this.kernTable,
this.stringRow,
this.cursorRow,
);
const off = row * MAX_CHARS;
this.cpuStringData.set(this.stringRow, off);
this.cpuCursorData.set(this.cursorRow, off);
this.stringDataDirty = true;
this.cursorDataDirty = true;
return halfWidth;
}
/** Pack player data into the CPU buffer (flushed to GPU in draw). */
private writePlayerDataRow(slot: PlayerSlot): void {
const d = this.cpuPlayerData;
const off = slot.index * 32; // 8 columns × 4 floats per RGBA texel
// Column 0: srcX, srcY, srcScale, startTime
d[off + 0] = slot.srcX;
d[off + 1] = slot.srcY;
d[off + 2] = slot.srcScale;
d[off + 3] = slot.startTime;
// Column 1: tgtX, tgtY, tgtScale, alive
d[off + 4] = slot.tgtX;
d[off + 5] = slot.tgtY;
d[off + 6] = slot.tgtScale;
d[off + 7] = slot.alive ? 1.0 : 0.0;
// Column 2: player territory color (r, g, b) + alpha
const color = this.playerColors.get(slot.playerID) ?? [0, 0, 0];
d[off + 8] = color[0];
d[off + 9] = color[1];
d[off + 10] = color[2];
d[off + 11] = 1.0;
// Column 3: nameLen, troopLen, isHuman, nameHalfWidth
d[off + 12] = slot.nameLen;
d[off + 13] = slot.troopLen;
d[off + 14] = slot.static.playerType === PlayerTypeEnum.Human ? 1.0 : 0.0;
d[off + 15] = slot.nameHalfWidth;
// Column 4: flagAtlasIdx, emojiAtlasIdx, [free], [free]
d[off + 16] = slot.flagAtlasIdx;
d[off + 17] = slot.emojiAtlasIdx;
d[off + 18] = 0;
d[off + 19] = 0;
// Column 5: crown, traitor, disconnected, alliance
d[off + 20] = slot.crown ? 1.0 : 0.0;
d[off + 21] = slot.traitor ? 1.0 : 0.0;
d[off + 22] = slot.disconnected ? 1.0 : 0.0;
d[off + 23] = slot.alliance ? 1.0 : 0.0;
// Column 6: allianceReq, target, embargo, nukeActive
d[off + 24] = slot.allianceReq ? 1.0 : 0.0;
d[off + 25] = slot.target ? 1.0 : 0.0;
d[off + 26] = slot.embargo ? 1.0 : 0.0;
d[off + 27] = slot.nukeActive ? 1.0 : 0.0;
// Column 7: nukeTargetsMe, traitorRemainingTicks, allianceFraction, [free]
d[off + 28] = slot.nukeTargetsMe ? 1.0 : 0.0;
d[off + 29] = slot.traitorRemainingTicks;
d[off + 30] = slot.allianceFraction;
d[off + 31] = 0;
this.playerDataDirty = true;
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
draw(cameraMatrix: Float32Array, ambient: number): void {
if (!this.textProgram.ready) return;
if (this.slots.size === 0) return;
const gl = this.gl;
if (this.stringDataDirty) {
gl.bindTexture(gl.TEXTURE_2D, this.stringTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
MAX_CHARS,
this.maxPlayers * LINES_PER_PLAYER,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.cpuStringData,
);
this.stringDataDirty = false;
}
if (this.cursorDataDirty) {
gl.bindTexture(gl.TEXTURE_2D, this.cursorTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
MAX_CHARS,
this.maxPlayers * LINES_PER_PLAYER,
gl.RED,
gl.FLOAT,
this.cpuCursorData,
);
this.cursorDataDirty = false;
}
if (this.playerDataDirty) {
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
8,
this.maxPlayers,
gl.RGBA,
gl.FLOAT,
this.cpuPlayerData,
);
this.playerDataDirty = false;
}
this.textProgram.draw(
cameraMatrix,
this.settings,
this.vao,
this.maxPlayers,
ambient,
);
this.iconProgram.draw(cameraMatrix, this.settings, this.vao);
this.statusIconProgram.draw(cameraMatrix, this.settings, this.vao);
if (this.settings.passEnabled.nameDebug) {
this.debugProgram.draw(cameraMatrix, this.settings, this.vao);
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
dispose(): void {
const gl = this.gl;
this.textProgram.dispose();
this.iconProgram.dispose();
this.statusIconProgram.dispose();
this.debugProgram.dispose();
gl.deleteTexture(this.glyphMetricsTex);
gl.deleteTexture(this.cursorTex);
gl.deleteTexture(this.stringTex);
gl.deleteTexture(this.playerDataTex);
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,163 @@
/**
* StatusIconProgram — instanced status icons above player names.
*
* Renders up to 8 status icons per player (crown, traitor, disconnected,
* alliance, alliance request, target, embargo, nuke). Each instance reads
* individual float flags from pd5/pd6 to decide whether to draw.
*
* Owns: shader program, uniform locations, status atlas texture.
* The shared playerDataTex is passed in but not owned/deleted.
*/
import statusAtlasMeta from "../../assets/status-atlas-meta.json";
import statusAtlasUrl from "../../assets/status-atlas.png?url";
import type { RenderSettings } from "../../render-settings";
import statusFragSrc from "../../shaders/name/status-icon.frag.glsl?raw";
import statusVertSrc from "../../shaders/name/status-icon.vert.glsl?raw";
import { createProgram } from "../../utils/gl-utils";
import type { ParsedAtlas } from "./types";
const MAX_STATUS_ICONS = 8;
export class StatusIconProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private playerDataTex: WebGLTexture;
private maxPlayers: number;
private statusAtlasTex: WebGLTexture | null = null;
private atlasReady = false;
// Dynamic uniform locations
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
private uNameScaleCap: WebGLUniformLocation;
private uStatusRowOffset: WebGLUniformLocation;
constructor(
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
playerDataTex: WebGLTexture,
maxPlayers: number,
) {
this.gl = gl;
this.playerDataTex = playerDataTex;
this.maxPlayers = maxPlayers;
this.program = createProgram(gl, statusVertSrc, statusFragSrc);
gl.useProgram(this.program);
// Texture unit bindings
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uStatusAtlas"), 1);
// Static uniforms from atlas metadata
const sm = statusAtlasMeta as any;
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
atlas.fontSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base);
gl.uniform1f(
gl.getUniformLocation(this.program, "uStatusCell")!,
sm.cellSize,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uStatusCols")!, sm.cols);
gl.uniform1f(
gl.getUniformLocation(this.program, "uStatusAtlasW")!,
sm.width,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uStatusAtlasH")!,
sm.height,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uStatusPad")!,
sm.pad ?? 0,
);
// Dynamic uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!;
this.uCullThreshold = gl.getUniformLocation(
this.program,
"uCullThreshold",
)!;
this.uNameScaleFactor = gl.getUniformLocation(
this.program,
"uNameScaleFactor",
)!;
this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!;
this.uStatusRowOffset = gl.getUniformLocation(
this.program,
"uStatusRowOffset",
)!;
this.loadAtlas();
}
private loadAtlas(): void {
const gl = this.gl;
const img = new Image();
img.onload = () => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.generateMipmap(gl.TEXTURE_2D);
this.statusAtlasTex = tex;
this.atlasReady = true;
};
img.src = statusAtlasUrl;
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
vao: WebGLVertexArrayObject,
): void {
if (!this.atlasReady) return;
const gl = this.gl;
const ns = settings.name;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, performance.now() / 1000);
gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed);
gl.uniform1f(this.uCullThreshold, ns.cullThreshold);
gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor);
gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap);
gl.uniform1f(this.uStatusRowOffset, ns.statusRowOffset);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.statusAtlasTex!);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(
gl.TRIANGLES,
0,
6,
this.maxPlayers * MAX_STATUS_ICONS,
);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.statusAtlasTex) gl.deleteTexture(this.statusAtlasTex);
}
}
@@ -0,0 +1,74 @@
/**
* Pure CPU text shaping — cursor position computation and number formatting.
* No WebGL dependency.
*/
import type { GlyphTables } from "./atlas-data";
import { CHAR_RANGE, MAX_CHARS } from "./types";
export interface LayoutResult {
charCodes: Uint8Array; // char code per slot (MAX_CHARS, zero-padded)
cursors: Float32Array; // centered cursor X per slot (MAX_CHARS)
halfWidth: number; // visual half-width in font units
}
/**
* Lay out a string: encode char codes, compute advance-based cursor X
* positions, then center on visual bounds.
*
* Writes into caller-provided buffers to avoid allocation.
*/
export function layoutString(
text: string,
glyph: GlyphTables,
kernTable: Int8Array,
charCodes: Uint8Array,
cursors: Float32Array,
): number {
charCodes.fill(0);
cursors.fill(0);
const len = Math.min(text.length, MAX_CHARS);
for (let i = 0; i < len; i++) {
charCodes[i] = text.charCodeAt(i);
}
// Advance-based cursor positions
let cumulative = 0;
let prevCode = 0;
for (let i = 0; i < len; i++) {
const code = charCodes[i];
cursors[i] = cumulative;
let adv = glyph.advance[code];
if (i > 0) {
adv += kernTable[prevCode * CHAR_RANGE + code];
}
cumulative += adv;
prevCode = code;
}
// Center on visual bounds (not advance bounds)
const firstCode = charCodes[0];
const lastCode = charCodes[len - 1];
const visualLeft = cursors[0] + glyph.xOffset[firstCode];
const visualRight =
cursors[len - 1] + glyph.xOffset[lastCode] + glyph.visW[lastCode];
const visualCenter = (visualLeft + visualRight) * 0.5;
for (let i = 0; i < len; i++) {
cursors[i] -= visualCenter;
}
return (visualRight - visualLeft) * 0.5;
}
/** Format internal troop count for display (internal values are 10x display). */
export function formatTroops(internalTroops: number): string {
const troops = internalTroops / 10;
if (troops >= 1_000_000) {
return (troops / 1_000_000).toFixed(1) + "M";
}
if (troops >= 1_000) {
return (troops / 1_000).toFixed(1) + "K";
}
return troops.toFixed(0);
}
@@ -0,0 +1,197 @@
/**
* TextProgram — MSDF text rendering (player names + troop counts).
*
* Owns: shader program, uniform locations, MSDF atlas texture (async loaded).
* Shared textures (glyphMetrics, cursor, strings, playerData) are passed in
* and bound at draw time but not owned/deleted by this class.
*/
import atlasUrl from "../../assets/msdf-atlas.png?url";
import type { RenderSettings } from "../../render-settings";
import nameFragSrc from "../../shaders/name/name.frag.glsl?raw";
import nameVertSrc from "../../shaders/name/name.vert.glsl?raw";
import { createProgram, shaderSrc } from "../../utils/gl-utils";
import type { ParsedAtlas } from "./types";
import { LINES_PER_PLAYER, MAX_CHARS } from "./types";
export interface TextProgramTextures {
glyphMetrics: WebGLTexture;
cursor: WebGLTexture;
strings: WebGLTexture;
playerData: WebGLTexture;
}
export class TextProgram {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private textures: TextProgramTextures;
// Async-loaded MSDF atlas
private atlasTex: WebGLTexture | null = null;
private atlasReady = false;
// Uniform locations
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uDistRange: WebGLUniformLocation;
private uLerpSpeed: WebGLUniformLocation;
private uCullThreshold: WebGLUniformLocation;
private uNameScaleFactor: WebGLUniformLocation;
private uNameScaleCap: WebGLUniformLocation;
private uTroopSizeMultiplier: WebGLUniformLocation;
private uOutlineWidth: WebGLUniformLocation;
private uNightAmbient: WebGLUniformLocation;
private uOutlineColor: WebGLUniformLocation;
private uOutlineUsePlayerColor: WebGLUniformLocation;
private uFillUsePlayerColor: WebGLUniformLocation;
private distanceRange: number;
constructor(
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
textures: TextProgramTextures,
) {
this.gl = gl;
this.textures = textures;
this.distanceRange = atlas.distanceRange;
this.program = createProgram(
gl,
shaderSrc(nameVertSrc, { MAX_CHARS, LINES_PER_PLAYER }),
nameFragSrc,
);
// Texture unit bindings
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uCursorX"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uStrings"), 3);
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 4);
// Static uniforms
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
atlas.fontSize,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uAtlasScaleW")!,
atlas.scaleW,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uAtlasScaleH")!,
atlas.scaleH,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, atlas.base);
// Dynamic uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!;
this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!;
this.uCullThreshold = gl.getUniformLocation(
this.program,
"uCullThreshold",
)!;
this.uNameScaleFactor = gl.getUniformLocation(
this.program,
"uNameScaleFactor",
)!;
this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!;
this.uTroopSizeMultiplier = gl.getUniformLocation(
this.program,
"uTroopSizeMultiplier",
)!;
this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!;
this.uNightAmbient = gl.getUniformLocation(this.program, "uNightAmbient")!;
this.uOutlineColor = gl.getUniformLocation(this.program, "uOutlineColor")!;
this.uOutlineUsePlayerColor = gl.getUniformLocation(
this.program,
"uOutlineUsePlayerColor",
)!;
this.uFillUsePlayerColor = gl.getUniformLocation(
this.program,
"uFillUsePlayerColor",
)!;
this.loadAtlas();
}
get ready(): boolean {
return this.atlasReady;
}
private loadAtlas(): void {
const gl = this.gl;
const img = new Image();
img.onload = () => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
this.atlasTex = tex;
this.atlasReady = true;
};
img.src = atlasUrl;
}
draw(
cameraMatrix: Float32Array,
settings: RenderSettings,
vao: WebGLVertexArrayObject,
maxPlayers: number,
ambient: number,
): void {
if (!this.atlasReady) return;
const gl = this.gl;
const ns = settings.name;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, performance.now() / 1000);
gl.uniform1f(this.uDistRange, this.distanceRange);
gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed);
gl.uniform1f(this.uCullThreshold, ns.cullThreshold);
gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor);
gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap);
gl.uniform1f(this.uTroopSizeMultiplier, ns.troopSizeMultiplier);
gl.uniform1f(this.uOutlineWidth, ns.outlineWidth);
gl.uniform1f(this.uNightAmbient, ambient);
gl.uniform3f(this.uOutlineColor, ns.outlineR, ns.outlineG, ns.outlineB);
gl.uniform1f(
this.uOutlineUsePlayerColor,
ns.outlineUsePlayerColor ? 1.0 : 0.0,
);
gl.uniform1f(this.uFillUsePlayerColor, ns.fillUsePlayerColor ? 1.0 : 0.0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.textures.glyphMetrics);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.textures.cursor);
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.textures.strings);
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D, this.textures.playerData);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(
gl.TRIANGLES,
0,
6,
maxPlayers * LINES_PER_PLAYER * MAX_CHARS,
);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
}
}
@@ -0,0 +1,88 @@
/**
* Shared types and constants for the NamePass subsystem.
*/
// ---------------------------------------------------------------------------
// BMFont JSON types
// ---------------------------------------------------------------------------
export interface BMChar {
id: number;
char: string;
width: number;
height: number;
xoffset: number;
yoffset: number;
xadvance: number;
x: number;
y: number;
page: number;
}
export interface BMKerning {
first: number;
second: number;
amount: number;
}
export interface ParsedAtlas {
fontSize: number;
base: number;
scaleW: number;
scaleH: number;
distanceRange: number;
chars: BMChar[];
kernings: BMKerning[];
}
// ---------------------------------------------------------------------------
// Per-player CPU-side state
// ---------------------------------------------------------------------------
export interface PlayerSlot {
index: number;
playerID: string;
static: import("../../../types").PlayerStatic;
srcX: number;
srcY: number;
srcScale: number;
tgtX: number;
tgtY: number;
tgtScale: number;
startTime: number;
alive: boolean;
nameLen: number;
troopLen: number;
lastTroopStr: string;
flagAtlasIdx: number;
emojiAtlasIdx: number;
nameHalfWidth: number;
// Status flags (individual booleans, written as 1.0/0.0 to GPU)
crown: boolean;
traitor: boolean;
disconnected: boolean;
alliance: boolean;
allianceReq: boolean;
target: boolean;
embargo: boolean;
nukeActive: boolean;
nukeTargetsMe: boolean;
traitorRemainingTicks: number;
allianceFraction: number;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Max char ID in the atlas (Latin Extended-A goes to 383). */
export const CHAR_RANGE = 384;
/** Max characters per text line (name or troop count). */
export const MAX_CHARS = 32;
/** Lines per player: 0 = name, 1 = troop count. */
export const LINES_PER_PLAYER = 2;
@@ -0,0 +1,114 @@
/**
* NightCompositePass — scene capture + day/night composite.
*
* Owns the scene capture FBO: terrain + territory render into it when
* day/night is enabled. Composites the captured scene with a blurred
* lightmap: output = scene * min(ambient + lightmap, 1.2).
*
* At full daytime (ambient ≈ 1.0) the composite is a visual identity —
* multiplication by ~1.0 — so the pass runs continuously with no threshold.
*/
import type { RenderSettings } from "../render-settings";
import { createFullscreenQuad, createProgram } from "../utils/gl-utils";
import compositeFragSrc from "../shaders/day-night/composite.frag.glsl?raw";
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
export class NightCompositePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
// Composite program
private compositeProg: WebGLProgram;
private uCompositeAmbient: WebGLUniformLocation;
private quadVao: WebGLVertexArrayObject;
constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
this.gl = gl;
this.settings = settings;
// --- Composite program ---
this.compositeProg = createProgram(gl, fullscreenVertSrc, compositeFragSrc);
this.uCompositeAmbient = gl.getUniformLocation(
this.compositeProg,
"uAmbient",
)!;
gl.useProgram(this.compositeProg);
gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uSceneTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uLightTex"), 1);
// --- Fullscreen quad ---
this.quadVao = createFullscreenQuad(gl);
}
// -------------------------------------------------------------------------
// Ambient
// -------------------------------------------------------------------------
getAmbient(tick: number): number {
const dn = this.settings.dayNight;
if (dn.mode === "light") return dn.dayAmbient;
if (dn.mode === "dark") return dn.nightAmbient;
// Normalize phase to [0, 1), 0 = noon
const phase = (((tick / dn.cycleTicks + dn.startPhase) % 1) + 1) % 1;
// Clamp holds so they never exceed the full cycle
const noonHold = Math.min(dn.noonHold, 1);
const nightHold = Math.min(dn.nightHold, Math.max(0, 1 - noonHold));
const halfTransition = (1 - noonHold - nightHold) / 2;
// Region boundaries (all in [0, 1))
const duskStart = noonHold / 2;
const duskEnd = duskStart + halfTransition; // = 0.5 - nightHold/2
const nightEnd = duskEnd + nightHold; // = 0.5 + nightHold/2
const dawnEnd = nightEnd + halfTransition; // = 1 - noonHold/2
let t: number;
if (phase < duskStart || phase >= dawnEnd) {
t = 1; // noon hold
} else if (phase < duskEnd) {
t = smoothstep(duskEnd, duskStart, phase); // day → night
} else if (phase < nightEnd) {
t = 0; // midnight hold
} else {
t = smoothstep(nightEnd, dawnEnd, phase); // night → day
}
return dn.nightAmbient + (dn.dayAmbient - dn.nightAmbient) * t;
}
// -------------------------------------------------------------------------
// Composite: scene * (ambient + lightmap) → screen
// -------------------------------------------------------------------------
/** Pure combiner — receives captured scene + lightmap textures, outputs to screen. */
draw(tick: number, sceneTex: WebGLTexture, lightmapTex: WebGLTexture): void {
const gl = this.gl;
gl.disable(gl.BLEND);
gl.useProgram(this.compositeProg);
gl.uniform1f(this.uCompositeAmbient, this.getAmbient(tick));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, sceneTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, lightmapTex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.compositeProg);
gl.deleteVertexArray(this.quadVao);
}
}
@@ -0,0 +1,152 @@
/**
* NukeTelegraphPass — renders animated blast-radius circles at the target
* location of each in-flight nuke.
*
* Instanced quads with two concentric circle SDFs (inner filled, outer
* dashed ring). Similar to SAMRadiusPass but with different aesthetics.
*/
import type { NukeTelegraphData } from "../../types";
import { DynamicInstanceBuffer } from "../dynamic-buffer";
import type { RenderSettings } from "../render-settings";
import { createProgram } from "../utils/gl-utils";
import fragSrc from "../shaders/nuke-telegraph/nuke-telegraph.frag.glsl?raw";
import vertSrc from "../shaders/nuke-telegraph/nuke-telegraph.vert.glsl?raw";
// Per-instance: x, y, innerRadius, outerRadius
const FLOATS_PER_INSTANCE = 4;
export class NukeTelegraphPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uTelegraphStyle: WebGLUniformLocation;
private uTelegraphAlpha: WebGLUniformLocation;
private uTelegraphColor: WebGLUniformLocation;
private instanceCount = 0;
private startTime = performance.now();
constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
this.gl = gl;
this.settings = settings;
this.program = createProgram(gl, vertSrc, fragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uTelegraphStyle = gl.getUniformLocation(
this.program,
"uTelegraphStyle",
)!;
this.uTelegraphAlpha = gl.getUniformLocation(
this.program,
"uTelegraphAlpha",
)!;
this.uTelegraphColor = gl.getUniformLocation(
this.program,
"uTelegraphColor",
)!;
// VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Attribute 0: unit quad [0,1]
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Attribute 1: per-instance vec4 (x, y, innerR, outerR)
const glBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
glBuf,
16,
FLOATS_PER_INSTANCE,
);
gl.bindBuffer(gl.ARRAY_BUFFER, glBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0);
gl.vertexAttribDivisor(1, 1);
gl.bindVertexArray(null);
}
update(data: NukeTelegraphData[]): void {
const count = data.length;
this.instanceBuf.ensureCapacity(count);
const buf = this.instanceBuf.float32;
for (let i = 0; i < count; i++) {
const d = data[i];
const off = i * FLOATS_PER_INSTANCE;
buf[off + 0] = d.x;
buf[off + 1] = d.y;
buf[off + 2] = d.innerRadius;
buf[off + 3] = d.outerRadius;
}
this.instanceCount = count;
if (count > 0) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
count * FLOATS_PER_INSTANCE,
);
}
}
draw(cameraMatrix: Float32Array): void {
if (this.instanceCount === 0) return;
const gl = this.gl;
const s = this.settings.nukeTelegraph;
const time = (performance.now() - this.startTime) / 1000;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, time);
gl.uniform4f(
this.uTelegraphStyle,
s.strokeWidth,
s.dashLen,
s.gapLen,
s.rotationSpeed,
);
gl.uniform4f(
this.uTelegraphAlpha,
s.baseAlpha,
s.pulseAmplitude,
s.pulseSpeed,
s.fillAlphaOffset,
);
gl.uniform3f(this.uTelegraphColor, s.colorR, s.colorG, s.colorB);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,328 @@
/**
* NukeTrajectoryPass — renders the nuke trajectory preview arc during
* build mode (Atom Bomb / Hydrogen Bomb ghost active).
*
* Renders as a triangle strip with screen-space line width. The cubic
* Bezier is evaluated on the GPU from 4 control-point uniforms; cumulative
* arc distances are pre-computed on the CPU for accurate pixel-space dashing.
*
* Zone boundary circles and SAM intercept X markers are drawn with a
* separate marker program.
*/
import type { NukeTrajectoryData } from "../../types";
import type { RenderSettings } from "../render-settings";
import { createProgram } from "../utils/gl-utils";
import markerFragSrc from "../shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl?raw";
import markerVertSrc from "../shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl?raw";
import fragSrc from "../shaders/nuke-trajectory/nuke-trajectory.frag.glsl?raw";
import vertSrc from "../shaders/nuke-trajectory/nuke-trajectory.vert.glsl?raw";
const NUM_SEGMENTS = 128;
const VERTS_PER_PAIR = 2;
const FLOATS_PER_VERT = 3; // (t, side, cumDist)
export class NukeTrajectoryPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
// Line program
private lineProgram: WebGLProgram;
private lineVAO: WebGLVertexArrayObject;
private lineBuf: WebGLBuffer;
private lineVertexCount: number;
private lineVertices: Float32Array;
private uLineCamera: WebGLUniformLocation;
private uLineP0: WebGLUniformLocation;
private uLineP1: WebGLUniformLocation;
private uLineP2: WebGLUniformLocation;
private uLineP3: WebGLUniformLocation;
private uLinePixelSize: WebGLUniformLocation;
private uLineTUntargetableStart: WebGLUniformLocation;
private uLineTUntargetableEnd: WebGLUniformLocation;
private uLineTSamIntercept: WebGLUniformLocation;
private uLineQuadHalfPx: WebGLUniformLocation;
private uLineLineHalfPx: WebGLUniformLocation;
private uLineOutlineHalfPx: WebGLUniformLocation;
private uLineDashPattern: WebGLUniformLocation;
private uLineLineColor: WebGLUniformLocation;
private uLineInterceptColor: WebGLUniformLocation;
private uLineOutlineColor: WebGLUniformLocation;
private uLineInterceptOutlineColor: WebGLUniformLocation;
// Marker program
private markerProgram: WebGLProgram;
private markerVAO: WebGLVertexArrayObject;
private uMarkerCamera: WebGLUniformLocation;
private uMarkerP0: WebGLUniformLocation;
private uMarkerP1: WebGLUniformLocation;
private uMarkerP2: WebGLUniformLocation;
private uMarkerP3: WebGLUniformLocation;
private uMarkerPixelSize: WebGLUniformLocation;
private uMarker: WebGLUniformLocation;
private uMarkerRadii: WebGLUniformLocation;
private visible = false;
private data: NukeTrajectoryData | null = null;
constructor(gl: WebGL2RenderingContext, settings: RenderSettings) {
this.gl = gl;
this.settings = settings;
// --- Line program ---
this.lineProgram = createProgram(gl, vertSrc, fragSrc);
this.uLineCamera = gl.getUniformLocation(this.lineProgram, "uCamera")!;
this.uLineP0 = gl.getUniformLocation(this.lineProgram, "uP0")!;
this.uLineP1 = gl.getUniformLocation(this.lineProgram, "uP1")!;
this.uLineP2 = gl.getUniformLocation(this.lineProgram, "uP2")!;
this.uLineP3 = gl.getUniformLocation(this.lineProgram, "uP3")!;
this.uLinePixelSize = gl.getUniformLocation(
this.lineProgram,
"uPixelSize",
)!;
this.uLineTUntargetableStart = gl.getUniformLocation(
this.lineProgram,
"uTUntargetableStart",
)!;
this.uLineTUntargetableEnd = gl.getUniformLocation(
this.lineProgram,
"uTUntargetableEnd",
)!;
this.uLineTSamIntercept = gl.getUniformLocation(
this.lineProgram,
"uTSamIntercept",
)!;
this.uLineQuadHalfPx = gl.getUniformLocation(
this.lineProgram,
"uQuadHalfPx",
)!;
this.uLineLineHalfPx = gl.getUniformLocation(
this.lineProgram,
"uLineHalfPx",
)!;
this.uLineOutlineHalfPx = gl.getUniformLocation(
this.lineProgram,
"uOutlineHalfPx",
)!;
this.uLineDashPattern = gl.getUniformLocation(
this.lineProgram,
"uDashPattern",
)!;
this.uLineLineColor = gl.getUniformLocation(
this.lineProgram,
"uLineColor",
)!;
this.uLineInterceptColor = gl.getUniformLocation(
this.lineProgram,
"uInterceptColor",
)!;
this.uLineOutlineColor = gl.getUniformLocation(
this.lineProgram,
"uOutlineColor",
)!;
this.uLineInterceptOutlineColor = gl.getUniformLocation(
this.lineProgram,
"uInterceptOutlineColor",
)!;
// Triangle strip: (N+1) pairs of left/right vertices
const N = NUM_SEGMENTS;
this.lineVertexCount = (N + 1) * VERTS_PER_PAIR;
this.lineVertices = new Float32Array(
this.lineVertexCount * FLOATS_PER_VERT,
);
this.lineVAO = gl.createVertexArray()!;
gl.bindVertexArray(this.lineVAO);
this.lineBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
this.lineVertices.byteLength,
gl.DYNAMIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
// --- Marker program ---
this.markerProgram = createProgram(gl, markerVertSrc, markerFragSrc);
this.uMarkerCamera = gl.getUniformLocation(this.markerProgram, "uCamera")!;
this.uMarkerP0 = gl.getUniformLocation(this.markerProgram, "uP0")!;
this.uMarkerP1 = gl.getUniformLocation(this.markerProgram, "uP1")!;
this.uMarkerP2 = gl.getUniformLocation(this.markerProgram, "uP2")!;
this.uMarkerP3 = gl.getUniformLocation(this.markerProgram, "uP3")!;
this.uMarkerPixelSize = gl.getUniformLocation(
this.markerProgram,
"uPixelSize",
)!;
this.uMarker = gl.getUniformLocation(this.markerProgram, "uMarker")!;
this.uMarkerRadii = gl.getUniformLocation(
this.markerProgram,
"uMarkerRadii",
)!;
this.markerVAO = gl.createVertexArray()!;
gl.bindVertexArray(this.markerVAO);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
}
update(data: NukeTrajectoryData | null): void {
this.data = data;
this.visible = data !== null;
if (data) this.rebuildVertices(data);
}
/** Recompute triangle strip vertices with cumulative arc distances. */
private rebuildVertices(d: NukeTrajectoryData): void {
const N = NUM_SEGMENTS;
const buf = this.lineVertices;
let cumDist = 0;
let prevX = d.p0x;
let prevY = d.p0y;
for (let i = 0; i <= N; i++) {
const t = i / N;
const T = 1 - t;
const TT = T * T;
const tt = t * t;
const x =
TT * T * d.p0x +
3 * TT * t * d.p1x +
3 * T * tt * d.p2x +
tt * t * d.p3x;
const y =
TT * T * d.p0y +
3 * TT * t * d.p1y +
3 * T * tt * d.p2y +
tt * t * d.p3y;
if (i > 0) {
const dx = x - prevX;
const dy = y - prevY;
cumDist += Math.sqrt(dx * dx + dy * dy);
}
prevX = x;
prevY = y;
const idx = i * VERTS_PER_PAIR * FLOATS_PER_VERT;
buf[idx + 0] = t;
buf[idx + 1] = -1;
buf[idx + 2] = cumDist;
buf[idx + 3] = t;
buf[idx + 4] = 1;
buf[idx + 5] = cumDist;
}
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuf);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, buf);
}
draw(cameraMatrix: Float32Array): void {
if (!this.visible || !this.data) return;
const gl = this.gl;
const d = this.data;
const s = this.settings.nukeTrajectory;
const pixelSize = 2.0 / (cameraMatrix[0] * gl.drawingBufferWidth);
// Derived pixel dimensions
const lineHalfPx = s.lineWidth / 2;
const outlineHalfPx = (s.lineWidth + s.outlineWidth) / 2;
const quadHalfPx = outlineHalfPx + 1.0; // AA padding
// --- Draw trajectory line ---
gl.useProgram(this.lineProgram);
gl.uniformMatrix3fv(this.uLineCamera, false, cameraMatrix);
gl.uniform2f(this.uLineP0, d.p0x, d.p0y);
gl.uniform2f(this.uLineP1, d.p1x, d.p1y);
gl.uniform2f(this.uLineP2, d.p2x, d.p2y);
gl.uniform2f(this.uLineP3, d.p3x, d.p3y);
gl.uniform1f(this.uLinePixelSize, pixelSize);
gl.uniform1f(this.uLineTUntargetableStart, d.tUntargetableStart);
gl.uniform1f(this.uLineTUntargetableEnd, d.tUntargetableEnd);
gl.uniform1f(this.uLineTSamIntercept, d.tSamIntercept);
gl.uniform1f(this.uLineQuadHalfPx, quadHalfPx);
gl.uniform1f(this.uLineLineHalfPx, lineHalfPx);
gl.uniform1f(this.uLineOutlineHalfPx, outlineHalfPx);
gl.uniform4f(
this.uLineDashPattern,
s.dashTargetable,
s.gapTargetable,
s.dashUntargetable,
s.gapUntargetable,
);
gl.uniform3f(this.uLineLineColor, s.lineR, s.lineG, s.lineB);
gl.uniform3f(
this.uLineInterceptColor,
s.interceptR,
s.interceptG,
s.interceptB,
);
gl.uniform3f(this.uLineOutlineColor, s.outlineR, s.outlineG, s.outlineB);
gl.uniform3f(
this.uLineInterceptOutlineColor,
s.interceptOutlineR,
s.interceptOutlineG,
s.interceptOutlineB,
);
gl.bindVertexArray(this.lineVAO);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, this.lineVertexCount);
// --- Draw markers ---
this.drawMarkers(cameraMatrix, d, pixelSize);
}
private drawMarkers(
cameraMatrix: Float32Array,
d: NukeTrajectoryData,
pixelSize: number,
): void {
const markers: [number, number][] = [];
if (d.tUntargetableStart >= 0) {
markers.push([d.tUntargetableStart, 0]);
markers.push([d.tUntargetableEnd, 0]);
}
if (d.tSamIntercept < 1.0) {
markers.push([d.tSamIntercept, 1]);
}
if (markers.length === 0) return;
const gl = this.gl;
const s = this.settings.nukeTrajectory;
gl.useProgram(this.markerProgram);
gl.uniformMatrix3fv(this.uMarkerCamera, false, cameraMatrix);
gl.uniform2f(this.uMarkerP0, d.p0x, d.p0y);
gl.uniform2f(this.uMarkerP1, d.p1x, d.p1y);
gl.uniform2f(this.uMarkerP2, d.p2x, d.p2y);
gl.uniform2f(this.uMarkerP3, d.p3x, d.p3y);
gl.uniform1f(this.uMarkerPixelSize, pixelSize);
gl.uniform2f(this.uMarkerRadii, s.markerCircleRadius, s.markerXRadius);
gl.bindVertexArray(this.markerVAO);
for (const [t, type] of markers) {
gl.uniform4f(this.uMarker, t, type, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.lineProgram);
gl.deleteProgram(this.markerProgram);
gl.deleteVertexArray(this.lineVAO);
gl.deleteVertexArray(this.markerVAO);
}
}
@@ -0,0 +1,237 @@
/**
* PointLightPass — instanced radial-falloff quads for unit/structure lights.
*
* Single VBO/VAO: units and structures packed together, uploaded once per tick.
* draw() is pure GPU: uniforms + one drawArraysInstanced call.
*/
import type { RendererConfig, UnitState } from "../../types";
import {
UT_ATOM_BOMB,
UT_CITY,
UT_DEFENSE_POST,
UT_FACTORY,
UT_HYDROGEN_BOMB,
UT_MIRV,
UT_MIRV_WARHEAD,
UT_MISSILE_SILO,
UT_PORT,
UT_SAM_LAUNCHER,
UT_TRADE_SHIP,
UT_TRAIN,
UT_TRANSPORT,
UT_WARSHIP,
} from "../../types";
import type { RenderSettings } from "../render-settings";
import { createProgram, shaderSrc } from "../utils/gl-utils";
import lightFragSrc from "../shaders/day-night/light.frag.glsl?raw";
import lightVertSrc from "../shaders/day-night/light.vert.glsl?raw";
// ---------------------------------------------------------------------------
// Light source configuration
// ---------------------------------------------------------------------------
interface LightConfig {
r: number;
g: number;
b: number;
radius: number;
intensity: number;
}
const LIGHT_CONFIGS: Record<string, LightConfig> = {
[UT_CITY]: { r: 1.0, g: 0.85, b: 0.5, radius: 18, intensity: 1.2 },
[UT_PORT]: { r: 1.0, g: 0.75, b: 0.4, radius: 12, intensity: 1.0 },
[UT_FACTORY]: { r: 1.0, g: 0.6, b: 0.3, radius: 12, intensity: 1.0 },
[UT_DEFENSE_POST]: { r: 0.8, g: 0.85, b: 1.0, radius: 10, intensity: 0.9 },
[UT_SAM_LAUNCHER]: { r: 0.8, g: 0.85, b: 1.0, radius: 10, intensity: 0.9 },
[UT_MISSILE_SILO]: { r: 1.0, g: 0.4, b: 0.2, radius: 10, intensity: 0.9 },
[UT_TRANSPORT]: { r: 0.9, g: 0.8, b: 0.6, radius: 6, intensity: 2.7 },
[UT_TRADE_SHIP]: { r: 0.9, g: 0.8, b: 0.6, radius: 6, intensity: 2.7 },
[UT_WARSHIP]: { r: 0.9, g: 0.85, b: 0.7, radius: 10, intensity: 2.8 },
[UT_ATOM_BOMB]: { r: 1.0, g: 0.9, b: 0.7, radius: 16, intensity: 1.1 },
[UT_HYDROGEN_BOMB]: { r: 1.0, g: 0.95, b: 0.6, radius: 22, intensity: 1.3 },
[UT_MIRV]: { r: 1.0, g: 0.9, b: 0.7, radius: 18, intensity: 1.2 },
[UT_MIRV_WARHEAD]: { r: 1.0, g: 0.6, b: 0.3, radius: 12, intensity: 1.0 },
[UT_TRAIN]: { r: 1.0, g: 0.85, b: 0.5, radius: 8, intensity: 2.0 },
};
const FLOATS_PER_LIGHT = 6;
const BYTES_PER_LIGHT = FLOATS_PER_LIGHT * 4;
const MAX_LIGHT_TYPES = 64;
const MAX_LIGHTS = 12288; // units + structures combined
export class PointLightPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
// Program + uniforms
private lightProg: WebGLProgram;
private uLightCam: WebGLUniformLocation;
private uRadiusMultiplier: WebGLUniformLocation;
private uRadiusArr: WebGLUniformLocation;
private uIntensityArr: WebGLUniformLocation;
private uFalloffPower: WebGLUniformLocation;
// Single instance buffer — units + structures packed together
private lightVao: WebGLVertexArrayObject;
private lightBuf: WebGLBuffer;
private lightData: Float32Array;
private lightCount = 0;
// Type config
private typeToIdx = new Map<string, number>();
private typeConfigs: (LightConfig | undefined)[];
private typeNames: string[];
private radiusArr = new Float32Array(MAX_LIGHT_TYPES);
private intensityArr = new Float32Array(MAX_LIGHT_TYPES);
private paletteData: Float32Array;
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
paletteData: Float32Array,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.paletteData = paletteData;
this.mapW = header.mapWidth;
// Build type → light config mapping
this.typeNames = header.unitTypes;
this.typeConfigs = new Array(header.unitTypes.length);
for (let i = 0; i < header.unitTypes.length; i++) {
this.typeConfigs[i] = LIGHT_CONFIGS[header.unitTypes[i]];
this.typeToIdx.set(header.unitTypes[i], i);
}
// Light program
this.lightProg = createProgram(
gl,
shaderSrc(lightVertSrc, { MAX_LIGHT_TYPES }),
lightFragSrc,
);
this.uLightCam = gl.getUniformLocation(this.lightProg, "uCamera")!;
this.uRadiusMultiplier = gl.getUniformLocation(
this.lightProg,
"uRadiusMultiplier",
)!;
this.uRadiusArr = gl.getUniformLocation(this.lightProg, "uRadius")!;
this.uIntensityArr = gl.getUniformLocation(this.lightProg, "uIntensity")!;
this.uFalloffPower = gl.getUniformLocation(
this.lightProg,
"uFalloffPower",
)!;
// Instance buffer + VAO
this.lightData = new Float32Array(MAX_LIGHTS * FLOATS_PER_LIGHT);
this.lightBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.lightBuf);
gl.bufferData(gl.ARRAY_BUFFER, this.lightData.byteLength, gl.DYNAMIC_DRAW);
this.lightVao = gl.createVertexArray()!;
gl.bindVertexArray(this.lightVao);
// Attribute 0: quad corner [0,1]
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Attribute 1: per-instance vec3 (x, y, typeIdx)
gl.bindBuffer(gl.ARRAY_BUFFER, this.lightBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_LIGHT, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: per-instance vec3 (r, g, b)
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 3, gl.FLOAT, false, BYTES_PER_LIGHT, 12);
gl.vertexAttribDivisor(2, 1);
gl.bindVertexArray(null);
}
/** Pack all light-emitting entities into the instance buffer and upload. Called every tick. */
updateLights(units: Map<number, UnitState>): void {
let count = 0;
for (const unit of units.values()) {
if (!unit.isActive) continue;
const typeIdx = this.typeToIdx.get(unit.unitType);
if (typeIdx === undefined) continue;
const cfg = this.typeConfigs[typeIdx];
if (!cfg) continue;
if (count >= MAX_LIGHTS) break;
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
const off = count * FLOATS_PER_LIGHT;
const pOff = unit.ownerID * 4;
this.lightData[off + 0] = x;
this.lightData[off + 1] = y;
this.lightData[off + 2] = typeIdx;
this.lightData[off + 3] = this.paletteData[pOff];
this.lightData[off + 4] = this.paletteData[pOff + 1];
this.lightData[off + 5] = this.paletteData[pOff + 2];
count++;
}
this.lightCount = count;
if (count > 0) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.lightBuf);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.lightData,
0,
count * FLOATS_PER_LIGHT,
);
}
}
/**
* Render instanced point lights into the currently bound FBO.
* Caller must set up additive blending and viewport.
*/
draw(cameraMatrix: Float32Array): void {
if (this.lightCount === 0) return;
const gl = this.gl;
const dn = this.settings.dayNight;
gl.useProgram(this.lightProg);
gl.uniformMatrix3fv(this.uLightCam, false, cameraMatrix);
gl.uniform1f(this.uRadiusMultiplier, dn.lightRadiusMultiplier);
gl.uniform1f(this.uFalloffPower, dn.falloffPower);
for (let i = 0; i < this.typeNames.length; i++) {
const cfg = this.typeConfigs[i];
if (!cfg) continue;
const ov = this.settings.lightConfigs[this.typeNames[i]];
this.radiusArr[i] = ov?.radius ?? cfg.radius;
this.intensityArr[i] = ov?.intensity ?? cfg.intensity;
}
gl.uniform1fv(this.uRadiusArr, this.radiusArr);
gl.uniform1fv(this.uIntensityArr, this.intensityArr);
gl.bindVertexArray(this.lightVao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.lightCount);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.lightProg);
gl.deleteVertexArray(this.lightVao);
gl.deleteBuffer(this.lightBuf);
}
}
@@ -0,0 +1,574 @@
/**
* RadialMenuPass — renders a radial (pie-wheel) context menu as screen-space
* arc segments with emoji icons.
*
* Supports one level of submenus: when a submenu is open, the parent items
* shrink into a smaller inner ring, a back button appears in the center, and
* the submenu items take the outer ring.
*
* Rendering elements (reused for each ring via drawRing):
* 1. Arcs: single quad with SDF annulus + angular segment masking + borders
* 2. Center button: filled circle drawn by the innermost ring
* 3. Icons: instanced quads sampling the emoji atlas
*/
import type { RadialMenuItem } from "../events";
import { createProgram } from "../utils/gl-utils";
import arcFragSrc from "../shaders/radial-menu/arcs.frag.glsl?raw";
import arcVertSrc from "../shaders/radial-menu/arcs.vert.glsl?raw";
import iconFragSrc from "../shaders/radial-menu/icon.frag.glsl?raw";
import iconVertSrc from "../shaders/radial-menu/icon.vert.glsl?raw";
import emojiAtlasMeta from "../assets/emoji-atlas-meta.json";
import emojiAtlasUrl from "../assets/emoji-atlas.png?url";
// ---------------------------------------------------------------------------
// Ring layout configs (CSS pixels)
// ---------------------------------------------------------------------------
interface RingConfig {
outerR: number;
innerR: number;
/** Icon half-size; if a function, receives the segment count. */
iconHalf: number | ((n: number) => number);
/** Opacity multiplier applied to colors (1 = full, <1 = dimmed). */
dim: number;
}
/** Normal top-level ring (game: innerRadius 40, arcWidth 55). */
const RING_NORMAL: RingConfig = {
outerR: 95,
innerR: 40,
iconHalf: (n) => (n <= 4 ? 20 : n <= 6 ? 17 : 14),
dim: 1.0,
};
/** Submenu active ring (game: innerRadius 75, arcWidth 65). */
const RING_SUBMENU: RingConfig = {
outerR: 140,
innerR: 75,
iconHalf: (n) => (n <= 4 ? 22 : n <= 6 ? 18 : 14),
dim: 1.0,
};
/** Parent ring when submenu is open (game: scales to 0.65). */
const RING_PARENT: RingConfig = {
outerR: 70,
innerR: 32,
iconHalf: 12,
dim: 0.5,
};
const MAX_SEGMENTS = 8;
/** Hit-test return value for the center button. */
export const CENTER_INDEX = -2;
const BACK_ITEM: RadialMenuItem = {
id: "__back__",
icon: "back-icon",
color: [0.45, 0.45, 0.45],
enabled: true,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildEmojiMap(): Map<string, number> {
const map = new Map<string, number>();
const emojis = (emojiAtlasMeta as { emojis: Record<string, number> }).emojis;
for (const [key, idx] of Object.entries(emojis)) {
map.set(key, idx);
}
return map;
}
// ---------------------------------------------------------------------------
// RadialMenuPass
// ---------------------------------------------------------------------------
export class RadialMenuPass {
private gl: WebGL2RenderingContext;
// Programs
private arcProg: WebGLProgram;
private iconProg: WebGLProgram;
private vao: WebGLVertexArrayObject;
// Arc uniform locations
private arcU: {
anchor: WebGLUniformLocation;
viewport: WebGLUniformLocation;
outerR: WebGLUniformLocation;
innerR: WebGLUniformLocation;
segCount: WebGLUniformLocation;
hoveredSeg: WebGLUniformLocation;
segColors: WebGLUniformLocation;
hasCenterBtn: WebGLUniformLocation;
centerColor: WebGLUniformLocation;
centerHovered: WebGLUniformLocation;
};
// Icon uniform locations
private iconU: {
anchor: WebGLUniformLocation;
viewport: WebGLUniformLocation;
outerR: WebGLUniformLocation;
innerR: WebGLUniformLocation;
segCount: WebGLUniformLocation;
iconHalf: WebGLUniformLocation;
emojiIndices: WebGLUniformLocation;
centerEmojiIdx: WebGLUniformLocation;
segOpacity: WebGLUniformLocation;
emojiAtlas: WebGLUniformLocation;
emojiCell: WebGLUniformLocation;
emojiCols: WebGLUniformLocation;
emojiAtlasW: WebGLUniformLocation;
emojiAtlasH: WebGLUniformLocation;
};
// Emoji + icon atlas
private emojiTex: WebGLTexture | null = null;
private emojiReady = false;
private emojiMap: Map<string, number>;
private atlasImg: HTMLImageElement | null = null;
private pendingIcons: { key: string; img: CanvasImageSource }[] = [];
// ---- State ----
private visible = false;
private anchorX = 0;
private anchorY = 0;
private items: RadialMenuItem[] = [];
private centerItem: RadialMenuItem | null = null;
private hoveredIndex = -1; // -1 = none, 0..n-1 = segment, CENTER_INDEX = center
// Submenu (one level)
private _inSubmenu = false;
private savedItems: RadialMenuItem[] = [];
private savedCenterItem: RadialMenuItem | null = null;
constructor(gl: WebGL2RenderingContext) {
this.gl = gl;
this.emojiMap = buildEmojiMap();
// Shared quad VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
// Arc program
this.arcProg = createProgram(gl, arcVertSrc, arcFragSrc);
this.arcU = {
anchor: gl.getUniformLocation(this.arcProg, "uAnchor")!,
viewport: gl.getUniformLocation(this.arcProg, "uViewport")!,
outerR: gl.getUniformLocation(this.arcProg, "uOuterR")!,
innerR: gl.getUniformLocation(this.arcProg, "uInnerR")!,
segCount: gl.getUniformLocation(this.arcProg, "uSegCount")!,
hoveredSeg: gl.getUniformLocation(this.arcProg, "uHoveredSeg")!,
segColors: gl.getUniformLocation(this.arcProg, "uSegColors")!,
hasCenterBtn: gl.getUniformLocation(this.arcProg, "uHasCenterBtn")!,
centerColor: gl.getUniformLocation(this.arcProg, "uCenterColor")!,
centerHovered: gl.getUniformLocation(this.arcProg, "uCenterHovered")!,
};
// Icon program
this.iconProg = createProgram(gl, iconVertSrc, iconFragSrc);
gl.useProgram(this.iconProg);
gl.uniform1i(gl.getUniformLocation(this.iconProg, "uEmojiAtlas"), 0);
const em = emojiAtlasMeta as {
width: number;
height: number;
cellSize: number;
cols: number;
};
gl.uniform1f(
gl.getUniformLocation(this.iconProg, "uEmojiCell")!,
em.cellSize,
);
gl.uniform1f(gl.getUniformLocation(this.iconProg, "uEmojiCols")!, em.cols);
gl.uniform1f(
gl.getUniformLocation(this.iconProg, "uEmojiAtlasW")!,
em.width,
);
gl.uniform1f(
gl.getUniformLocation(this.iconProg, "uEmojiAtlasH")!,
em.height,
);
this.iconU = {
anchor: gl.getUniformLocation(this.iconProg, "uAnchor")!,
viewport: gl.getUniformLocation(this.iconProg, "uViewport")!,
outerR: gl.getUniformLocation(this.iconProg, "uOuterR")!,
innerR: gl.getUniformLocation(this.iconProg, "uInnerR")!,
segCount: gl.getUniformLocation(this.iconProg, "uSegCount")!,
iconHalf: gl.getUniformLocation(this.iconProg, "uIconHalf")!,
emojiIndices: gl.getUniformLocation(this.iconProg, "uEmojiIndices")!,
centerEmojiIdx: gl.getUniformLocation(this.iconProg, "uCenterEmojiIdx")!,
segOpacity: gl.getUniformLocation(this.iconProg, "uSegOpacity")!,
emojiAtlas: gl.getUniformLocation(this.iconProg, "uEmojiAtlas")!,
emojiCell: gl.getUniformLocation(this.iconProg, "uEmojiCell")!,
emojiCols: gl.getUniformLocation(this.iconProg, "uEmojiCols")!,
emojiAtlasW: gl.getUniformLocation(this.iconProg, "uEmojiAtlasW")!,
emojiAtlasH: gl.getUniformLocation(this.iconProg, "uEmojiAtlasH")!,
};
this.loadEmojiAtlas();
}
private loadEmojiAtlas(): void {
const img = new Image();
img.onload = () => {
this.atlasImg = img;
this.rebuildAtlasTexture();
};
img.src = emojiAtlasUrl;
}
/**
* Register additional icon images to append to the atlas texture.
* Call from the adapter after loading game SVG icons.
*/
registerIcons(icons: { key: string; img: CanvasImageSource }[]): void {
this.pendingIcons = icons;
if (this.atlasImg) this.rebuildAtlasTexture();
}
private rebuildAtlasTexture(): void {
if (!this.atlasImg) return;
const gl = this.gl;
const meta = emojiAtlasMeta as {
width: number;
height: number;
cellSize: number;
cols: number;
emojis: Record<string, number>;
};
const baseCount = Object.keys(meta.emojis).length;
const totalCount = baseCount + this.pendingIcons.length;
const rows = Math.ceil(totalCount / meta.cols);
const height = Math.max(meta.height, rows * meta.cellSize);
const canvas = document.createElement("canvas");
canvas.width = meta.width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
// Draw existing emoji atlas
ctx.drawImage(this.atlasImg, 0, 0);
// Append extra icons into new cells (preserving aspect ratio)
// Minimal padding — SVGs are already clean vectors, maximize resolution
const pad = Math.floor(meta.cellSize * 0.04);
const size = meta.cellSize - pad * 2;
for (let i = 0; i < this.pendingIcons.length; i++) {
const idx = baseCount + i;
const col = idx % meta.cols;
const row = Math.floor(idx / meta.cols);
const img = this.pendingIcons[i].img;
const nw = (img as HTMLImageElement).naturalWidth || size;
const nh = (img as HTMLImageElement).naturalHeight || size;
const aspect = nw / nh;
let dw = size,
dh = size;
if (aspect > 1) dh = size / aspect;
else dw = size * aspect;
const ox = (size - dw) / 2;
const oy = (size - dh) / 2;
ctx.drawImage(
img,
col * meta.cellSize + pad + ox,
row * meta.cellSize + pad + oy,
dw,
dh,
);
this.emojiMap.set(this.pendingIcons[i].key, idx);
}
// Upload texture
this.emojiTex ??= gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, this.emojiTex);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.generateMipmap(gl.TEXTURE_2D);
this.emojiReady = true;
// Update atlas height uniform (texture may be taller now)
gl.useProgram(this.iconProg);
gl.uniform1f(this.iconU.emojiAtlasH, height);
}
resolveEmoji(icon: string): number {
return this.emojiMap.get(icon) ?? -1;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
show(
anchorX: number,
anchorY: number,
items: RadialMenuItem[],
centerItem?: RadialMenuItem,
): void {
this.visible = true;
this.anchorX = anchorX;
this.anchorY = anchorY;
this.items = items.slice(0, MAX_SEGMENTS);
this.centerItem = centerItem ?? null;
// Cursor is at the anchor — center button starts hovered
this.hoveredIndex = this.centerItem ? CENTER_INDEX : -1;
this._inSubmenu = false;
this.savedItems = [];
this.savedCenterItem = null;
}
openSubMenu(subItems: RadialMenuItem[]): void {
this.savedItems = this.items;
this.savedCenterItem = this.centerItem;
this.items = subItems.slice(0, MAX_SEGMENTS);
this.centerItem = BACK_ITEM;
this._inSubmenu = true;
this.hoveredIndex = -1;
}
goBack(): void {
if (!this._inSubmenu) return;
this.items = this.savedItems;
this.centerItem = this.savedCenterItem;
this._inSubmenu = false;
this.savedItems = [];
this.savedCenterItem = null;
this.hoveredIndex = -1;
}
hide(): void {
this.visible = false;
this.hoveredIndex = -1;
this._inSubmenu = false;
this.savedItems = [];
this.savedCenterItem = null;
}
setHover(index: number): void {
this.hoveredIndex = index;
}
get isVisible(): boolean {
return this.visible;
}
get inSubmenu(): boolean {
return this._inSubmenu;
}
getItems(): readonly RadialMenuItem[] {
return this.items;
}
getCenterItem(): RadialMenuItem | null {
return this.centerItem;
}
/** Look up an item by hit-test index. */
getItemAt(index: number): RadialMenuItem | null {
if (index === CENTER_INDEX) return this.centerItem;
if (index >= 0 && index < this.items.length) return this.items[index];
return null;
}
// ---------------------------------------------------------------------------
// Hit testing
// ---------------------------------------------------------------------------
hitTest(screenX: number, screenY: number): number {
if (!this.visible) return -1;
const dx = screenX - this.anchorX;
const dy = screenY - this.anchorY;
const dist = Math.sqrt(dx * dx + dy * dy);
const active = this._inSubmenu ? RING_SUBMENU : RING_NORMAL;
const centerR = this._inSubmenu ? RING_PARENT.innerR : RING_NORMAL.innerR;
const ringInner = active.innerR;
const ringOuter = active.outerR;
// Center button
if (dist < centerR) return this.centerItem ? CENTER_INDEX : -1;
// Gap / parent ring zone (non-interactive)
if (dist < ringInner) return -1;
// Active ring
if (dist > ringOuter || this.items.length === 0) return -1;
let angle = Math.atan2(dx, -dy); // 0 = top, CW positive
if (angle < 0) angle += Math.PI * 2;
const n = this.items.length;
const segArc = (Math.PI * 2) / n;
// Rotate so first segment is centered at top (game: startAngle = -π/n)
const shifted = (angle + Math.PI / n) % (Math.PI * 2);
return Math.min(Math.floor(shifted / segArc), n - 1);
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
draw(): void {
if (!this.visible) return;
if (this.items.length === 0 && !this.centerItem) return;
const gl = this.gl;
const dpr = window.devicePixelRatio || 1;
const vw = gl.drawingBufferWidth;
const vh = gl.drawingBufferHeight;
const ax = this.anchorX * dpr;
const ay = this.anchorY * dpr;
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.bindVertexArray(this.vao);
// Parent ring (dimmed, non-interactive) — drawn first so active ring overlays
if (this._inSubmenu && this.savedItems.length > 0) {
const p = RING_PARENT;
this.drawRing(
ax,
ay,
vw,
vh,
p,
this.savedItems,
-1,
BACK_ITEM,
this.hoveredIndex === CENTER_INDEX,
);
}
// Active ring — expands when in submenu
const active = this._inSubmenu ? RING_SUBMENU : RING_NORMAL;
this.drawRing(
ax,
ay,
vw,
vh,
active,
this.items,
this.hoveredIndex >= 0 ? this.hoveredIndex : -1,
this._inSubmenu ? null : this.centerItem,
!this._inSubmenu && this.hoveredIndex === CENTER_INDEX,
);
}
/** Draw a single ring (arcs + icons) using a RingConfig. */
private drawRing(
ax: number,
ay: number,
vw: number,
vh: number,
cfg: RingConfig,
items: readonly RadialMenuItem[],
hoveredSeg: number,
centerItem: RadialMenuItem | null,
centerHovered: boolean,
): void {
const gl = this.gl;
const dpr = window.devicePixelRatio || 1;
const n = items.length;
const hasCenter = centerItem !== null;
const outerR = cfg.outerR * dpr;
const innerFrac = cfg.innerR / cfg.outerR;
const dim = cfg.dim;
const ih =
typeof cfg.iconHalf === "function" ? cfg.iconHalf(n) : cfg.iconHalf;
const iconHalf = ih * dpr;
// --- Arcs ---
gl.useProgram(this.arcProg);
gl.uniform2f(this.arcU.anchor, ax, ay);
gl.uniform2f(this.arcU.viewport, vw, vh);
gl.uniform1f(this.arcU.outerR, outerR);
gl.uniform1f(this.arcU.innerR, innerFrac);
gl.uniform1i(this.arcU.segCount, n);
gl.uniform1i(this.arcU.hoveredSeg, hoveredSeg);
gl.uniform1i(this.arcU.hasCenterBtn, hasCenter ? 1 : 0);
if (hasCenter) {
const cc = centerItem.color;
gl.uniform3f(
this.arcU.centerColor,
cc[0] * dim,
cc[1] * dim,
cc[2] * dim,
);
gl.uniform1i(this.arcU.centerHovered, centerHovered ? 1 : 0);
}
const colors = new Float32Array(MAX_SEGMENTS * 4);
for (let i = 0; i < n; i++) {
const c = items[i].color;
colors[i * 4 + 0] = c[0] * dim;
colors[i * 4 + 1] = c[1] * dim;
colors[i * 4 + 2] = c[2] * dim;
colors[i * 4 + 3] = items[i].enabled ? 1 : 0;
}
gl.uniform4fv(this.arcU.segColors, colors);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// --- Icons ---
if (!this.emojiReady || (n === 0 && !hasCenter)) return;
gl.useProgram(this.iconProg);
gl.uniform2f(this.iconU.anchor, ax, ay);
gl.uniform2f(this.iconU.viewport, vw, vh);
gl.uniform1f(this.iconU.outerR, outerR);
gl.uniform1f(this.iconU.innerR, innerFrac);
gl.uniform1i(this.iconU.segCount, n);
gl.uniform1f(this.iconU.iconHalf, iconHalf);
const indices = new Float32Array(MAX_SEGMENTS);
const opacities = new Float32Array(MAX_SEGMENTS);
indices.fill(-1);
opacities.fill(1);
for (let i = 0; i < n; i++) {
indices[i] = this.resolveEmoji(items[i].icon);
opacities[i] = items[i].enabled ? 1.0 : 0.3;
}
gl.uniform1fv(this.iconU.emojiIndices, indices);
gl.uniform1fv(this.iconU.segOpacity, opacities);
const centerIdx = hasCenter ? this.resolveEmoji(centerItem.icon) : -1;
gl.uniform1f(this.iconU.centerEmojiIdx, centerIdx);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.emojiTex!);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, n + 1);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.arcProg);
gl.deleteProgram(this.iconProg);
gl.deleteVertexArray(this.vao);
if (this.emojiTex) gl.deleteTexture(this.emojiTex);
}
}
@@ -0,0 +1,340 @@
/**
* RailroadPass — GPU railroad overlay rendering.
*
* Renders railroad tracks as a fullscreen quad pass, reading rail orientation
* from an R8UI texture. Two LOD modes: detailed 3×3 sub-grid sprites at high
* zoom, screen-space anti-aliased lines at medium zoom. Hidden below minimum
* zoom threshold.
*
* Also renders ghost railroad paths (semi-transparent) for build-mode preview.
*
* Data flow:
* Uint8Array railroadState → R8UI texture (rail type per tile, 0=none, 1-6=type)
* GhostPreviewData → R8UI ghost texture (ghost rail paths)
* R8UI terrainTex → water detection for bridge rendering (shader neighbor lookup)
* R16UI tileTex (shared) → owner lookup for rail color
* RGBA32F paletteTex → player color lookup
*/
import type { GhostPreviewData } from "../../types";
import type { RenderSettings } from "../render-settings";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
import railroadFragSrc from "../shaders/railroad/railroad.frag.glsl?raw";
import { getPaletteSize } from "../utils/color-utils";
import {
createMapQuad,
createProgram,
createTexture2D,
shaderSrc,
} from "../utils/gl-utils";
import { TILE_DEFINES } from "../utils/tile-codec";
// ---------------------------------------------------------------------------
// Rail orientation (0-5) → texture value (1-6, 0=none)
// ---------------------------------------------------------------------------
const VERTICAL = 0;
const HORIZONTAL = 1;
const TOP_LEFT = 2;
const TOP_RIGHT = 3;
const BOTTOM_LEFT = 4;
const BOTTOM_RIGHT = 5;
function railExtremity(tile: number, next: number, w: number): number {
const dx = (next % w) - (tile % w);
const dy = (next - (next % w)) / w - (tile - (tile % w)) / w;
if (dx === 0) return VERTICAL;
if (dy === 0) return HORIZONTAL;
return VERTICAL;
}
function railDirection(
prev: number,
cur: number,
next: number,
w: number,
): number {
const x1 = prev % w,
y1 = (prev - x1) / w;
const x2 = cur % w,
y2 = (cur - x2) / w;
const x3 = next % w,
y3 = (next - x3) / w;
const dx1 = x2 - x1,
dy1 = y2 - y1;
const dx2 = x3 - x2,
dy2 = y3 - y2;
if (dx1 === dx2 && dy1 === dy2) {
return dx1 !== 0 ? HORIZONTAL : VERTICAL;
}
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return BOTTOM_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return BOTTOM_LEFT;
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return TOP_RIGHT;
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return TOP_LEFT;
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return TOP_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return TOP_RIGHT;
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return BOTTOM_RIGHT;
}
return VERTICAL;
}
// ---------------------------------------------------------------------------
// RailroadPass
// ---------------------------------------------------------------------------
export class RailroadPass {
private program: WebGLProgram;
private railroadTex: WebGLTexture;
private ghostRailTex: WebGLTexture;
private tileTex: WebGLTexture;
private paletteTex: WebGLTexture;
private terrainTex: WebGLTexture;
private vao: WebGLVertexArrayObject;
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uZoom: WebGLUniformLocation;
private uRailDetailZoom: WebGLUniformLocation;
private uRailAlpha: WebGLUniformLocation;
private uGhostOwnerID: WebGLUniformLocation;
private mapW: number;
private mapH: number;
private settings: RenderSettings;
private cpuRailroadState: Uint8Array;
private railroadDirty = false;
private cpuGhostRailState: Uint8Array;
private ghostRailDirty = false;
private ghostOwnerID = 0;
constructor(
private gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
paletteTex: WebGLTexture,
terrainBytes: Uint8Array,
settings: RenderSettings,
) {
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.paletteTex = paletteTex;
this.settings = settings;
this.cpuRailroadState = new Uint8Array(mapW * mapH);
this.cpuGhostRailState = new Uint8Array(mapW * mapH);
this.program = createProgram(
gl,
overlayVertSrc,
shaderSrc(railroadFragSrc, {
PALETTE_SIZE: getPaletteSize(),
...TILE_DEFINES,
}),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uZoom = gl.getUniformLocation(this.program, "uZoom")!;
this.uRailDetailZoom = gl.getUniformLocation(
this.program,
"uRailDetailZoom",
)!;
this.uRailAlpha = gl.getUniformLocation(this.program, "uRailAlpha")!;
this.uGhostOwnerID = gl.getUniformLocation(this.program, "uGhostOwnerID")!;
// Texture unit bindings + ghost defaults
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uRailroadTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uTerrainTex"), 3);
gl.uniform1i(gl.getUniformLocation(this.program, "uGhostRailTex"), 4);
gl.uniform1f(this.uGhostOwnerID, 0);
// R8UI terrain texture (static, uploaded once for bridge detection)
this.terrainTex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: terrainBytes,
filter: gl.NEAREST,
});
// R8UI railroad texture
this.railroadTex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: this.cpuRailroadState,
filter: gl.NEAREST,
});
// R8UI ghost railroad texture (same format, ghost paths only)
this.ghostRailTex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: this.cpuGhostRailState,
filter: gl.NEAREST,
});
this.vao = createMapQuad(gl, mapW, mapH);
}
uploadRailroadState(railroadState: Uint8Array): void {
this.cpuRailroadState.set(railroadState);
this.railroadDirty = true;
}
updateGhostPreview(data: GhostPreviewData | null): void {
this.cpuGhostRailState.fill(0);
if (data) {
const maxRef = this.mapW * this.mapH;
// Ghost rail paths (1-6 = orientation)
for (const path of data.ghostRailPaths) {
if (path.length === 0) continue;
const tiles = this.computePathOrientations(path);
for (const t of tiles) {
if (t.ref >= 0 && t.ref < maxRef) {
this.cpuGhostRailState[t.ref] = t.type + 1;
}
}
}
// Overlapping railroad highlights (7 = green highlight marker)
// overlappingRailroads contains resolved tile refs (not rail IDs)
for (const ref of data.overlappingRailroads) {
if (ref >= 0 && ref < maxRef) {
this.cpuGhostRailState[ref] = 7;
}
}
this.ghostOwnerID = data.ownerID;
} else {
this.ghostOwnerID = 0;
}
this.ghostRailDirty = true;
}
/** Draw the railroad overlay. Must be called with alpha blending enabled. */
draw(cameraMatrix: Float32Array, zoom: number): void {
const gl = this.gl;
const rs = this.settings.railroad;
// Skip entirely when below minimum zoom
if (zoom < rs.railMinZoom) return;
// Flush CPU railroad state → GPU
if (this.railroadDirty) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.railroadTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
this.mapW,
this.mapH,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.cpuRailroadState,
);
this.railroadDirty = false;
}
// Flush ghost railroad state → GPU
if (this.ghostRailDirty) {
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
this.mapW,
this.mapH,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.cpuGhostRailState,
);
this.ghostRailDirty = false;
}
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uZoom, zoom);
gl.uniform1f(this.uRailDetailZoom, rs.railDetailZoom);
gl.uniform1f(this.uRailAlpha, rs.railAlpha);
gl.uniform1f(this.uGhostOwnerID, this.ghostOwnerID);
// Bind textures: 0=railroad, 1=tile, 2=palette, 3=terrain, 4=ghostRail
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.railroadTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.terrainTex);
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
// ---- Rail orientation computation ----
private computePathOrientations(
tileRefs: number[],
): Array<{ ref: number; type: number }> {
if (tileRefs.length === 0) return [];
if (tileRefs.length === 1) return [{ ref: tileRefs[0], type: VERTICAL }];
const w = this.mapW;
const result: Array<{ ref: number; type: number }> = [];
result.push({
ref: tileRefs[0],
type: railExtremity(tileRefs[0], tileRefs[1], w),
});
for (let i = 1; i < tileRefs.length - 1; i++) {
result.push({
ref: tileRefs[i],
type: railDirection(tileRefs[i - 1], tileRefs[i], tileRefs[i + 1], w),
});
}
const last = tileRefs.length - 1;
result.push({
ref: tileRefs[last],
type: railExtremity(tileRefs[last], tileRefs[last - 1], w),
});
return result;
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteTexture(this.railroadTex);
gl.deleteTexture(this.ghostRailTex);
gl.deleteTexture(this.terrainTex);
// Don't delete tileTex or paletteTex — shared with other passes
}
}
@@ -0,0 +1,79 @@
/**
* RangeCirclePass — draws a translucent white circle showing the effective
* range of a structure during build-mode ghost preview.
*
* Single quad with circle SDF in the fragment shader.
* Active only when a ghost preview with rangeRadius > 0 is set.
*/
import type { GhostPreviewData } from "../../types";
import { createProgram } from "../utils/gl-utils";
import fragSrc from "../shaders/range-circle/range-circle.frag.glsl?raw";
import vertSrc from "../shaders/range-circle/range-circle.vert.glsl?raw";
export class RangeCirclePass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private uCamera: WebGLUniformLocation;
private uCenter: WebGLUniformLocation;
private uRadius: WebGLUniformLocation;
private centerX = 0;
private centerY = 0;
private radius = 0;
constructor(gl: WebGL2RenderingContext) {
this.gl = gl;
this.program = createProgram(gl, vertSrc, fragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uCenter = gl.getUniformLocation(this.program, "uCenter")!;
this.uRadius = gl.getUniformLocation(this.program, "uRadius")!;
// Unit quad [0,1]
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
}
updateGhostPreview(data: GhostPreviewData | null): void {
if (data && data.rangeRadius > 0) {
this.centerX = data.tileX;
this.centerY = data.tileY;
this.radius = data.rangeRadius;
} else {
this.radius = 0;
}
}
draw(cameraMatrix: Float32Array): void {
if (this.radius <= 0) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uCenter, this.centerX, this.centerY);
gl.uniform1f(this.uRadius, this.radius);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,396 @@
/**
* SAMRadiusPass — renders rotating dashed circles around SAM launchers
* when the player is in build mode (ghost preview active).
*
* Allied SAM ranges are merged via circle union: overlapping circles from
* the same alliance group show as a single combined shape rather than
* overlapping rings. Each circle's visible (uncovered) arcs are emitted
* as separate instances.
*
* Colors by ownership relationship:
* self → green (0, 1, 0)
* ally → yellow (1, 1, 0)
* enemy → red (1, 0, 0)
*/
import type { UnitState } from "../../types";
import { UT_SAM_LAUNCHER } from "../../types";
import { DynamicInstanceBuffer } from "../dynamic-buffer";
import type { RenderSettings } from "../render-settings";
import { createProgram } from "../utils/gl-utils";
import { samRange } from "../utils/nuke-trajectory";
import fragSrc from "../shaders/sam-radius/sam-radius.frag.glsl?raw";
import vertSrc from "../shaders/sam-radius/sam-radius.vert.glsl?raw";
const TWO_PI = Math.PI * 2;
const EPS = 1e-9;
// Per-instance: x, y, radius, r, g, b, arcStart, arcEnd
const FLOATS_PER_INSTANCE = 8;
// Relationship colors
const COLOR_SELF = [0, 1, 0]; // green
const COLOR_ALLY = [1, 1, 0]; // yellow
const COLOR_ENEMY = [1, 0, 0]; // red
interface SAMCircle {
x: number;
y: number;
radius: number;
color: number[];
group: number; // alliance group: 0 = friendly, 1 = enemy
}
type Interval = [number, number];
// ---------------------------------------------------------------------------
// Circle union geometry
// ---------------------------------------------------------------------------
function normalizeAngle(a: number): number {
while (a < 0) a += TWO_PI;
while (a >= TWO_PI) a -= TWO_PI;
return a;
}
function mergeIntervals(intervals: Interval[]): Interval[] {
if (intervals.length === 0) return [];
// Split wrapping intervals, then merge
const flat: Interval[] = [];
for (const [s, e] of intervals) {
const ns = normalizeAngle(s);
const ne = normalizeAngle(e);
if (ne < ns) {
flat.push([ns, TWO_PI]);
flat.push([0, ne]);
} else {
flat.push([ns, ne]);
}
}
flat.sort((a, b) => a[0] - b[0]);
const merged: Interval[] = [];
let cur: Interval = [flat[0][0], flat[0][1]];
for (let i = 1; i < flat.length; i++) {
const it = flat[i];
if (it[0] <= cur[1] + EPS) {
cur[1] = Math.max(cur[1], it[1]);
} else {
merged.push(cur);
cur = [it[0], it[1]];
}
}
merged.push(cur);
return merged;
}
/** Compute the uncovered arc intervals for circle `a` given all circles. */
function computeUncoveredArcs(a: SAMCircle, circles: SAMCircle[]): Interval[] {
const covered: Interval[] = [];
for (const b of circles) {
if (a === b) continue;
if (a.group !== b.group) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy);
// a fully inside b → no visible arcs
if (d + a.radius <= b.radius + EPS) return [];
// No overlap
if (d >= a.radius + b.radius - EPS) continue;
// Coincident centers
if (d <= EPS) {
if (b.radius >= a.radius) return [];
continue;
}
// Angular span on a covered by b (law of cosines)
const cosPhi =
(a.radius * a.radius + d * d - b.radius * b.radius) / (2 * a.radius * d);
const phi = Math.acos(Math.max(-1, Math.min(1, cosPhi)));
const theta = Math.atan2(dy, dx);
covered.push([theta - phi, theta + phi]);
}
const merged = mergeIntervals(covered);
// Subtract covered from [0, 2π)
if (merged.length === 0) return [[0, TWO_PI]];
const uncovered: Interval[] = [];
let cursor = 0;
for (const [s, e] of merged) {
if (s > cursor + EPS) uncovered.push([cursor, s]);
cursor = Math.max(cursor, e);
}
if (cursor < TWO_PI - EPS) uncovered.push([cursor, TWO_PI]);
return uncovered;
}
// ---------------------------------------------------------------------------
// Pass
// ---------------------------------------------------------------------------
export class SAMRadiusPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private uCamera: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uOutline: WebGLUniformLocation;
private uStrokeWidth: WebGLUniformLocation;
private uDashLen: WebGLUniformLocation;
private uGapLen: WebGLUniformLocation;
private uRotationSpeed: WebGLUniformLocation;
private uAlpha: WebGLUniformLocation;
private uOutlineWidth: WebGLUniformLocation;
private uOutlineSoftness: WebGLUniformLocation;
private settings: RenderSettings;
private instanceCount = 0;
private visible = false;
private mapW = 0;
private startTime = performance.now();
private localPlayerID = 0;
private allies = new Set<number>();
// Owner-color mode fields
private paletteData: Float32Array | null = null;
private colorMode: "perspective" | "owner" = "perspective";
private allianceClusters: Map<number, number> = new Map();
private lastStructures: Map<number, UnitState> | null = null;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
settings: RenderSettings,
) {
this.gl = gl;
this.mapW = mapW;
this.settings = settings;
this.program = createProgram(gl, vertSrc, fragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uOutline = gl.getUniformLocation(this.program, "uOutline")!;
this.uStrokeWidth = gl.getUniformLocation(this.program, "uStrokeWidth")!;
this.uDashLen = gl.getUniformLocation(this.program, "uDashLen")!;
this.uGapLen = gl.getUniformLocation(this.program, "uGapLen")!;
this.uRotationSpeed = gl.getUniformLocation(
this.program,
"uRotationSpeed",
)!;
this.uAlpha = gl.getUniformLocation(this.program, "uAlpha")!;
this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!;
this.uOutlineSoftness = gl.getUniformLocation(
this.program,
"uOutlineSoftness",
)!;
// VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Attribute 0: unit quad [0,1]
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Instance buffer: [x, y, radius, r, g, b, arcStart, arcEnd]
const glBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
glBuf,
64,
FLOATS_PER_INSTANCE,
);
gl.bindBuffer(gl.ARRAY_BUFFER, glBuf);
const stride = FLOATS_PER_INSTANCE * 4;
// Attribute 1: per-instance vec3 (x, y, radius)
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: per-instance vec3 (r, g, b)
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 3, gl.FLOAT, false, stride, 12);
gl.vertexAttribDivisor(2, 1);
// Attribute 3: per-instance vec2 (arcStart, arcEnd)
gl.enableVertexAttribArray(3);
gl.vertexAttribPointer(3, 2, gl.FLOAT, false, stride, 24);
gl.vertexAttribDivisor(3, 1);
gl.bindVertexArray(null);
}
/** Set the local player's ID (from ghost preview ownerID). */
setLocalPlayer(id: number): void {
if (id === this.localPlayerID) return;
this.localPlayerID = id;
this.rebuild();
}
/** Update ally set (player smallIDs allied with local player). */
setAllies(allies: Set<number>): void {
this.allies = allies;
this.rebuild();
}
setPaletteData(data: Float32Array): void {
this.paletteData = data;
}
setColorMode(mode: "perspective" | "owner"): void {
if (mode === this.colorMode) return;
this.colorMode = mode;
this.rebuild();
}
setAllianceClusters(clusters: Map<number, number>): void {
this.allianceClusters = clusters;
}
private rebuild(): void {
if (this.lastStructures) this.updateStructures(this.lastStructures);
}
/** Call with current structures to update SAM positions/radii/colors. */
updateStructures(structures: Map<number, UnitState>): void {
this.lastStructures = structures;
const w = this.mapW;
const ownerMode = this.colorMode === "owner";
// 1. Collect SAM circles
const circles: SAMCircle[] = [];
for (const u of structures.values()) {
if (u.unitType !== UT_SAM_LAUNCHER) continue;
if (!u.isActive) continue;
const x = u.pos % w;
const y = (u.pos - x) / w;
let color: number[];
let group: number;
if (ownerMode && this.paletteData) {
// Owner-colored: palette color, alliance-cluster-based merging
const off = u.ownerID * 4;
color = [
this.paletteData[off],
this.paletteData[off + 1],
this.paletteData[off + 2],
];
group = this.allianceClusters.get(u.ownerID) ?? u.ownerID;
} else {
// Perspective: self/ally/enemy colors, binary group
const isFriendly =
u.ownerID === this.localPlayerID || this.allies.has(u.ownerID);
color =
u.ownerID === this.localPlayerID
? COLOR_SELF
: this.allies.has(u.ownerID)
? COLOR_ALLY
: COLOR_ENEMY;
group = isFriendly ? 0 : 1;
}
circles.push({
x,
y,
radius: samRange(u.level),
color,
group,
});
}
// 2. Compute circle unions → uncovered arcs per circle
let count = 0;
for (const c of circles) {
const arcs = computeUncoveredArcs(c, circles);
for (const [arcStart, arcEnd] of arcs) {
this.instanceBuf.ensureCapacity(count + 1);
const off = count * FLOATS_PER_INSTANCE;
const data = this.instanceBuf.float32;
data[off + 0] = c.x;
data[off + 1] = c.y;
data[off + 2] = c.radius;
data[off + 3] = c.color[0];
data[off + 4] = c.color[1];
data[off + 5] = c.color[2];
data[off + 6] = arcStart;
data[off + 7] = arcEnd;
count++;
}
}
this.instanceCount = count;
if (count > 0) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
count * FLOATS_PER_INSTANCE,
);
}
}
/** Show/hide based on whether build mode is active. */
setVisible(visible: boolean): void {
this.visible = visible;
}
draw(cameraMatrix: Float32Array): void {
if (!this.visible || this.instanceCount === 0) return;
const gl = this.gl;
const time = (performance.now() - this.startTime) / 1000;
const s = this.settings.samRadius;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTime, time);
gl.uniform1f(this.uOutline, this.colorMode === "owner" ? 1.0 : 0.0);
gl.uniform1f(this.uStrokeWidth, s.strokeWidth);
gl.uniform1f(this.uDashLen, s.dashLen);
gl.uniform1f(this.uGapLen, s.gapLen);
gl.uniform1f(this.uRotationSpeed, s.rotationSpeed);
gl.uniform1f(this.uAlpha, s.alpha);
gl.uniform1f(this.uOutlineWidth, s.outlineWidth);
gl.uniform1f(this.uOutlineSoftness, s.outlineSoftness);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,103 @@
/**
* SelectionBoxPass — draws a stippled pulsating square border around a
* selected warship, matching the game's native UILayer selection box.
*
* Single quad with tile-space SDF logic in the fragment shader.
* Active only when a unit is selected via setSelectedUnit().
*/
import { createProgram } from "../utils/gl-utils";
import fragSrc from "../shaders/selection-box/selection-box.frag.glsl?raw";
import vertSrc from "../shaders/selection-box/selection-box.vert.glsl?raw";
/** Half-size of the selection box in tiles (matches game's SELECTION_BOX_SIZE). */
const HALF_SIZE = 6;
export class SelectionBoxPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private uCamera: WebGLUniformLocation;
private uCenter: WebGLUniformLocation;
private uHalfSize: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uColor: WebGLUniformLocation;
private active = false;
private centerX = 0;
private centerY = 0;
private colorR = 1;
private colorG = 1;
private colorB = 1;
constructor(gl: WebGL2RenderingContext) {
this.gl = gl;
this.program = createProgram(gl, vertSrc, fragSrc);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uCenter = gl.getUniformLocation(this.program, "uCenter")!;
this.uHalfSize = gl.getUniformLocation(this.program, "uHalfSize")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uColor = gl.getUniformLocation(this.program, "uColor")!;
// Unit quad [0,1]
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
}
/**
* Set the selection box center and color. Pass active=false to hide.
*/
update(
active: boolean,
centerX: number,
centerY: number,
r: number,
g: number,
b: number,
): void {
this.active = active;
this.centerX = centerX;
this.centerY = centerY;
this.colorR = r;
this.colorG = g;
this.colorB = b;
}
hide(): void {
this.active = false;
}
draw(cameraMatrix: Float32Array, frameTick: number): void {
if (!this.active) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uCenter, this.centerX, this.centerY);
gl.uniform1f(this.uHalfSize, HALF_SIZE);
gl.uniform1f(this.uTime, frameTick);
gl.uniform3f(this.uColor, this.colorR, this.colorG, this.colorB);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
}
@@ -0,0 +1,176 @@
/**
* SpawnOverlayPass — spawn phase tile highlights + breathing rings.
*
* Active only during spawn phase. Renders:
* 1. Colored highlights on unowned tiles within radius 9 of each human
* player's spawn center (blinks every 5th tick).
* 2. Animated breathing rings around the local player and teammates.
*
* Uses a fullscreen map quad (reuses overlay.vert.glsl) so the fragment
* shader can sample tileTex for ownership and compute distance-based
* effects in tile-space coordinates.
*/
import type { RenderSettings } from "../render-settings";
import { createMapQuad, createProgram, shaderSrc } from "../utils/gl-utils";
import { TILE_DEFINES } from "../utils/tile-codec";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
import spawnFragSrc from "../shaders/spawn-overlay/spawn-overlay.frag.glsl?raw";
const MAX_SPAWNS = 32;
export interface SpawnCenter {
x: number;
y: number;
r: number;
g: number;
b: number;
isSelf: boolean;
isTeammate: boolean;
}
export class SpawnOverlayPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private tileTex: WebGLTexture;
private settings: RenderSettings["spawnOverlay"];
// Uniforms
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uSpawnCount: WebGLUniformLocation;
private uBreathRadius: WebGLUniformLocation;
private uSpawnA: WebGLUniformLocation;
private uSpawnB: WebGLUniformLocation;
private uHighlightRadiusSq: WebGLUniformLocation;
private uHighlightAlpha: WebGLUniformLocation;
private uSelfRadii: WebGLUniformLocation;
private uMateRadii: WebGLUniformLocation;
private uGradientStops: WebGLUniformLocation;
private mapW: number;
private mapH: number;
// State
private active = false;
private centers: SpawnCenter[] = [];
private animTime = 0;
private lastTime = 0;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
settings: RenderSettings["spawnOverlay"],
) {
this.gl = gl;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.settings = settings;
this.program = createProgram(
gl,
overlayVertSrc,
shaderSrc(spawnFragSrc, { MAX_SPAWNS, ...TILE_DEFINES }),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uSpawnCount = gl.getUniformLocation(this.program, "uSpawnCount")!;
this.uBreathRadius = gl.getUniformLocation(this.program, "uBreathRadius")!;
this.uSpawnA = gl.getUniformLocation(this.program, "uSpawnA")!;
this.uSpawnB = gl.getUniformLocation(this.program, "uSpawnB")!;
this.uHighlightRadiusSq = gl.getUniformLocation(
this.program,
"uHighlightRadiusSq",
)!;
this.uHighlightAlpha = gl.getUniformLocation(
this.program,
"uHighlightAlpha",
)!;
this.uSelfRadii = gl.getUniformLocation(this.program, "uSelfRadii")!;
this.uMateRadii = gl.getUniformLocation(this.program, "uMateRadii")!;
this.uGradientStops = gl.getUniformLocation(
this.program,
"uGradientStops",
)!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
this.vao = createMapQuad(gl, mapW, mapH);
}
/** Update spawn overlay state each frame. */
update(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
this.active = inSpawnPhase && centers.length > 0;
this.centers = centers;
}
draw(cameraMatrix: Float32Array): void {
if (!this.active) return;
const gl = this.gl;
const s = this.settings;
const now = performance.now();
// Advance animation time
if (this.lastTime > 0) {
this.animTime += (now - this.lastTime) * s.animSpeed;
}
this.lastTime = now;
const breathRadius = 0.5 + 0.5 * Math.sin(this.animTime);
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1i(this.uSpawnCount, Math.min(this.centers.length, MAX_SPAWNS));
gl.uniform1f(this.uBreathRadius, breathRadius);
// Settings-driven uniforms
gl.uniform1f(
this.uHighlightRadiusSq,
s.highlightRadius * s.highlightRadius,
);
gl.uniform1f(this.uHighlightAlpha, s.highlightAlpha);
gl.uniform4f(this.uSelfRadii, s.selfMinRad, s.selfMaxRad, 0, 0);
gl.uniform4f(this.uMateRadii, s.mateMinRad, s.mateMaxRad, 0, 0);
gl.uniform2f(this.uGradientStops, s.gradientInnerEdge, s.gradientSolidEnd);
// Upload spawn center data as vec4 arrays
const count = Math.min(this.centers.length, MAX_SPAWNS);
const dataA = new Float32Array(count * 4);
const dataB = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
const c = this.centers[i];
dataA[i * 4 + 0] = c.x;
dataA[i * 4 + 1] = c.y;
dataA[i * 4 + 2] = c.r;
dataA[i * 4 + 3] = c.g;
dataB[i * 4 + 0] = c.b;
dataB[i * 4 + 1] = c.isSelf ? 1 : 0;
dataB[i * 4 + 2] = c.isTeammate ? 1 : 0;
dataB[i * 4 + 3] = 0;
}
gl.uniform4fv(this.uSpawnA, dataA);
gl.uniform4fv(this.uSpawnB, dataB);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
// tileTex owned by GPUResources
}
}
@@ -0,0 +1,324 @@
/**
* StructureLevelPass — MSDF-rendered level numbers above structures.
*
* Renders level digits for structures with level > 1 using the same MSDF
* atlas and glyph infrastructure as NamePass. One instanced draw call per frame.
*
* Only visible when zoom > dotsThreshold (matching structure icon visibility).
*/
import type { RendererConfig, UnitState } from "../../types";
import {
STRUCTURE_TYPES,
UT_CITY,
UT_DEFENSE_POST,
UT_FACTORY,
UT_MISSILE_SILO,
UT_PORT,
UT_SAM_LAUNCHER,
} from "../../types";
import { DynamicInstanceBuffer } from "../dynamic-buffer";
import type { RenderSettings } from "../render-settings";
import { createProgram } from "../utils/gl-utils";
import type { GlyphTables } from "./name-pass/atlas-data";
import { buildGlyphTables, parseAtlasData } from "./name-pass/atlas-data";
import { buildGlyphMetricsTex } from "./name-pass/data-textures";
import { layoutString } from "./name-pass/text-layout";
import { CHAR_RANGE, MAX_CHARS } from "./name-pass/types";
import atlasUrl from "../assets/msdf-atlas.png?url";
import fragSrc from "../shaders/structure-level/structure-level.frag.glsl?raw";
import vertSrc from "../shaders/structure-level/structure-level.vert.glsl?raw";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Atlas column order — must match StructurePass. */
const STRUCTURE_ORDER = [
UT_CITY,
UT_PORT,
UT_FACTORY,
UT_DEFENSE_POST,
UT_SAM_LAUNCHER,
UT_MISSILE_SILO,
] as const;
/** Max characters per level label (handles up to "99"). */
const MAX_LEVEL_CHARS = 4;
const FLOATS_PER_INSTANCE = 5; // worldX, worldY, cursorX, charCode, atlasIdx
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
// ---------------------------------------------------------------------------
// StructureLevelPass
// ---------------------------------------------------------------------------
export class StructureLevelPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
// Uniform locations
private uCamera: WebGLUniformLocation;
private uZoom: WebGLUniformLocation;
private uIconSize: WebGLUniformLocation;
private uDotsThreshold: WebGLUniformLocation;
private uScaleFactor: WebGLUniformLocation;
private uDistRange: WebGLUniformLocation;
private uOutlineWidth: WebGLUniformLocation;
private uLevelScale: WebGLUniformLocation;
private uHighlightMask: WebGLUniformLocation;
private uHighlightDimAlpha: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private instanceCount = 0;
private glyphMetricsTex: WebGLTexture;
private atlasTex: WebGLTexture | null = null;
private atlasReady = false;
// CPU-side glyph tables for layoutString
private glyph: GlyphTables;
private kernTable: Int8Array;
private mapW: number;
// Reusable buffers for layoutString
private charCodes = new Uint8Array(MAX_CHARS);
private cursors = new Float32Array(MAX_CHARS);
private distanceRange: number;
private fontSize: number;
private atlasScaleH: number;
private base: number;
/** unitType string → atlas column index (05). */
private typeToAtlasCol = new Map<string, number>();
/** Build-button hover highlight bitmask (0 = off). */
private highlightMask = 0;
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = header.mapWidth;
// Build unitType string → atlas column mapping
for (let i = 0; i < header.unitTypes.length; i++) {
const col = STRUCTURE_ORDER.indexOf(
header.unitTypes[i] as (typeof STRUCTURE_ORDER)[number],
);
if (col >= 0) this.typeToAtlasCol.set(header.unitTypes[i], col);
}
// Parse atlas data (same source as NamePass)
const atlas = parseAtlasData();
this.glyph = buildGlyphTables(atlas.chars);
this.kernTable = new Int8Array(CHAR_RANGE * CHAR_RANGE); // digits don't kern
this.distanceRange = atlas.distanceRange;
this.fontSize = atlas.fontSize;
this.atlasScaleH = atlas.scaleH;
this.base = atlas.base;
// Compile shaders
this.program = createProgram(gl, vertSrc, fragSrc);
// Texture unit bindings
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1);
// Static uniforms
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
this.fontSize,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uAtlasScaleH")!,
this.atlasScaleH,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, this.base);
// Dynamic uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uZoom = gl.getUniformLocation(this.program, "uZoom")!;
this.uIconSize = gl.getUniformLocation(this.program, "uIconSize")!;
this.uDotsThreshold = gl.getUniformLocation(
this.program,
"uDotsThreshold",
)!;
this.uScaleFactor = gl.getUniformLocation(this.program, "uScaleFactor")!;
this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!;
this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!;
this.uLevelScale = gl.getUniformLocation(this.program, "uLevelScale")!;
this.uHighlightMask = gl.getUniformLocation(
this.program,
"uHighlightMask",
)!;
this.uHighlightDimAlpha = gl.getUniformLocation(
this.program,
"uHighlightDimAlpha",
)!;
// Glyph metrics data texture
this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas);
// Start async MSDF atlas load
this.loadAtlas();
// Instance buffer
const glBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
glBuf,
4096,
FLOATS_PER_INSTANCE,
);
// VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Attribute 0: unit quad [0,1]²
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Attribute 1: per-instance vec4 (worldX, worldY, cursorX, charCode)
gl.bindBuffer(gl.ARRAY_BUFFER, glBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: per-instance float (atlasIdx)
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 1, gl.FLOAT, false, BYTES_PER_INSTANCE, 16);
gl.vertexAttribDivisor(2, 1);
gl.bindVertexArray(null);
}
private loadAtlas(): void {
const img = new Image();
img.onload = () => {
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
this.atlasTex = tex;
this.atlasReady = true;
};
img.src = atlasUrl;
}
updateStructures(units: Map<number, UnitState>): void {
let count = 0;
for (const unit of units.values()) {
if (!STRUCTURE_TYPES.has(unit.unitType)) continue;
if (unit.level <= 1) continue;
const levelStr = unit.level.toString();
layoutString(
levelStr,
this.glyph,
this.kernTable,
this.charCodes,
this.cursors,
);
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
const len = Math.min(levelStr.length, MAX_LEVEL_CHARS);
const atlasIdx = this.typeToAtlasCol.get(unit.unitType) ?? 0;
for (let i = 0; i < len; i++) {
this.instanceBuf.ensureCapacity(count + 1);
const off = count * FLOATS_PER_INSTANCE;
const data = this.instanceBuf.float32;
data[off + 0] = x;
data[off + 1] = y;
data[off + 2] = this.cursors[i];
data[off + 3] = this.charCodes[i];
data[off + 4] = atlasIdx;
count++;
}
}
this.instanceCount = count;
if (count > 0) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
count * FLOATS_PER_INSTANCE,
);
}
}
draw(cameraMatrix: Float32Array, zoom: number): void {
if (!this.atlasReady || this.instanceCount === 0) return;
const gl = this.gl;
const ss = this.settings.structure;
const sl = this.settings.structureLevel;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uZoom, zoom);
gl.uniform1f(this.uIconSize, ss.iconSize);
gl.uniform1f(this.uDotsThreshold, ss.dotsZoomThreshold);
gl.uniform1f(this.uScaleFactor, ss.iconScaleFactorZoomedOut);
gl.uniform1f(this.uDistRange, this.distanceRange);
gl.uniform1f(this.uOutlineWidth, sl.outlineWidth);
gl.uniform1f(this.uLevelScale, sl.scale);
gl.uniform1i(this.uHighlightMask, this.highlightMask);
gl.uniform1f(this.uHighlightDimAlpha, ss.highlightDimAlpha);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.glyphMetricsTex);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount);
}
/** Highlight structures of the given types (null/empty = off). Dims all other types. */
setHighlightTypes(unitTypes: string[] | null): void {
let mask = 0;
if (unitTypes) {
for (const t of unitTypes) {
const col = this.typeToAtlasCol.get(t);
if (col !== undefined) mask |= 1 << col;
}
}
this.highlightMask = mask;
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
gl.deleteTexture(this.glyphMetricsTex);
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
}
}
@@ -0,0 +1,435 @@
/**
* StructurePass — GPU-rendered structures with icon sprites.
*
* Renders a filled circle in player color with a white icon overlay,
* sampled from a pre-built 6-column sprite atlas (generate-sprite-atlases.mjs).
*
* Two LODs based on zoom:
* - zoom > 0.5: full icon with circle background
* - zoom <= 0.5: smaller dots (no icon detail)
*
* One instanced draw call per frame.
*
* Data flow:
* FrameSnapshot.units → filter structures → instance VBO → GPU
*/
import type { GhostPreviewData, RendererConfig, UnitState } from "../../types";
import {
UT_CITY,
UT_DEFENSE_POST,
UT_FACTORY,
UT_MISSILE_SILO,
UT_PORT,
UT_SAM_LAUNCHER,
} from "../../types";
import { DynamicInstanceBuffer } from "../dynamic-buffer";
import type { RenderSettings } from "../render-settings";
import { getPaletteSize } from "../utils/color-utils";
import { createProgram, shaderSrc } from "../utils/gl-utils";
import structureFragSrc from "../shaders/structure/structure.frag.glsl?raw";
import structureVertSrc from "../shaders/structure/structure.vert.glsl?raw";
// Pre-built icon atlas (generated by scripts/generate-sprite-atlases.mjs)
import iconAtlasUrl from "../assets/icon-atlas.png?url";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* Structure types in atlas column order.
* Index = atlas column index.
*/
const STRUCTURE_ORDER = [
UT_CITY,
UT_PORT,
UT_FACTORY,
UT_DEFENSE_POST,
UT_SAM_LAUNCHER,
UT_MISSILE_SILO,
] as const;
const ATLAS_COLS = STRUCTURE_ORDER.length;
// ---------------------------------------------------------------------------
// Instance data layout
// ---------------------------------------------------------------------------
// Per-instance: x, y, ownerID, underConstruction, atlasIdx, markedForDeletion
const FLOATS_PER_INSTANCE = 6;
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
// ---------------------------------------------------------------------------
// StructurePass
// ---------------------------------------------------------------------------
export class StructurePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private uCamera: WebGLUniformLocation;
private uZoom: WebGLUniformLocation;
private uIconSize: WebGLUniformLocation;
private uDotsThreshold: WebGLUniformLocation;
private uScaleFactor: WebGLUniformLocation;
private uShapeScales: WebGLUniformLocation;
private uIconFills: WebGLUniformLocation;
private uGhostAlpha: WebGLUniformLocation;
private uOutlineColor: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private uHighlightMask: WebGLUniformLocation;
private uHighlightOutlineW: WebGLUniformLocation;
private uHighlightDimAlpha: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private ghostInstanceBuf: WebGLBuffer;
private paletteTex: WebGLTexture;
private atlasTex: WebGLTexture;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
private instanceCount = 0;
/** unitType string → atlas column index (05) */
private typeToAtlasCol = new Map<string, number>();
private mapW: number;
/** Build-button hover highlight: bitmask of atlas columns (0 = off). */
private highlightMask = 0;
/** Ghost preview state (null = no ghost). */
private ghost: GhostPreviewData | null = null;
/** Scratch buffer for the single ghost instance (avoids allocation). */
private ghostBuf = new Float32Array(FLOATS_PER_INSTANCE);
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
paletteTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = header.mapWidth;
this.paletteTex = paletteTex;
// Build unitType string → atlas column mapping
for (let i = 0; i < header.unitTypes.length; i++) {
const col = STRUCTURE_ORDER.indexOf(
header.unitTypes[i] as (typeof STRUCTURE_ORDER)[number],
);
if (col >= 0) {
this.typeToAtlasCol.set(header.unitTypes[i], col);
}
}
// Compile shaders
this.program = createProgram(
gl,
shaderSrc(structureVertSrc, { ATLAS_COLS }),
shaderSrc(structureFragSrc, {
PALETTE_SIZE: getPaletteSize(),
ATLAS_COLS,
}),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uZoom = gl.getUniformLocation(this.program, "uZoom")!;
this.uIconSize = gl.getUniformLocation(this.program, "uIconSize")!;
this.uDotsThreshold = gl.getUniformLocation(
this.program,
"uDotsThreshold",
)!;
this.uScaleFactor = gl.getUniformLocation(this.program, "uScaleFactor")!;
this.uShapeScales = gl.getUniformLocation(this.program, "uShapeScales")!;
this.uIconFills = gl.getUniformLocation(this.program, "uIconFills")!;
this.uGhostAlpha = gl.getUniformLocation(this.program, "uGhostAlpha")!;
this.uOutlineColor = gl.getUniformLocation(this.program, "uOutlineColor")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
this.uHighlightMask = gl.getUniformLocation(
this.program,
"uHighlightMask",
)!;
this.uHighlightOutlineW = gl.getUniformLocation(
this.program,
"uHighlightOutlineW",
)!;
this.uHighlightDimAlpha = gl.getUniformLocation(
this.program,
"uHighlightDimAlpha",
)!;
// Texture unit bindings + ghost defaults
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
gl.uniform1f(this.uGhostAlpha, 1.0);
gl.uniform3f(this.uOutlineColor, 0, 0, 0);
gl.uniform1i(this.uHighlightMask, 0);
// Create placeholder atlas texture (1×1 white pixel)
// Replaced asynchronously once SVGs load
this.atlasTex = gl.createTexture()!;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([255, 255, 255, 255]),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
// Start async atlas build
this.loadAtlas();
// --- Instance buffers ---
const instanceGlBuf = gl.createBuffer()!;
this.instanceBuf = new DynamicInstanceBuffer(
gl,
instanceGlBuf,
2048,
FLOATS_PER_INSTANCE,
);
// Separate tiny buffer for ghost (avoids corrupting real instance data)
this.ghostInstanceBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.ghostInstanceBuf);
gl.bufferData(gl.ARRAY_BUFFER, BYTES_PER_INSTANCE, gl.DYNAMIC_DRAW);
// --- VAO ---
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Attribute 0: unit quad [0,0]→[1,1]
const quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Attribute 1: per-instance vec4 (x, y, ownerID, underConstruction)
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: per-instance vec2 (atlasIdx, markedForDeletion)
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 16);
gl.vertexAttribDivisor(2, 1);
gl.bindVertexArray(null);
}
private async loadAtlas(): Promise<void> {
const img = new Image();
img.src = iconAtlasUrl;
await img.decode();
const gl = this.gl;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
}
updateStructures(units: Map<number, UnitState>): void {
let count = 0;
for (const unit of units.values()) {
const atlasIdx = this.typeToAtlasCol.get(unit.unitType);
if (atlasIdx === undefined) continue;
this.instanceBuf.ensureCapacity(count + 1);
const off = count * FLOATS_PER_INSTANCE;
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
this.instanceBuf.float32[off + 0] = x;
this.instanceBuf.float32[off + 1] = y;
this.instanceBuf.float32[off + 2] = unit.ownerID;
this.instanceBuf.float32[off + 3] = unit.underConstruction ? 1 : 0;
this.instanceBuf.float32[off + 4] = atlasIdx;
this.instanceBuf.float32[off + 5] =
unit.markedForDeletion !== false ? 1 : 0;
count++;
}
this.instanceCount = count;
if (count > 0) {
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.instanceBuf.float32,
0,
count * FLOATS_PER_INSTANCE,
);
}
}
updateGhostPreview(data: GhostPreviewData | null): void {
this.ghost = data;
}
setAltView(active: boolean): void {
this.altView = active;
}
/** Highlight structures of the given types (null/empty = off). Dims all other types. */
setHighlightTypes(unitTypes: string[] | null): void {
let mask = 0;
if (unitTypes) {
for (const t of unitTypes) {
const col = this.typeToAtlasCol.get(t);
if (col !== undefined) mask |= 1 << col;
}
}
this.highlightMask = mask;
}
setAffiliationTex(tex: WebGLTexture): void {
this.affiliationTex = tex;
}
draw(cameraMatrix: Float32Array, zoom: number): void {
const hasGhost =
this.ghost !== null && this.typeToAtlasCol.has(this.ghost.ghostType);
if (this.instanceCount === 0 && !hasGhost) return;
const gl = this.gl;
gl.useProgram(this.program);
const ss = this.settings.structure;
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uZoom, zoom);
gl.uniform1f(this.uIconSize, ss.iconSize);
gl.uniform1f(this.uDotsThreshold, ss.dotsZoomThreshold);
gl.uniform1f(this.uScaleFactor, ss.iconScaleFactorZoomedOut);
// Build per-structure uniform arrays from settings, ordered by atlas column
const scales = new Float32Array(ATLAS_COLS);
const fills = new Float32Array(ATLAS_COLS);
for (let i = 0; i < STRUCTURE_ORDER.length; i++) {
const cfg = ss.shapes[STRUCTURE_ORDER[i]];
scales[i] = cfg?.scale ?? 1.0;
fills[i] = cfg?.iconFill ?? 0.6;
}
gl.uniform1fv(this.uShapeScales, scales);
gl.uniform1fv(this.uIconFills, fills);
gl.uniform1i(
this.uAltView,
this.altView && this.settings.altView.recolorStructures ? 1 : 0,
);
gl.uniform1i(this.uHighlightMask, this.highlightMask);
gl.uniform1f(this.uHighlightOutlineW, ss.highlightOutlineWidth);
gl.uniform1f(this.uHighlightDimAlpha, ss.highlightDimAlpha);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
if (this.affiliationTex) {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
gl.bindVertexArray(this.vao);
// --- Real structures ---
if (this.instanceCount > 0) {
gl.uniform1f(this.uGhostAlpha, 1.0);
gl.uniform3f(this.uOutlineColor, 0, 0, 0);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount);
}
// --- Ghost structure (1 translucent instance with outline) ---
if (hasGhost) {
const g = this.ghost!;
const atlasIdx = this.typeToAtlasCol.get(g.ghostType)!;
// Temporarily rebind instance attrs to ghost buffer
gl.bindBuffer(gl.ARRAY_BUFFER, this.ghostInstanceBuf);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 16);
// -- Green highlight on existing structure being upgraded --
if (g.canUpgrade && g.upgradeTargetTile !== null) {
const tx = g.upgradeTargetTile % this.mapW;
const ty = (g.upgradeTargetTile - tx) / this.mapW;
this.ghostBuf[0] = tx;
this.ghostBuf[1] = ty;
this.ghostBuf[2] = g.ownerID;
this.ghostBuf[3] = 0;
this.ghostBuf[4] = atlasIdx;
this.ghostBuf[5] = 0;
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.ghostBuf);
gl.uniform1f(this.uGhostAlpha, 0.6);
gl.uniform3f(this.uOutlineColor, 0.0, 0.8, 0.0); // green highlight
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 1);
}
// -- Ghost icon at cursor --
this.ghostBuf[0] = g.tileX;
this.ghostBuf[1] = g.tileY;
this.ghostBuf[2] = g.ownerID;
this.ghostBuf[3] = 0;
this.ghostBuf[4] = atlasIdx;
this.ghostBuf[5] = 0;
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.ghostBuf);
gl.uniform1f(this.uGhostAlpha, 0.5);
if (g.canUpgrade) {
gl.uniform3f(this.uOutlineColor, 0.0, 0.8, 0.0); // green tint — upgrade
} else if (g.canBuild) {
gl.uniform3f(this.uOutlineColor, 0, 0, 0); // no tint — valid build
} else {
gl.uniform3f(this.uOutlineColor, 0.8, 0.2, 0.2); // red tint — can't build
}
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 1);
// Restore instance attrs to main buffer
gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 16);
}
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
if (this.ghostInstanceBuf) gl.deleteBuffer(this.ghostInstanceBuf);
gl.deleteVertexArray(this.vao);
gl.deleteTexture(this.atlasTex);
}
}
@@ -0,0 +1,77 @@
/**
* TerrainPass — renders the static terrain map as a textured quad.
*
* The terrain never changes during a replay, so this texture is uploaded
* exactly once and blitted every frame as the opaque background layer.
*
* Vertex shader transforms the map quad by the camera mat3.
* Fragment shader samples the RGBA8 terrain texture with nearest-neighbour
* filtering so each terrain cell stays pixel-crisp at every zoom level.
*/
import terrainFragSrc from "../shaders/terrain/terrain.frag.glsl?raw";
import terrainVertSrc from "../shaders/terrain/terrain.vert.glsl?raw";
import {
createMapQuad,
createProgram,
createTexture2D,
shaderSrc,
} from "../utils/gl-utils";
// ---------------------------------------------------------------------------
// TerrainPass
// ---------------------------------------------------------------------------
export class TerrainPass {
private program: WebGLProgram;
private tex: WebGLTexture;
private vao: WebGLVertexArrayObject;
private uCamera: WebGLUniformLocation;
constructor(
private gl: WebGL2RenderingContext,
terrainRGBA: Uint8Array,
mapW: number,
mapH: number,
) {
this.program = createProgram(
gl,
shaderSrc(terrainVertSrc, { MAP_W: mapW, MAP_H: mapH }),
terrainFragSrc,
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
// Static RGBA8 terrain texture — uploaded once, never updated.
this.tex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.RGBA8,
format: gl.RGBA,
type: gl.UNSIGNED_BYTE,
data: terrainRGBA,
filter: gl.NEAREST, // pixel-crisp at all zoom levels
});
this.vao = createMapQuad(gl, mapW, mapH);
}
/** Render the terrain. Call with depth test disabled, no blending. */
draw(cameraMatrix: Float32Array): void {
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteTexture(this.tex);
// VAO + buffer leak is acceptable on dispose (context is being destroyed)
}
}
@@ -0,0 +1,353 @@
/**
* TerritoryPass — territory fill + fallout charcoal ground.
*
* Draws only what should be darkened by the night cycle:
* - Owned territory (player color fill)
* - Unowned fallout (charcoal ground)
*
* No borders, embers, trails, or defense checkerboard — those are
* handled by BorderStampPass and TrailPass at full brightness.
*
* Also owns the CPU-side tile and trail state, flushing to shared
* GPU textures on draw.
*/
import type { TilePair } from "../../types";
import type { RenderSettings } from "../render-settings";
import { getPaletteSize } from "../utils/color-utils";
import { createMapQuad, createProgram, shaderSrc } from "../utils/gl-utils";
import { OWNER_MASK, TILE_DEFINES } from "../utils/tile-codec";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
import territoryFragSrc from "../shaders/map-overlay/territory.frag.glsl?raw";
export class TerritoryPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private program: WebGLProgram;
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private uCharcoalBase: WebGLUniformLocation;
private uCharcoalVariation: WebGLUniformLocation;
private uCharcoalAlpha: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private tileTex: WebGLTexture;
private trailTex: WebGLTexture;
private paletteTex: WebGLTexture;
private altView = false;
/** CPU-side tile state (deltas written here, flushed to GPU before draw). */
private cpuTileState: Uint16Array;
private tilesDirty = false;
/** CPU-side trail state (R8UI, 0=none, 1255=ownerID). */
private cpuTrailState: Uint8Array;
private trailsDirty = false;
/** Live-game references — bypasses memcpy. Null for replay path. */
private liveTileRef: Uint16Array | null = null;
private liveTrailRef: Uint8Array | null = null;
/** Dirty row range for partial tile upload. Infinity/-1 = full upload. */
private dirtyRowMin = Infinity;
private dirtyRowMax = -1;
/** Dirty row range for partial trail upload. Infinity/-1 = full upload. */
private trailDirtyRowMin = Infinity;
private trailDirtyRowMax = -1;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
trailTex: WebGLTexture,
paletteTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.trailTex = trailTex;
this.paletteTex = paletteTex;
this.cpuTileState = new Uint16Array(mapW * mapH);
this.cpuTrailState = new Uint8Array(mapW * mapH);
this.program = createProgram(
gl,
overlayVertSrc,
shaderSrc(territoryFragSrc, {
PALETTE_SIZE: getPaletteSize(),
...TILE_DEFINES,
}),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
this.uCharcoalBase = gl.getUniformLocation(this.program, "uCharcoalBase")!;
this.uCharcoalVariation = gl.getUniformLocation(
this.program,
"uCharcoalVariation",
)!;
this.uCharcoalAlpha = gl.getUniformLocation(
this.program,
"uCharcoalAlpha",
)!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
this.vao = createMapQuad(gl, mapW, mapH);
}
// ---------------------------------------------------------------------------
// Tile data upload
// ---------------------------------------------------------------------------
/** Full tile state upload (on seek). */
uploadFullTileState(tileState: Uint16Array): void {
this.liveTileRef = null;
this.cpuTileState.set(tileState);
this.tilesDirty = true;
}
/** Live-game path: reference the game's own arrays directly. */
setLiveRefs(tileState: Uint16Array, trailState: Uint8Array): void {
this.liveTileRef = tileState;
this.liveTrailRef = trailState;
this.tilesDirty = true;
this.trailsDirty = true;
}
/** Apply tile deltas (during playback). */
uploadDeltaTiles(changedTiles: TilePair[]): void {
const ts = this.cpuTileState;
for (let i = 0; i < changedTiles.length; i++) {
const tp = changedTiles[i];
ts[tp.ref] = tp.state;
}
this.tilesDirty = true;
}
/** Live delta: update live ref + compute dirty row range from deltas. */
applyLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void {
this.liveTileRef = tileState;
let minRow = Infinity,
maxRow = -1;
for (let i = 0; i < changedTiles.length; i++) {
const row = (changedTiles[i].ref / this.mapW) | 0;
if (row < minRow) minRow = row;
if (row > maxRow) maxRow = row;
}
if (maxRow >= 0) {
this.dirtyRowMin = Math.min(this.dirtyRowMin, minRow);
this.dirtyRowMax = Math.max(this.dirtyRowMax, maxRow);
}
this.tilesDirty = true;
}
/** Live trail delta: update live ref + accept dirty row range from TrailManager. */
applyLiveTrailDelta(
trailState: Uint8Array,
dirtyRowMin: number,
dirtyRowMax: number,
): void {
this.liveTrailRef = trailState;
if (dirtyRowMax >= 0) {
this.trailDirtyRowMin = Math.min(this.trailDirtyRowMin, dirtyRowMin);
this.trailDirtyRowMax = Math.max(this.trailDirtyRowMax, dirtyRowMax);
}
this.trailsDirty = true;
}
/** Full trail state upload (on seek). */
uploadFullTrailState(trailState: Uint8Array): void {
this.liveTrailRef = null;
this.cpuTrailState.set(trailState);
this.trailsDirty = true;
}
/** Set a single trail tile (during playback advance). */
setTrailTile(ref: number, ownerID: number): void {
this.cpuTrailState[ref] = ownerID;
this.trailsDirty = true;
}
/** Clear all trails (on seek before rebuilding). */
clearTrails(): void {
this.cpuTrailState.fill(0);
this.trailsDirty = true;
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/** Get ownerID at a tile reference. Returns 0 for unowned. */
getOwnerAt(tileRef: number): number {
const ts = this.liveTileRef ?? this.cpuTileState;
if (tileRef < 0 || tileRef >= ts.length) return 0;
return ts[tileRef] & OWNER_MASK;
}
/** AABB of all tiles owned by ownerID. */
getBBoxForOwner(
ownerID: number,
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const w = this.mapW;
const ts = this.liveTileRef ?? this.cpuTileState;
for (let i = 0; i < ts.length; i++) {
if ((ts[i] & OWNER_MASK) === ownerID) {
const x = i % w;
const y = (i - x) / w;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
return minX === Infinity ? null : { minX, minY, maxX, maxY };
}
// ---------------------------------------------------------------------------
// GPU flush + draw
// ---------------------------------------------------------------------------
/** Flush tile texture to GPU early (before heat update reads it). Returns true if data was uploaded. */
flushTileTexture(): boolean {
if (!this.tilesDirty) return false;
const gl = this.gl;
const src = this.liveTileRef ?? this.cpuTileState;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
if (this.dirtyRowMax >= 0) {
// Partial upload — only dirty rows
const minRow = this.dirtyRowMin;
const rowCount = this.dirtyRowMax - minRow + 1;
const offset = minRow * this.mapW;
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
minRow,
this.mapW,
rowCount,
gl.RED_INTEGER,
gl.UNSIGNED_SHORT,
src.subarray(offset, offset + rowCount * this.mapW),
);
} else {
// Full upload (first tick, seek, replay full frame, etc.)
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
this.mapW,
this.mapH,
gl.RED_INTEGER,
gl.UNSIGNED_SHORT,
src,
);
}
this.dirtyRowMin = Infinity;
this.dirtyRowMax = -1;
this.tilesDirty = false;
return true;
}
/** Flush trail texture to GPU (called before TrailPass draws). */
flushTrailTexture(): void {
if (!this.trailsDirty) return;
const gl = this.gl;
const src = this.liveTrailRef ?? this.cpuTrailState;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.trailTex);
if (this.trailDirtyRowMax >= 0) {
// Partial upload — only dirty rows
const minRow = this.trailDirtyRowMin;
const rowCount = this.trailDirtyRowMax - minRow + 1;
const offset = minRow * this.mapW;
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
minRow,
this.mapW,
rowCount,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
src.subarray(offset, offset + rowCount * this.mapW),
);
} else {
// Full upload (first tick, seek, replay, etc.)
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
this.mapW,
this.mapH,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
src,
);
}
this.trailDirtyRowMin = Infinity;
this.trailDirtyRowMax = -1;
this.trailsDirty = false;
}
setAltView(active: boolean): void {
this.altView = active;
}
/** Draw territory fill + fallout charcoal. Blending must be enabled by caller. */
draw(cameraMatrix: Float32Array): void {
this.flushTileTexture();
this.flushTrailTexture();
const gl = this.gl;
const mo = this.settings.mapOverlay;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.uniform1f(this.uCharcoalBase, mo.charcoalBase);
gl.uniform1f(this.uCharcoalVariation, mo.charcoalVariation);
gl.uniform1f(this.uCharcoalAlpha, mo.charcoalAlpha);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
// tileTex, trailTex, paletteTex owned by GPUResources / renderer
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* TrailPass — boat trail lines.
*
* Simple dedicated pass: for each tile with a non-zero trail owner,
* output the owner's territory color at configurable alpha.
* Always draws at full brightness (after night composite).
*/
import type { RenderSettings } from "../render-settings";
import { getPaletteSize } from "../utils/color-utils";
import { createMapQuad, createProgram, shaderSrc } from "../utils/gl-utils";
import { TILE_DEFINES } from "../utils/tile-codec";
import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw";
import trailFragSrc from "../shaders/map-overlay/trail.frag.glsl?raw";
export class TrailPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private mapW: number;
private mapH: number;
private program: WebGLProgram;
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uTrailAlpha: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private trailTex: WebGLTexture;
private paletteTex: WebGLTexture;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
trailTex: WebGLTexture,
paletteTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.trailTex = trailTex;
this.paletteTex = paletteTex;
this.program = createProgram(
gl,
overlayVertSrc,
shaderSrc(trailFragSrc, {
PALETTE_SIZE: getPaletteSize(),
...TILE_DEFINES,
}),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uTrailAlpha = gl.getUniformLocation(this.program, "uTrailAlpha")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTrailTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
this.vao = createMapQuad(gl, mapW, mapH);
}
setAltView(active: boolean): void {
this.altView = active;
}
setAffiliationTex(tex: WebGLTexture): void {
this.affiliationTex = tex;
}
/** Draw trail overlay. Blending must be enabled by caller. */
draw(cameraMatrix: Float32Array): void {
const gl = this.gl;
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uTrailAlpha, this.settings.mapOverlay.trailAlpha);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.trailTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
if (this.affiliationTex) {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
}
+513
View File
@@ -0,0 +1,513 @@
/**
* UnitPass — GPU-rendered mobile unit sprites.
*
* Renders all mobile (non-structure) units: boats, nukes, shells, SAM
* missiles, and MIRV warheads. All unit types are rotationally symmetric
* — no rotation needed. Sprites are tiny grayscale PNGs colorized on the
* GPU using the standard 3-band gray replacement (180/130/70). Shell and
* MIRV Warhead use programmatic 3×3 white squares (colorized to border
* color).
*
* Two instanced draw calls per frame — ground units and missiles are
* split into separate buffers for correct layer ordering:
* Ground/sea (boats, trains) → rendered below structures
* Missiles (nukes, shells, SAM, MIRV warheads) → rendered above structures
*
* Atlas layout (12 columns × 13px cells, pre-built by generate-sprite-atlases.mjs):
* Col 0: Transport (5×5)
* Col 1: Trade Ship (5×5)
* Col 2: Warship (11×11)
* Col 3: Atom Bomb (7×7)
* Col 4: Hydrogen Bomb (9×9)
* Col 5: MIRV (13×13, grayscale colorized)
* Col 6: SAM Missile (3×3)
* Col 7: Shell (3×3 white square)
* Col 8: MIRV Warhead (3×3 white square)
* Col 9: Train Engine (5×5)
* Col 10: Train Carriage (5×5)
* Col 11: Train Carriage Loaded (5×5)
*
* Data flow:
* FrameSnapshot.units → filter by typeToAtlasIdx → instance VBO → GPU
* Shells emit 2 instances (pos + lastPos) to match live game's 2-pixel trail.
*/
import type { RendererConfig, UnitState } from "../../types";
import {
TrainType,
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
UT_MIRV,
UT_MIRV_WARHEAD,
UT_SAM_MISSILE,
UT_SHELL,
UT_TRADE_SHIP,
UT_TRAIN,
UT_TRANSPORT,
UT_WARSHIP,
} from "../../types";
import { DynamicInstanceBuffer } from "../dynamic-buffer";
import type { RenderSettings } from "../render-settings";
import unitFragSrc from "../shaders/unit/unit.frag.glsl?raw";
import unitVertSrc from "../shaders/unit/unit.vert.glsl?raw";
import { getPaletteSize } from "../utils/color-utils";
import { createProgram, shaderSrc } from "../utils/gl-utils";
// Pre-built sprite atlas (generated by scripts/generate-sprite-atlases.mjs)
import unitAtlasUrl from "../assets/unit-atlas.png?url";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Unit types in atlas column order. Index = atlas column.
* TrainEngine/TrainCarriage/TrainCarriageLoaded are synthetic names —
* they don't match header.unitTypes directly. Train resolution is
* handled specially in updateUnits() via trainType + loaded fields.
*/
const UNIT_ORDER = [
UT_TRANSPORT,
UT_TRADE_SHIP,
UT_WARSHIP,
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
UT_MIRV,
UT_SAM_MISSILE,
UT_SHELL,
UT_MIRV_WARHEAD,
"TrainEngine",
"TrainCarriage",
"TrainCarriageLoaded",
] as const;
const ATLAS_COLS = UNIT_ORDER.length;
// ---------------------------------------------------------------------------
// Instance data layout
// ---------------------------------------------------------------------------
/**
* Per-instance data (16 bytes):
* float x, y, ownerID — 12 bytes (3 floats)
* uint8 atlasIdx — 1 byte (atlas column 011)
* uint8 flags — 1 byte (0 = normal, 1 = flicker, 2 = angry)
* 2 bytes padding — aligns to 4-byte boundary
*/
const FLOATS_PER_INSTANCE = 4;
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
/** Flag values — passed as uint8, received as float in shader via normalized attribute */
const FLAG_NORMAL = 0;
const FLAG_FLICKER = 1;
const FLAG_ANGRY = 2;
const FLAG_TRADE_FRIENDLY = 3;
/** Atlas column indices for train sub-types (resolved from trainType + loaded) */
const TRAIN_ENGINE_COL = UNIT_ORDER.indexOf("TrainEngine");
const TRAIN_CARRIAGE_COL = UNIT_ORDER.indexOf("TrainCarriage");
const TRAIN_CARRIAGE_LOADED_COL = UNIT_ORDER.indexOf("TrainCarriageLoaded");
/** Nuke + warhead types — rendered with flickering hot colors */
const FLICKER_TYPES: ReadonlySet<string> = new Set([
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
UT_MIRV,
UT_MIRV_WARHEAD,
UT_SAM_MISSILE,
UT_SHELL,
]);
/** Missile/projectile types — rendered on top of structures in the layer order.
* Ground/sea units (boats, trains) render below structures. */
const MISSILE_TYPES: ReadonlySet<string> = new Set([
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
UT_MIRV,
UT_SAM_MISSILE,
UT_SHELL,
UT_MIRV_WARHEAD,
]);
// ---------------------------------------------------------------------------
// Helper: create a VAO for instanced unit rendering
// ---------------------------------------------------------------------------
function createUnitVao(
gl: WebGL2RenderingContext,
quadBuf: WebGLBuffer,
instanceBuf: WebGLBuffer,
): WebGLVertexArrayObject {
const vao = gl.createVertexArray()!;
gl.bindVertexArray(vao);
// Attribute 0: unit quad [0,0]->[1,1]
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Attribute 1: per-instance vec3 (x, y, ownerID) — 3 floats at offset 0
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuf);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_INSTANCE, 0);
gl.vertexAttribDivisor(1, 1);
// Attribute 2: per-instance (atlasIdx, flags) — 2 uint8s at offset 12, converted to float
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, BYTES_PER_INSTANCE, 12);
gl.vertexAttribDivisor(2, 1);
gl.bindVertexArray(null);
return vao;
}
// ---------------------------------------------------------------------------
// UnitPass
// ---------------------------------------------------------------------------
export class UnitPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
private program: WebGLProgram;
private uCamera: WebGLUniformLocation;
private uTick: WebGLUniformLocation;
private uUnitSize: WebGLUniformLocation;
private uFlickerSpeed: WebGLUniformLocation;
private uAngryColor: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
// Ground/sea units (boats, trains) — render below structures
private groundVao: WebGLVertexArrayObject;
private groundBuf: DynamicInstanceBuffer;
private groundCount = 0;
// Missiles/projectiles (nukes, shells, SAM) — render above structures
private missileVao: WebGLVertexArrayObject;
private missileBuf: DynamicInstanceBuffer;
private missileCount = 0;
private quadBuf: WebGLBuffer;
private paletteTex: WebGLTexture;
private atlasTex: WebGLTexture;
/** Frame tick received from renderer — drives tick-based effects */
private frameTick = 0;
/** unitType string → atlas column (0-11) */
private typeToAtlasCol = new Map<string, number>();
private mapW: number;
// Trade-friendly detection: enemy trade ships heading to a self/allied port
private localPlayerID = 0;
private friendlyOwners = new Set<number>();
private structures: Map<number, UnitState> = new Map();
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
paletteTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
this.settings = settings;
this.mapW = header.mapWidth;
this.paletteTex = paletteTex;
// Build unitType string → atlas column mapping
for (let i = 0; i < header.unitTypes.length; i++) {
const col = UNIT_ORDER.indexOf(
header.unitTypes[i] as (typeof UNIT_ORDER)[number],
);
if (col >= 0) {
this.typeToAtlasCol.set(header.unitTypes[i], col);
}
}
// Compile shaders
this.program = createProgram(
gl,
shaderSrc(unitVertSrc, { ATLAS_COLS }),
shaderSrc(unitFragSrc, { PALETTE_SIZE: getPaletteSize() }),
);
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uTick = gl.getUniformLocation(this.program, "uTick")!;
this.uUnitSize = gl.getUniformLocation(this.program, "uUnitSize")!;
this.uFlickerSpeed = gl.getUniformLocation(this.program, "uFlickerSpeed")!;
this.uAngryColor = gl.getUniformLocation(this.program, "uAngryColor")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
// Texture unit bindings
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
// Create placeholder atlas texture (1x1 gray pixel)
this.atlasTex = gl.createTexture()!;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([128, 128, 128, 255]),
);
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);
// Start async atlas build
this.loadAtlas();
// --- Shared quad buffer ---
this.quadBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]),
gl.STATIC_DRAW,
);
// --- Ground instance buffer + VAO ---
const groundGlBuf = gl.createBuffer()!;
this.groundBuf = new DynamicInstanceBuffer(
gl,
groundGlBuf,
1024,
FLOATS_PER_INSTANCE,
);
this.groundVao = createUnitVao(gl, this.quadBuf, groundGlBuf);
// --- Missile instance buffer + VAO ---
const missileGlBuf = gl.createBuffer()!;
this.missileBuf = new DynamicInstanceBuffer(
gl,
missileGlBuf,
512,
FLOATS_PER_INSTANCE,
);
this.missileVao = createUnitVao(gl, this.quadBuf, missileGlBuf);
}
private async loadAtlas(): Promise<void> {
const img = new Image();
img.src = unitAtlasUrl;
await img.decode();
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
private emitGround(
x: number,
y: number,
ownerID: number,
atlasIdx: number,
flags: number,
): void {
this.groundBuf.ensureCapacity(this.groundCount + 1);
const off = this.groundCount * FLOATS_PER_INSTANCE;
this.groundBuf.float32[off + 0] = x;
this.groundBuf.float32[off + 1] = y;
this.groundBuf.float32[off + 2] = ownerID;
const byteOff = this.groundCount * BYTES_PER_INSTANCE;
this.groundBuf.uint8[byteOff + 12] = atlasIdx;
this.groundBuf.uint8[byteOff + 13] = flags;
this.groundCount++;
}
private emitMissile(
x: number,
y: number,
ownerID: number,
atlasIdx: number,
flags: number,
): void {
this.missileBuf.ensureCapacity(this.missileCount + 1);
const off = this.missileCount * FLOATS_PER_INSTANCE;
this.missileBuf.float32[off + 0] = x;
this.missileBuf.float32[off + 1] = y;
this.missileBuf.float32[off + 2] = ownerID;
const byteOff = this.missileCount * BYTES_PER_INSTANCE;
this.missileBuf.uint8[byteOff + 12] = atlasIdx;
this.missileBuf.uint8[byteOff + 13] = flags;
this.missileCount++;
}
updateUnits(units: Map<number, UnitState>, tick: number): void {
this.frameTick = tick;
this.groundCount = 0;
this.missileCount = 0;
for (const unit of units.values()) {
if (!unit.isActive) continue;
let atlasIdx = this.typeToAtlasCol.get(unit.unitType);
// Train sub-type resolution: "Train" isn't in UNIT_ORDER.
// Resolve to engine/carriage/loaded carriage based on trainType + loaded fields.
if (atlasIdx === undefined && unit.unitType === UT_TRAIN) {
const tt = unit.trainType;
if (tt === TrainType.Engine || tt === TrainType.TailEngine) {
atlasIdx = TRAIN_ENGINE_COL;
} else {
atlasIdx = unit.loaded
? TRAIN_CARRIAGE_LOADED_COL
: TRAIN_CARRIAGE_COL;
}
}
if (atlasIdx === undefined) continue;
const isAngryWarship =
unit.unitType === UT_WARSHIP && unit.targetUnitId !== null;
const isFlicker = FLICKER_TYPES.has(unit.unitType);
// Enemy trade ships heading to a self/allied port get FLAG_TRADE_FRIENDLY
// so alt-view renders them yellow instead of red.
let isTradeFriendly = false;
if (
unit.unitType === UT_TRADE_SHIP &&
unit.targetUnitId !== null &&
this.localPlayerID > 0
) {
const targetPort = this.structures.get(unit.targetUnitId);
if (targetPort) {
const portOwner = targetPort.ownerID;
isTradeFriendly =
portOwner === this.localPlayerID ||
this.friendlyOwners.has(portOwner);
}
}
const flags = isTradeFriendly
? FLAG_TRADE_FRIENDLY
: isAngryWarship
? FLAG_ANGRY
: isFlicker
? FLAG_FLICKER
: FLAG_NORMAL;
const isMissile = MISSILE_TYPES.has(unit.unitType);
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
if (isMissile) {
this.emitMissile(x, y, unit.ownerID, atlasIdx, flags);
// Shells emit a second instance at lastPos (2-pixel trail effect)
if (unit.unitType === UT_SHELL && unit.lastPos !== unit.pos) {
const lx = unit.lastPos % this.mapW;
const ly = (unit.lastPos - lx) / this.mapW;
this.emitMissile(lx, ly, unit.ownerID, atlasIdx, flags);
}
} else {
this.emitGround(x, y, unit.ownerID, atlasIdx, flags);
}
}
const gl = this.gl;
if (this.groundCount > 0) {
gl.bindBuffer(gl.ARRAY_BUFFER, this.groundBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.groundBuf.float32,
0,
this.groundCount * FLOATS_PER_INSTANCE,
);
}
if (this.missileCount > 0) {
gl.bindBuffer(gl.ARRAY_BUFFER, this.missileBuf.buffer);
gl.bufferSubData(
gl.ARRAY_BUFFER,
0,
this.missileBuf.float32,
0,
this.missileCount * FLOATS_PER_INSTANCE,
);
}
}
setAltView(active: boolean): void {
this.altView = active;
}
setAffiliationTex(tex: WebGLTexture): void {
this.affiliationTex = tex;
}
setLocalPlayer(id: number): void {
this.localPlayerID = id;
}
setAllies(allies: Set<number>): void {
this.friendlyOwners = allies;
}
setStructures(structs: Map<number, UnitState>): void {
this.structures = structs;
}
/** Bind shared program state + uniforms (call before drawGround/drawMissiles). */
private bindProgram(cameraMatrix: Float32Array): void {
const gl = this.gl;
gl.useProgram(this.program);
const us = this.settings.unit;
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform1f(this.uTick, this.frameTick);
gl.uniform1f(this.uUnitSize, us.unitSize);
gl.uniform1f(this.uFlickerSpeed, us.flickerSpeed);
gl.uniform3f(this.uAngryColor, us.angryR, us.angryG, us.angryB);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
if (this.affiliationTex) {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
}
/** Draw ground/sea units (boats, trains). Render below structures. */
drawGround(cameraMatrix: Float32Array): void {
if (this.groundCount === 0) return;
this.bindProgram(cameraMatrix);
const gl = this.gl;
gl.bindVertexArray(this.groundVao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.groundCount);
}
/** Draw missiles/projectiles (nukes, shells, SAM, MIRV warheads). Render above structures. */
drawMissiles(cameraMatrix: Float32Array): void {
if (this.missileCount === 0) return;
this.bindProgram(cameraMatrix);
const gl = this.gl;
gl.bindVertexArray(this.missileVao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.missileCount);
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.program);
this.groundBuf.dispose();
this.missileBuf.dispose();
gl.deleteBuffer(this.quadBuf);
gl.deleteVertexArray(this.groundVao);
gl.deleteVertexArray(this.missileVao);
gl.deleteTexture(this.atlasTex);
}
}
+316
View File
@@ -0,0 +1,316 @@
{
"passEnabled": {
"terrain": true,
"mapOverlay": true,
"structure": true,
"unit": true,
"name": true,
"falloutBloom": true,
"railroad": true,
"fx": true,
"bar": true,
"dayNight": true,
"nameDebug": false
},
"falloutBloom": {
"broilSpeedCold": 0.0018,
"broilSpeedHot": 0,
"noiseFreq1": 0.059,
"noiseFreq2": 0.171,
"contrastLoCold": 0.52,
"contrastLoHot": 0,
"contrastHiCold": 1,
"contrastHiHot": 0,
"metaFreq": 0.02,
"intensityCold": 0.15,
"intensityHot": 0.6,
"metaInfluenceCold": 1,
"metaInfluenceHot": 0,
"opacityFadeEnd": 1,
"bloomR": 0.054901960784313725,
"bloomG": 0.8196078431372549,
"bloomB": 0,
"bloomCoverage": 1.1,
"heatDecayPerTick": 1
},
"dayNight": {
"mode": "cycle",
"cycleTicks": 6000,
"startPhase": 0,
"noonHold": 0.25,
"nightHold": 0.1,
"nightAmbient": 0.15,
"dayAmbient": 1,
"falloffPower": 2,
"falloutLightR": 0.15,
"falloutLightG": 0.95,
"falloutLightB": 0.15,
"falloutLightIntensity": 5.2,
"falloutLightThreshold": 0.01,
"emberLightR": 1,
"emberLightG": 0.4,
"emberLightB": 0.05,
"emberLightIntensity": 3,
"blurZoomDivisor": 4,
"lightRadiusMultiplier": 1
},
"mapOverlay": {
"trailAlpha": 0.588,
"defenseCheckerDarken": 0.7,
"charcoalBase": 0,
"charcoalVariation": 0.05,
"charcoalAlpha": 0.87,
"emberThresholdUnowned": 0.85,
"emberThresholdOwned": 0.875,
"emberFlickerSpeed": 0.12,
"emberColorDarkR": 0.6,
"emberColorDarkG": 0.15,
"emberColorDarkB": 0,
"emberColorBrightR": 1,
"emberColorBrightG": 0.5,
"emberColorBrightB": 0.05,
"emberStrengthUnowned": 0.5,
"highlightBrighten": 0.6,
"highlightThicken": 2,
"defensePostRange": 30,
"embargoTintRatio": 0.35,
"friendlyTintRatio": 0.35
},
"railroad": {
"railMinZoom": 2,
"railDetailZoom": 4,
"railAlpha": 1
},
"structure": {
"iconSize": 35,
"dotsZoomThreshold": 0.5,
"iconScaleFactorZoomedOut": 1.4,
"shapes": {
"City": {
"scale": 1,
"iconFill": 0.85
},
"Port": {
"scale": 1.08,
"iconFill": 0.85
},
"Factory": {
"scale": 1,
"iconFill": 0.85
},
"Defense Post": {
"scale": 1,
"iconFill": 0.8
},
"SAM Launcher": {
"scale": 1.4,
"iconFill": 1
},
"Missile Silo": {
"scale": 1.55,
"iconFill": 0.85
}
},
"highlightOutlineWidth": 0.04,
"highlightDimAlpha": 0.3
},
"structureLevel": {
"scale": 1.2,
"outlineWidth": 1.4
},
"bar": {
"healthBarW": 11,
"healthBarH": 3,
"healthBarOffsetY": -6,
"progressBarW": 14,
"progressBarH": 3,
"progressBarOffsetY": 6,
"borderWidth": 1,
"threshold1": 0.25,
"threshold2": 0.5,
"threshold3": 0.75,
"colorRedR": 0.91,
"colorRedG": 0.098,
"colorRedB": 0.098,
"colorOrangeR": 0.941,
"colorOrangeG": 0.478,
"colorOrangeB": 0.098,
"colorYellowR": 0.792,
"colorYellowG": 0.906,
"colorYellowB": 0.059,
"colorGreenR": 0.173,
"colorGreenG": 0.937,
"colorGreenB": 0.071
},
"unit": {
"unitSize": 13,
"flickerSpeed": 0.3,
"angryR": 0.784,
"angryG": 0,
"angryB": 0
},
"name": {
"lerpSpeed": 10,
"cullThreshold": 0.008,
"nameScaleFactor": 0.4,
"nameScaleCap": 3,
"troopSizeMultiplier": 0.6,
"outlineWidth": 1.4,
"outlineR": 1.0,
"outlineG": 1.0,
"outlineB": 1.0,
"outlineUsePlayerColor": false,
"fillUsePlayerColor": true,
"emojiRowOffset": 1.4,
"statusRowOffset": 2.5
},
"fx": {
"shockwaveRingWidth": 0.04,
"nukeShockwaveDurationMs": 1500,
"nukeShockwaveRadiusFactor": 1.5,
"samShockwaveDurationMs": 800,
"samShockwaveRadius": 40,
"debrisLifetimeMs": 6000,
"debrisFadeIn": 0.1,
"debrisFadeOut": 0.8,
"conquestLifetimeMs": 2500,
"conquestFadeIn": 0.1,
"conquestFadeOut": 0.6
},
"nukeTrajectory": {
"lineWidth": 1.25,
"outlineWidth": 1.5,
"dashTargetable": 8,
"gapTargetable": 4,
"dashUntargetable": 2,
"gapUntargetable": 6,
"lineR": 1,
"lineG": 1,
"lineB": 1,
"interceptR": 1,
"interceptG": 0.314,
"interceptB": 0.314,
"outlineR": 0.549,
"outlineG": 0.549,
"outlineB": 0.549,
"interceptOutlineR": 0.588,
"interceptOutlineG": 0.353,
"interceptOutlineB": 0.353,
"markerCircleRadius": 6,
"markerXRadius": 8
},
"nukeTelegraph": {
"strokeWidth": 1.5,
"dashLen": 12,
"gapLen": 6,
"rotationSpeed": 20,
"baseAlpha": 0.85,
"pulseAmplitude": 0.1,
"pulseSpeed": 3,
"fillAlphaOffset": 0.6,
"colorR": 1,
"colorG": 0,
"colorB": 0
},
"moveIndicator": {
"startRadius": 13,
"chevronSize": 5,
"lineWidth": 2,
"duration": 800,
"converge": 0.7
},
"samRadius": {
"strokeWidth": 1.5,
"dashLen": 12,
"gapLen": 6,
"rotationSpeed": 14,
"alpha": 0.8,
"outlineWidth": 0.4,
"outlineSoftness": 0.15
},
"bonusPopup": {
"scale": 6,
"lifetimeMs": 1500,
"riseSpeed": 3,
"yOffset": -3,
"outlineWidth": 2,
"colorR": 1,
"colorG": 1,
"colorB": 1,
"minScreenScale": 0.15,
"cullZoom": 0.3
},
"spawnOverlay": {
"highlightRadius": 9,
"highlightAlpha": 1.0,
"selfMinRad": 8,
"selfMaxRad": 24,
"mateMinRad": 5,
"mateMaxRad": 14,
"animSpeed": 0.005,
"gradientInnerEdge": 0.01,
"gradientSolidEnd": 0.1
},
"altView": {
"gridFontSize": 16,
"recolorStructures": true
},
"lightConfigs": {
"City": {
"radius": 18,
"intensity": 1.2
},
"Port": {
"radius": 12,
"intensity": 1
},
"Factory": {
"radius": 12,
"intensity": 1
},
"Defense Post": {
"radius": 10,
"intensity": 0.9
},
"SAM Launcher": {
"radius": 10,
"intensity": 0.9
},
"Missile Silo": {
"radius": 10,
"intensity": 0.9
},
"Transport": {
"radius": 6,
"intensity": 2.7
},
"Trade Ship": {
"radius": 6,
"intensity": 2.7
},
"Warship": {
"radius": 10,
"intensity": 2.8
},
"Atom Bomb": {
"radius": 16,
"intensity": 1.1
},
"Hydrogen Bomb": {
"radius": 22,
"intensity": 1.3
},
"MIRV": {
"radius": 18,
"intensity": 1.2
},
"MIRV Warhead": {
"radius": 12,
"intensity": 1
},
"Train": {
"radius": 8,
"intensity": 2
}
}
}
+253
View File
@@ -0,0 +1,253 @@
import defaults from "./render-settings.json";
export interface RenderSettings {
passEnabled: {
terrain: boolean;
mapOverlay: boolean;
structure: boolean;
unit: boolean;
name: boolean;
falloutBloom: boolean;
railroad: boolean;
fx: boolean;
bar: boolean;
dayNight: boolean;
nameDebug: boolean;
};
falloutBloom: {
broilSpeedCold: number;
broilSpeedHot: number;
noiseFreq1: number;
noiseFreq2: number;
contrastLoCold: number;
contrastLoHot: number;
contrastHiCold: number;
contrastHiHot: number;
metaFreq: number;
intensityCold: number;
intensityHot: number;
metaInfluenceCold: number;
metaInfluenceHot: number;
opacityFadeEnd: number;
bloomR: number;
bloomG: number;
bloomB: number;
bloomCoverage: number;
heatDecayPerTick: number;
};
dayNight: {
mode: "light" | "dark" | "cycle";
cycleTicks: number;
startPhase: number; // 01, where 0 = noon, 0.25 = dusk, 0.5 = midnight, 0.75 = dawn
noonHold: number; // fraction of cycle held at full brightness (01)
nightHold: number; // fraction of cycle held at full darkness (01); noonHold+nightHold ≤ 1
nightAmbient: number;
dayAmbient: number;
falloffPower: number;
falloutLightR: number;
falloutLightG: number;
falloutLightB: number;
falloutLightIntensity: number;
falloutLightThreshold: number;
emberLightR: number;
emberLightG: number;
emberLightB: number;
emberLightIntensity: number;
blurZoomDivisor: number;
lightRadiusMultiplier: number;
};
mapOverlay: {
trailAlpha: number;
defenseCheckerDarken: number;
charcoalBase: number;
charcoalVariation: number;
charcoalAlpha: number;
emberThresholdUnowned: number;
emberThresholdOwned: number;
emberFlickerSpeed: number;
emberColorDarkR: number;
emberColorDarkG: number;
emberColorDarkB: number;
emberColorBrightR: number;
emberColorBrightG: number;
emberColorBrightB: number;
emberStrengthUnowned: number;
highlightBrighten: number;
highlightThicken: number;
defensePostRange: number;
embargoTintRatio: number;
friendlyTintRatio: number;
};
railroad: {
railMinZoom: number;
railDetailZoom: number;
railAlpha: number;
};
structure: {
iconSize: number;
dotsZoomThreshold: number;
iconScaleFactorZoomedOut: number;
shapes: Record<string, { scale: number; iconFill: number }>;
highlightOutlineWidth: number;
highlightDimAlpha: number;
};
structureLevel: {
scale: number;
outlineWidth: number;
};
bar: {
healthBarW: number;
healthBarH: number;
healthBarOffsetY: number;
progressBarW: number;
progressBarH: number;
progressBarOffsetY: number;
borderWidth: number;
threshold1: number;
threshold2: number;
threshold3: number;
colorRedR: number;
colorRedG: number;
colorRedB: number;
colorOrangeR: number;
colorOrangeG: number;
colorOrangeB: number;
colorYellowR: number;
colorYellowG: number;
colorYellowB: number;
colorGreenR: number;
colorGreenG: number;
colorGreenB: number;
};
unit: {
unitSize: number;
flickerSpeed: number;
angryR: number;
angryG: number;
angryB: number;
};
name: {
lerpSpeed: number;
cullThreshold: number;
nameScaleFactor: number;
nameScaleCap: number;
troopSizeMultiplier: number;
outlineWidth: number;
outlineR: number;
outlineG: number;
outlineB: number;
outlineUsePlayerColor: boolean;
fillUsePlayerColor: boolean;
emojiRowOffset: number;
statusRowOffset: number;
};
fx: {
shockwaveRingWidth: number;
nukeShockwaveDurationMs: number;
nukeShockwaveRadiusFactor: number;
samShockwaveDurationMs: number;
samShockwaveRadius: number;
debrisLifetimeMs: number;
debrisFadeIn: number; // 01 fraction of lifetime
debrisFadeOut: number; // 01 fraction of lifetime (start of fade)
conquestLifetimeMs: number;
conquestFadeIn: number;
conquestFadeOut: number;
};
nukeTrajectory: {
lineWidth: number; // px — main line stroke width
outlineWidth: number; // px — extra width for outline behind line
dashTargetable: number; // px — dash length in targetable zone
gapTargetable: number; // px — gap length in targetable zone
dashUntargetable: number; // px — dash length in untargetable zone
gapUntargetable: number; // px — gap length in untargetable zone
lineR: number; // normal line color
lineG: number;
lineB: number;
interceptR: number; // line color after SAM intercept
interceptG: number;
interceptB: number;
outlineR: number; // outline color (normal)
outlineG: number;
outlineB: number;
interceptOutlineR: number; // outline color (after intercept)
interceptOutlineG: number;
interceptOutlineB: number;
markerCircleRadius: number; // px — zone boundary circle size
markerXRadius: number; // px — SAM intercept X size
};
nukeTelegraph: {
strokeWidth: number; // world units — circle ring width
dashLen: number; // world units — outer ring dash length
gapLen: number; // world units — outer ring gap length
rotationSpeed: number; // outer ring rotation speed
baseAlpha: number; // base opacity (01)
pulseAmplitude: number; // alpha pulse ±
pulseSpeed: number; // pulse frequency (radians/sec)
fillAlphaOffset: number; // inner fill is baseAlpha minus this
colorR: number; // circle color
colorG: number;
colorB: number;
};
moveIndicator: {
startRadius: number; // screen px — initial distance from center
chevronSize: number; // screen px — wing span
lineWidth: number; // screen px — stroke width
duration: number; // ms — total animation lifetime
converge: number; // 01 — fraction of radius consumed during animation
};
samRadius: {
strokeWidth: number; // ring half-width in world units
dashLen: number; // dash length in world units
gapLen: number; // gap length in world units
rotationSpeed: number; // world units per second
alpha: number; // base opacity (01)
outlineWidth: number; // outline border width in world units
outlineSoftness: number; // smoothstep range (0 = hard, higher = softer)
};
bonusPopup: {
scale: number;
lifetimeMs: number;
riseSpeed: number;
yOffset: number;
outlineWidth: number;
colorR: number;
colorG: number;
colorB: number;
minScreenScale: number; // minimum world-scale when zoomed out (prevents vanishing)
cullZoom: number; // popups hidden below this zoom level
};
spawnOverlay: {
highlightRadius: number; // tile highlight radius (squared internally)
highlightAlpha: number; // tile highlight opacity (01)
selfMinRad: number; // self ring inner radius
selfMaxRad: number; // self ring outer radius
mateMinRad: number; // teammate ring inner radius
mateMaxRad: number; // teammate ring outer radius
animSpeed: number; // breathing animation speed
gradientInnerEdge: number; // static gradient inner ramp end (01)
gradientSolidEnd: number; // static gradient solid band end (01)
};
altView: {
gridFontSize: number;
recolorStructures: boolean;
};
lightConfigs: Record<string, { radius: number; intensity: number }>;
}
/** Create a fresh settings object with defaults from render-settings.json. */
export function createRenderSettings(): RenderSettings {
return JSON.parse(JSON.stringify(defaults)) as RenderSettings;
}
/** Dump current settings to a downloadable JSON file. */
export function dumpSettings(settings: RenderSettings): void {
const json = JSON.stringify(settings, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "render-settings.json";
a.click();
URL.revokeObjectURL(url);
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
/**
* Utilities for RenderSettings persistence — deep-assign, deep-diff.
*/
type Obj = Record<string, any>;
/** Recursively assign source values onto target, preserving target's structure. */
export function deepAssign(target: Obj, source: Obj): void {
for (const key of Object.keys(source)) {
if (
typeof source[key] === "object" &&
source[key] !== null &&
typeof target[key] === "object" &&
target[key] !== null
) {
deepAssign(target[key] as Obj, source[key] as Obj);
} else if (key in target) {
target[key] = source[key];
}
}
}
/**
* Compute a sparse deep-partial of values that differ from defaults.
* Returns `undefined` if nothing differs.
*/
export function deepDiff(defaults: Obj, current: Obj): Obj | undefined {
let result: Obj | undefined;
for (const key of Object.keys(defaults)) {
const dv = defaults[key];
const cv = current[key];
if (
typeof dv === "object" &&
dv !== null &&
typeof cv === "object" &&
cv !== null
) {
const sub = deepDiff(dv as Obj, cv as Obj);
if (sub !== undefined) {
result ??= {};
result[key] = sub;
}
} else if (dv !== cv) {
result ??= {};
result[key] = cv;
}
}
return result;
}
@@ -0,0 +1,41 @@
#version 300 es
precision highp float;
uniform vec2 uBarSize;
uniform float uBorderWidth;
uniform vec3 uThresholds;
uniform vec3 uColorRed;
uniform vec3 uColorOrange;
uniform vec3 uColorYellow;
uniform vec3 uColorGreen;
in vec2 vLocalPos;
flat in float vProgress;
out vec4 fragColor;
void main() {
float x = vLocalPos.x;
float y = vLocalPos.y;
float w = uBarSize.x;
float h = uBarSize.y;
// Border on each side
float bw = uBorderWidth;
bool inBorder = x < bw || x > w - bw || y < bw || y > h - bw;
// Colored fill region
float fillWidth = vProgress * (w - 2.0 * bw);
bool inFill = !inBorder && (x - bw) < fillWidth;
if (inFill) {
vec3 color;
if (vProgress < uThresholds.x) color = uColorRed;
else if (vProgress < uThresholds.y) color = uColorOrange;
else if (vProgress < uThresholds.z) color = uColorYellow;
else color = uColorGreen;
fragColor = vec4(color, 1.0);
} else {
fragColor = vec4(0.0, 0.0, 0.0, 1.0);
}
}
@@ -0,0 +1,27 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos; // unit quad [0,1]x[0,1]
layout(location = 1) in vec3 aInstData; // x, y, progress
uniform mat3 uCamera;
uniform vec2 uBarSize; // (width, height) in world tiles
uniform vec2 uBarOffset; // offset from unit center in tiles
out vec2 vLocalPos; // [0, barWidth] x [0, barHeight]
flat out float vProgress;
void main() {
float worldX = aInstData.x;
float worldY = aInstData.y;
vProgress = aInstData.z;
vec2 center = vec2(worldX + 0.5, worldY + 0.5);
vec2 barOrigin = center + uBarOffset;
vec2 worldPos = barOrigin + aPos * uBarSize;
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vLocalPos = aPos * uBarSize;
}
@@ -0,0 +1,123 @@
#version 300 es
precision highp float;
precision highp usampler2D;
uniform usampler2D uTileTex; // R16UI — tile state per cell
uniform usampler2D uRelationTex; // R8UI — relationship matrix (ownerA × ownerB)
uniform vec2 uMapSize;
uniform uint uHighlightOwner;
uniform int uHighlightThicken; // Chebyshev radius for highlight expansion
uniform float uTick;
uniform float uEmberThresholdUnowned;
uniform float uEmberThresholdOwned;
uniform float uEmberFlickerSpeed;
// Defense post proximity — (x, y, ownerID, _) per post
uniform vec4 uDefensePosts[MAX_DEFENSE_POSTS];
uniform int uDefensePostCount;
uniform float uDefensePostRange;
out vec4 fragColor;
uint getOwner(ivec2 c) {
if (c.x < 0 || c.y < 0 || c.x >= int(uMapSize.x) || c.y >= int(uMapSize.y))
return 0u;
return texelFetch(uTileTex, c, 0).r & uint(OWNER_MASK);
}
void main() {
ivec2 tc = ivec2(gl_FragCoord.xy);
if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard;
uint raw = texelFetch(uTileTex, tc, 0).r;
uint owner = raw & uint(OWNER_MASK);
bool fallout = (raw & (1u << FALLOUT_BIT)) != 0u;
// --- Border detection ---
float borderType = 0.0; // 0=interior, ~0.5=normal border, ~1.0=highlight border
uint maxRel = 0u; // 0=neutral, 1=friendly, 2=embargo
if (owner != 0u) {
// Cardinal neighbor check (standard border)
uint n = getOwner(tc + ivec2( 0, -1));
uint s = getOwner(tc + ivec2( 0, 1));
uint w = getOwner(tc + ivec2(-1, 0));
uint e = getOwner(tc + ivec2( 1, 0));
bool isBorder = (n != owner) || (s != owner) || (w != owner) || (e != owner);
if (isBorder) {
borderType = 0.5; // normal border
// Relationship lookup for each cardinal neighbor with different owner
if (n != owner && n != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, n), 0).r);
if (s != owner && s != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, s), 0).r);
if (w != owner && w != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, w), 0).r);
if (e != owner && e != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, e), 0).r);
}
// Highlight: N-tile Chebyshev expansion
if (uHighlightOwner != 0u && owner == uHighlightOwner) {
if (isBorder) {
borderType = 1.0; // upgrade to highlight border
} else {
// Check expanding rings for any tile with different owner
for (int d = 1; d <= 10; d++) {
if (d > uHighlightThicken) break;
bool found = false;
// Check all tiles at Chebyshev distance d
for (int i = -d; i <= d; i++) {
// Top/bottom edges
if (getOwner(tc + ivec2(i, -d)) != owner) { found = true; break; }
if (getOwner(tc + ivec2(i, d)) != owner) { found = true; break; }
}
if (!found) {
for (int i = -d + 1; i <= d - 1; i++) {
// Left/right edges (excluding corners already checked)
if (getOwner(tc + ivec2(-d, i)) != owner) { found = true; break; }
if (getOwner(tc + ivec2( d, i)) != owner) { found = true; break; }
}
}
if (found) {
borderType = 1.0; // highlight border
break;
}
}
}
}
}
// --- Defense post proximity ---
float defenseFlag = 0.0;
if (borderType > 0.0 && owner != 0u) {
float rangeSq = uDefensePostRange * uDefensePostRange;
for (int i = 0; i < MAX_DEFENSE_POSTS; i++) {
if (i >= uDefensePostCount) break;
vec4 dp = uDefensePosts[i];
if (uint(dp.z) != owner) continue;
float dx = float(tc.x) - dp.x;
float dy = float(tc.y) - dp.y;
if (dx * dx + dy * dy <= rangeSq) {
defenseFlag = 1.0;
break;
}
}
}
// --- Ember detection ---
float emberIntensity = 0.0;
if (fallout) {
float h = fract(sin(float(tc.x) * 12.9898 + float(tc.y) * 78.233) * 43758.5453);
float h2 = fract(sin(float(tc.x) * 63.7 + float(tc.y) * 157.3) * 23421.631);
float threshold = (owner == 0u) ? uEmberThresholdUnowned : uEmberThresholdOwned;
if (h2 > threshold) {
float flicker = max(0.0, sin(uTick * uEmberFlickerSpeed + h * 12.0) * 0.8 + 0.2);
flicker *= flicker; // sharpen
emberIntensity = flicker;
}
}
// A = relationship: 0.0=neutral, 0.5=friendly, 1.0=embargo
float relation = float(maxRel) * 0.5;
fragColor = vec4(borderType, emberIntensity, defenseFlag, relation);
}
@@ -0,0 +1,38 @@
#version 300 es
precision highp float;
uniform sampler2D uAtlas;
uniform float uDistRange;
in vec2 vUV;
flat in float vAlpha;
flat in vec3 vColor;
flat in float vOutlineWidth;
out vec4 fragColor;
float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}
void main() {
if (vAlpha <= 0.0) discard;
vec3 msd = texture(uAtlas, vUV).rgb;
float sd = median(msd.r, msd.g, msd.b);
vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0));
vec2 screenTexSize = 1.0 / fwidth(vUV);
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
float screenPxDist = screenPxRange * (sd - 0.5);
float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0);
// Colored text with dark outline
float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0);
float effectiveOutline = min(vOutlineWidth, maxOutline);
float outlineDist = screenPxDist + effectiveOutline;
float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0);
vec3 color = mix(vec3(0.0), vColor, fillAlpha);
fragColor = vec4(color, outlineAlpha * vAlpha);
}
@@ -0,0 +1,89 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
// Per-instance: worldX, worldY, cursorX, charCode
layout(location = 1) in vec4 aInst;
// Per-instance: alpha, colorR, colorG, colorB
layout(location = 2) in vec4 aStyle;
// Per-instance: scale, outlineWidth
layout(location = 3) in vec2 aScaleOutline;
uniform sampler2D uGlyphMetrics; // CHAR_RANGE x 2, RGBA32F
uniform mat3 uCamera;
uniform float uFontSize;
uniform float uAtlasScaleH;
uniform float uBase;
uniform float uZoom;
uniform float uMinScreenScale; // minimum world-scale factor when zoomed out
out vec2 vUV;
flat out float vAlpha;
flat out vec3 vColor;
flat out float vOutlineWidth;
void main() {
float worldX = aInst.x;
float worldY = aInst.y;
float cursorX = aInst.z;
int charCode = int(aInst.w);
if (charCode == 0 || aStyle.x <= 0.0) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vAlpha = 0.0;
vColor = vec3(1.0);
vOutlineWidth = 0.0;
return;
}
// Per-instance scale (world units per font em).
// Zoom-aware minimum: ensure popups don't shrink below minScreenScale when
// zoomed out. effectiveScale = max(scale, minScreenScale / zoom) so that
// at low zoom the popup grows in world-space to maintain a minimum screen
// footprint.
float effectiveScale = max(aScaleOutline.x, uMinScreenScale / uZoom);
float worldScale = effectiveScale / uFontSize;
// Glyph metrics from data texture
vec4 m0 = texelFetch(uGlyphMetrics, ivec2(charCode, 0), 0);
vec4 m1 = texelFetch(uGlyphMetrics, ivec2(charCode, 1), 0);
float glyphW = m0.w;
float glyphH = m1.x;
float u0 = m1.y;
float v0 = m1.z;
float u1 = m1.w;
float v1 = v0 + glyphH / uAtlasScaleH;
if (glyphW <= 0.0 || glyphH <= 0.0) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vAlpha = 0.0;
vColor = vec3(1.0);
vOutlineWidth = 0.0;
return;
}
vec2 center = vec2(worldX + 0.5, worldY + 0.5);
float baselineY = -uBase * 0.5;
vec2 glyphOrigin = vec2(
cursorX + m0.y,
baselineY + m0.z
) * worldScale;
vec2 glyphSize = vec2(glyphW, glyphH) * worldScale;
vec2 worldPos = center + glyphOrigin + aPos * glyphSize;
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y));
vAlpha = aStyle.x;
vColor = aStyle.yzw;
vOutlineWidth = aScaleOutline.y;
}
@@ -0,0 +1,32 @@
#version 300 es
precision highp float;
in vec2 vLocal; // [-1, +1]
uniform vec3 uColor;
out vec4 fragColor;
const float LINE_HALF_W = 0.08; // line half-width (normalized to quad)
const float GAP = 0.15; // center gap radius (normalized)
const float AA = 0.02; // anti-alias width
void main() {
float ax = abs(vLocal.x);
float ay = abs(vLocal.y);
// Horizontal arm: |y| < lineWidth, |x| > gap
float hMask = smoothstep(LINE_HALF_W + AA, LINE_HALF_W - AA, ay)
* smoothstep(GAP - AA, GAP + AA, ax)
* (1.0 - smoothstep(1.0 - AA, 1.0, ax));
// Vertical arm: |x| < lineWidth, |y| > gap
float vMask = smoothstep(LINE_HALF_W + AA, LINE_HALF_W - AA, ax)
* smoothstep(GAP - AA, GAP + AA, ay)
* (1.0 - smoothstep(1.0 - AA, 1.0, ay));
float mask = max(hMask, vMask);
if (mask < 0.01) discard;
fragColor = vec4(uColor, mask * 0.9);
}
@@ -0,0 +1,22 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos; // [0,1] quad
uniform mat3 uCamera;
uniform vec2 uCenter; // world tile coords
uniform float uHalfSize; // half-size in pixels (screen space)
uniform vec2 uViewport; // canvas width, height in pixels
out vec2 vLocal; // [-1, +1]
void main() {
vLocal = aPos * 2.0 - 1.0;
// Project center to clip space
vec3 clip = uCamera * vec3(uCenter + 0.5, 1.0);
// Offset in screen pixels → NDC
vec2 pixelToNDC = 2.0 / uViewport;
gl_Position = vec4(clip.xy + vLocal * uHalfSize * pixelToNDC, 0.0, 1.0);
}
@@ -0,0 +1,79 @@
#version 300 es
precision highp float;
precision highp usampler2D;
uniform usampler2D uTileTex;
uniform sampler2D uPalette;
uniform sampler2D uBorderTex; // RGBA8 — border flags from BorderComputePass
uniform sampler2D uAffiliation; // 256×2 RGBA8 — affiliation colors (row 0 = border)
uniform vec2 uMapSize;
uniform int uAltView;
uniform float uHighlightBrighten;
uniform float uDefenseCheckerDarken;
uniform float uEmbargoTintRatio;
uniform float uFriendlyTintRatio;
uniform vec3 uEmberColorDark;
uniform vec3 uEmberColorBright;
uniform float uEmberStrengthUnowned;
in vec2 vWorldPos;
out vec4 fragColor;
void main() {
ivec2 tc = ivec2(floor(vWorldPos));
if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard;
uint raw = texelFetch(uTileTex, tc, 0).r;
uint owner = raw & uint(OWNER_MASK);
// Read pre-computed border flags from BorderComputePass
vec4 borderData = texelFetch(uBorderTex, tc, 0);
float borderType = borderData.r; // 0=interior, ~0.5=normal, ~1.0=highlight
float emberIntensity = borderData.g; // 01 flicker value
bool defense = borderData.b > 0.5; // defense post proximity
float relation = borderData.a; // 0.0=neutral, ~0.5=friendly, ~1.0=embargo
bool isBorder = borderType > 0.25;
bool isHighlightBorder = borderType > 0.75;
// --- Border stamp: full-brightness border color ---
if (isBorder && owner != 0u) {
vec3 bc;
if (uAltView != 0) {
// Alt-view: pure affiliation color from palette row 0
bc = texelFetch(uAffiliation, ivec2(int(owner), 0), 0).rgb;
} else {
float u = (float(owner) + 0.5) / float(PALETTE_SIZE);
bc = texture(uPalette, vec2(u, 0.75)).rgb;
if (isHighlightBorder) {
bc = mix(bc, vec3(1.0), uHighlightBrighten);
}
// Relationship tint (applied BEFORE defense checkerboard, matching game)
if (relation > 0.75) {
bc = mix(bc, vec3(1.0, 0.0, 0.0), uEmbargoTintRatio);
} else if (relation > 0.25) {
bc = mix(bc, vec3(0.0, 1.0, 0.0), uFriendlyTintRatio);
}
// Defense bonus: checkerboard darken (applied AFTER tint, matching game)
if (defense) {
bool checker = ((tc.x + tc.y) & 1) == 1;
if (checker) bc *= uDefenseCheckerDarken;
}
}
fragColor = vec4(bc, 1.0);
return;
}
// --- Ember stamp: full-brightness ember on fallout tiles ---
if (emberIntensity > 0.0) {
float h = fract(sin(float(tc.x) * 12.9898 + float(tc.y) * 78.233) * 43758.5453);
vec3 ember = mix(uEmberColorDark, uEmberColorBright, h) * emberIntensity * uEmberStrengthUnowned;
float a = max(ember.r, max(ember.g, ember.b));
if (a > 0.01) {
fragColor = vec4(ember, 1.0);
return;
}
}
discard;
}
@@ -0,0 +1,10 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
uniform mat3 uCamera;
out vec2 vWorldPos;
void main() {
vec3 clip = uCamera * vec3(aPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vWorldPos = aPos;
}
@@ -0,0 +1,18 @@
#version 300 es
precision highp float;
uniform sampler2D uSceneTex;
uniform sampler2D uLightTex;
uniform float uAmbient;
in vec2 vUV;
out vec4 fragColor;
void main() {
vec3 scene = texture(uSceneTex, vUV).rgb;
vec3 light = texture(uLightTex, vUV).rgb;
// Scale lights inversely with ambient — invisible at full day, full strength at deep night
vec3 illumination = min(vec3(uAmbient) + light * (1.0 - uAmbient), vec3(1.2));
fragColor = vec4(scene * illumination, 1.0);
}
@@ -0,0 +1,8 @@
#version 300 es
precision highp float;
uniform sampler2D uTex;
in vec2 vUV;
out vec4 fragColor;
void main() {
fragColor = texture(uTex, vUV);
}
@@ -0,0 +1,11 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
uniform mat3 uCamera;
uniform vec2 uMapSize;
out vec2 vUV;
void main() {
vec3 clip = uCamera * vec3(aPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vUV = aPos / uMapSize;
}
@@ -0,0 +1,40 @@
#version 300 es
precision highp float;
precision highp usampler2D;
uniform sampler2D uHeatTex;
uniform usampler2D uTileTex;
uniform sampler2D uBorderTex;
uniform vec2 uMapSize;
uniform vec3 uFalloutLightColor;
uniform float uFalloutLightIntensity;
uniform float uFalloutLightThreshold;
uniform vec3 uEmberLightColor;
uniform float uEmberLightIntensity;
out vec4 fragColor;
void main() {
ivec2 tc = ivec2(gl_FragCoord.xy);
if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard;
uint raw = texelFetch(uTileTex, tc, 0).r;
bool fallout = (raw & (1u << FALLOUT_BIT)) != 0u;
if (!fallout) discard;
float heat = texelFetch(uHeatTex, tc, 0).r;
// Green fallout glow
vec3 light = vec3(0.0);
if (heat >= uFalloutLightThreshold) {
float fi = heat * uFalloutLightIntensity;
light += uFalloutLightColor * fi;
}
// Ember light — read pre-computed flicker from BorderComputePass
float emberIntensity = texelFetch(uBorderTex, tc, 0).g;
if (emberIntensity > 0.0) {
light += uEmberLightColor * emberIntensity * uEmberLightIntensity;
}
float a = max(light.r, max(light.g, light.b));
if (a < 0.001) discard;
fragColor = vec4(light, a);
}
@@ -0,0 +1,20 @@
#version 300 es
precision highp float;
in vec2 vLocalPos;
flat in vec3 vColor;
flat in float vIntensity;
uniform float uFalloffPower;
out vec4 fragColor;
void main() {
float dist = length(vLocalPos) * 2.0; // [0, 1] from center to edge
if (dist > 1.0) discard;
float falloff = pow(1.0 - dist, uFalloffPower);
float brightness = falloff * vIntensity;
fragColor = vec4(vColor * brightness, brightness);
}
@@ -0,0 +1,29 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos; // quad corner [0,1]
layout(location = 1) in vec3 aLightPosIdx; // x, y, typeIdx
layout(location = 2) in vec3 aLightColor; // r, g, b
uniform mat3 uCamera;
uniform float uRadiusMultiplier;
uniform float uRadius[MAX_LIGHT_TYPES];
uniform float uIntensity[MAX_LIGHT_TYPES];
out vec2 vLocalPos;
flat out vec3 vColor;
flat out float vIntensity;
void main() {
int typeIdx = int(aLightPosIdx.z);
float radius = uRadius[typeIdx] * uRadiusMultiplier;
vec2 center = vec2(aLightPosIdx.x + 0.5, aLightPosIdx.y + 0.5);
vec2 worldPos = center + (aPos - 0.5) * radius * 2.0;
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vLocalPos = aPos - 0.5; // [-0.5, 0.5]
vColor = aLightColor.rgb;
vIntensity = uIntensity[typeIdx];
}
@@ -0,0 +1,10 @@
#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform float uBloomCoverage;
in vec2 vUV;
out vec4 fragColor;
void main() {
vec4 bloom = texture(uTex, vUV);
fragColor = bloom * uBloomCoverage;
}
@@ -0,0 +1,11 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
uniform mat3 uCamera;
uniform vec2 uMapSize;
out vec2 vUV;
void main() {
vec3 clip = uCamera * vec3(aPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vUV = aPos / uMapSize;
}

Some files were not shown because too many files have changed in this diff Show More