add webgl renderer
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
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_(1968–1971)": 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,
|
||||
"Polish–Lithuanian 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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 322 B |
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 [0–1]. */
|
||||
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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: 0–255 (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 0–1 (start of full alpha)
|
||||
fadeOut: number; // fraction 0–1 (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 (0–5). */
|
||||
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 (0–5) */
|
||||
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, 1–255=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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 0–11)
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; // 0–1, where 0 = noon, 0.25 = dusk, 0.5 = midnight, 0.75 = dawn
|
||||
noonHold: number; // fraction of cycle held at full brightness (0–1)
|
||||
nightHold: number; // fraction of cycle held at full darkness (0–1); 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; // 0–1 fraction of lifetime
|
||||
debrisFadeOut: number; // 0–1 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 (0–1)
|
||||
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; // 0–1 — 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 (0–1)
|
||||
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 (0–1)
|
||||
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 (0–1)
|
||||
gradientSolidEnd: number; // static gradient solid band end (0–1)
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -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; // 0–1 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;
|
||||
}
|
||||