diff --git a/src/client/render/frame/derive/alliance-clusters.ts b/src/client/render/frame/derive/alliance-clusters.ts new file mode 100644 index 000000000..3c895ebca --- /dev/null +++ b/src/client/render/frame/derive/alliance-clusters.ts @@ -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, +): Map { + const parent = new Map(); + + 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(); + for (const id of parent.keys()) { + result.set(id, find(id)); + } + return result; +} diff --git a/src/client/render/frame/derive/attack-rings.ts b/src/client/render/frame/derive/attack-rings.ts new file mode 100644 index 000000000..fbe025839 --- /dev/null +++ b/src/client/render/frame/derive/attack-rings.ts @@ -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, + 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, + 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; +} diff --git a/src/client/render/frame/derive/nuke-telegraphs.ts b/src/client/render/frame/derive/nuke-telegraphs.ts new file mode 100644 index 000000000..5523efb16 --- /dev/null +++ b/src/client/render/frame/derive/nuke-telegraphs.ts @@ -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, + mapW: number, + friendlyIDs?: ReadonlySet, +): 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, + mapW: number, + friendlyIDs?: ReadonlySet, +): 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; +} diff --git a/src/client/render/frame/derive/player-status.ts b/src/client/render/frame/derive/player-status.ts new file mode 100644 index 000000000..2a165e007 --- /dev/null +++ b/src/client/render/frame/derive/player-status.ts @@ -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 = 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, + units: ReadonlyMap, +): Map { + const result = new Map(); + + // Nuke owners: players who have an active nuke in flight + const nukeOwners = new Set(); + 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; +} diff --git a/src/client/render/frame/derive/relation-matrix.ts b/src/client/render/frame/derive/relation-matrix.ts new file mode 100644 index 000000000..f0eca51eb --- /dev/null +++ b/src/client/render/frame/derive/relation-matrix.ts @@ -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, + teams?: ReadonlyMap, +): 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(); + 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 { + const m = new Map(); + for (const p of players) { + if (p.team !== null) m.set(p.smallID, p.team); + } + return m; +} diff --git a/src/client/render/frame/index.ts b/src/client/render/frame/index.ts new file mode 100644 index 000000000..fcac13180 --- /dev/null +++ b/src/client/render/frame/index.ts @@ -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"; diff --git a/src/client/render/frame/railroad-cache.ts b/src/client/render/frame/railroad-cache.ts new file mode 100644 index 000000000..803201eaf --- /dev/null +++ b/src/client/render/frame/railroad-cache.ts @@ -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(); + + /** + * 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(); + + /** 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 { + const result = new Map(); + 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; + } + } + } +} diff --git a/src/client/render/frame/trail-manager.ts b/src/client/render/frame/trail-manager.ts new file mode 100644 index 000000000..c9fd32d01 --- /dev/null +++ b/src/client/render/frame/trail-manager.ts @@ -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; + lastPosStamped: number; // tile ref of the last position we stamped +} + +export class TrailManager { + private readonly trailState: Uint8Array; + private readonly unitTrails = new Map(); + 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, 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): 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; + } + } + } +} diff --git a/src/client/render/frame/upload.ts b/src/client/render/frame/upload.ts new file mode 100644 index 000000000..cc0774beb --- /dev/null +++ b/src/client/render/frame/upload.ts @@ -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, gameTick: number): void; + updateStructures(units: ReadonlyMap): void; + applyDeadUnits(deadUnits: DeadUnitFx[]): void; + applyConquestEvents(events: ConquestFx[]): void; + applyBonusEvents(events: BonusEvent[]): void; + updateAttackRings(rings: AttackRingInput[]): void; + updateNukeTelegraphs(data: NukeTelegraphData[]): void; + updateNames( + names: ReadonlyMap, + players: ReadonlyMap, + snap: boolean, + statusData?: ReadonlyMap, + ): void; + updateRelations(data: Uint8Array, size: number): void; + setSAMAllianceClusters(clusters: ReadonlyMap): 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); +} diff --git a/src/client/render/game-constants.ts b/src/client/render/game-constants.ts new file mode 100644 index 000000000..51f5e65e3 --- /dev/null +++ b/src/client/render/game-constants.ts @@ -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> = { + [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> = { + [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)); +} diff --git a/src/client/render/gl/assets/MissileSiloIconWhite.svg b/src/client/render/gl/assets/MissileSiloIconWhite.svg new file mode 100644 index 000000000..4235be74e --- /dev/null +++ b/src/client/render/gl/assets/MissileSiloIconWhite.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + diff --git a/src/client/render/gl/assets/emoji-atlas-meta.json b/src/client/render/gl/assets/emoji-atlas-meta.json new file mode 100644 index 000000000..ddd8fdbff --- /dev/null +++ b/src/client/render/gl/assets/emoji-atlas-meta.json @@ -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 + } +} diff --git a/src/client/render/gl/assets/emoji-atlas.png b/src/client/render/gl/assets/emoji-atlas.png new file mode 100644 index 000000000..14969caa8 Binary files /dev/null and b/src/client/render/gl/assets/emoji-atlas.png differ diff --git a/src/client/render/gl/assets/flag-atlas-meta.json b/src/client/render/gl/assets/flag-atlas-meta.json new file mode 100644 index 000000000..33a9501a6 --- /dev/null +++ b/src/client/render/gl/assets/flag-atlas-meta.json @@ -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 + } +} diff --git a/src/client/render/gl/assets/flag-atlas.png b/src/client/render/gl/assets/flag-atlas.png new file mode 100644 index 000000000..288e3ac3f Binary files /dev/null and b/src/client/render/gl/assets/flag-atlas.png differ diff --git a/src/client/render/gl/assets/fx-atlas-meta.json b/src/client/render/gl/assets/fx-atlas-meta.json new file mode 100644 index 000000000..b86c81e7c --- /dev/null +++ b/src/client/render/gl/assets/fx-atlas-meta.json @@ -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 + } + ] +} diff --git a/src/client/render/gl/assets/fx-atlas.png b/src/client/render/gl/assets/fx-atlas.png new file mode 100644 index 000000000..475422437 Binary files /dev/null and b/src/client/render/gl/assets/fx-atlas.png differ diff --git a/src/client/render/gl/assets/icon-atlas.png b/src/client/render/gl/assets/icon-atlas.png new file mode 100644 index 000000000..eda0253c1 Binary files /dev/null and b/src/client/render/gl/assets/icon-atlas.png differ diff --git a/src/client/render/gl/assets/msdf-atlas.json b/src/client/render/gl/assets/msdf-atlas.json new file mode 100644 index 000000000..91a1421fb --- /dev/null +++ b/src/client/render/gl/assets/msdf-atlas.json @@ -0,0 +1,29513 @@ +{ + "pages": ["overpass-bold.png"], + "chars": [ + { + "id": 124, + "index": 95, + "char": "|", + "width": 22, + "height": 64, + "xoffset": -4, + "yoffset": -11, + "xadvance": 14, + "chnl": 15, + "x": 0, + "y": 0, + "page": 0 + }, + { + "id": 199, + "index": 137, + "char": "Ç", + "width": 42, + "height": 62, + "xoffset": -5, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 23, + "y": 0, + "page": 0 + }, + { + "id": 106, + "index": 77, + "char": "j", + "width": 29, + "height": 61, + "xoffset": -11, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 66, + "y": 0, + "page": 0 + }, + { + "id": 210, + "index": 148, + "char": "Ò", + "width": 45, + "height": 61, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 0, + "y": 65, + "page": 0 + }, + { + "id": 211, + "index": 149, + "char": "Ó", + "width": 45, + "height": 61, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 46, + "y": 63, + "page": 0 + }, + { + "id": 212, + "index": 150, + "char": "Ô", + "width": 45, + "height": 61, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 92, + "y": 62, + "page": 0 + }, + { + "id": 217, + "index": 155, + "char": "Ù", + "width": 42, + "height": 61, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 96, + "y": 0, + "page": 0 + }, + { + "id": 218, + "index": 156, + "char": "Ú", + "width": 42, + "height": 61, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 138, + "y": 62, + "page": 0 + }, + { + "id": 219, + "index": 157, + "char": "Û", + "width": 42, + "height": 61, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 139, + "y": 0, + "page": 0 + }, + { + "id": 192, + "index": 130, + "char": "À", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 181, + "y": 62, + "page": 0 + }, + { + "id": 193, + "index": 131, + "char": "Á", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 182, + "y": 0, + "page": 0 + }, + { + "id": 194, + "index": 132, + "char": "Â", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 181, + "y": 123, + "page": 0 + }, + { + "id": 195, + "index": 133, + "char": "Ã", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 0, + "y": 184, + "page": 0 + }, + { + "id": 196, + "index": 134, + "char": "Ä", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 48, + "y": 184, + "page": 0 + }, + { + "id": 200, + "index": 138, + "char": "È", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 96, + "y": 184, + "page": 0 + }, + { + "id": 201, + "index": 139, + "char": "É", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 136, + "y": 124, + "page": 0 + }, + { + "id": 202, + "index": 140, + "char": "Ê", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 136, + "y": 185, + "page": 0 + }, + { + "id": 203, + "index": 141, + "char": "Ë", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 176, + "y": 184, + "page": 0 + }, + { + "id": 204, + "index": 142, + "char": "Ì", + "width": 30, + "height": 60, + "xoffset": -9, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 216, + "y": 184, + "page": 0 + }, + { + "id": 205, + "index": 143, + "char": "Í", + "width": 30, + "height": 60, + "xoffset": -7, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 229, + "y": 61, + "page": 0 + }, + { + "id": 206, + "index": 144, + "char": "Î", + "width": 35, + "height": 60, + "xoffset": -10, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 230, + "y": 0, + "page": 0 + }, + { + "id": 207, + "index": 145, + "char": "Ï", + "width": 31, + "height": 60, + "xoffset": -9, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 229, + "y": 122, + "page": 0 + }, + { + "id": 209, + "index": 147, + "char": "Ñ", + "width": 42, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 260, + "y": 61, + "page": 0 + }, + { + "id": 213, + "index": 151, + "char": "Õ", + "width": 45, + "height": 60, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 266, + "y": 0, + "page": 0 + }, + { + "id": 214, + "index": 152, + "char": "Ö", + "width": 45, + "height": 60, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 247, + "y": 183, + "page": 0 + }, + { + "id": 220, + "index": 158, + "char": "Ü", + "width": 42, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 261, + "y": 122, + "page": 0 + }, + { + "id": 221, + "index": 159, + "char": "Ý", + "width": 46, + "height": 60, + "xoffset": -7, + "yoffset": -16, + "xadvance": 33, + "chnl": 15, + "x": 303, + "y": 61, + "page": 0 + }, + { + "id": 223, + "index": 161, + "char": "ß", + "width": 38, + "height": 60, + "xoffset": -5, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 312, + "y": 0, + "page": 0 + }, + { + "id": 253, + "index": 191, + "char": "ý", + "width": 40, + "height": 60, + "xoffset": -7, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 293, + "y": 183, + "page": 0 + }, + { + "id": 254, + "index": 192, + "char": "þ", + "width": 37, + "height": 60, + "xoffset": -4, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 304, + "y": 122, + "page": 0 + }, + { + "id": 255, + "index": 193, + "char": "ÿ", + "width": 40, + "height": 60, + "xoffset": -7, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 334, + "y": 183, + "page": 0 + }, + { + "id": 376, + "index": 196, + "char": "Ÿ", + "width": 46, + "height": 60, + "xoffset": -7, + "yoffset": -16, + "xadvance": 33, + "chnl": 15, + "x": 342, + "y": 122, + "page": 0 + }, + { + "id": 182, + "index": 120, + "char": "¶", + "width": 42, + "height": 59, + "xoffset": -7, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 92, + "y": 124, + "page": 0 + }, + { + "id": 36, + "index": 7, + "char": "$", + "width": 39, + "height": 58, + "xoffset": -6, + "yoffset": -10, + "xadvance": 28, + "chnl": 15, + "x": 46, + "y": 125, + "page": 0 + }, + { + "id": 197, + "index": 135, + "char": "Å", + "width": 47, + "height": 57, + "xoffset": -6, + "yoffset": -13, + "xadvance": 34, + "chnl": 15, + "x": 350, + "y": 61, + "page": 0 + }, + { + "id": 338, + "index": 194, + "char": "Œ", + "width": 57, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 45, + "chnl": 15, + "x": 351, + "y": 0, + "page": 0 + }, + { + "id": 40, + "index": 11, + "char": "(", + "width": 28, + "height": 56, + "xoffset": -5, + "yoffset": -6, + "xadvance": 17, + "chnl": 15, + "x": 0, + "y": 127, + "page": 0 + }, + { + "id": 41, + "index": 12, + "char": ")", + "width": 28, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 17, + "chnl": 15, + "x": 375, + "y": 183, + "page": 0 + }, + { + "id": 47, + "index": 18, + "char": "/", + "width": 41, + "height": 56, + "xoffset": -9, + "yoffset": -6, + "xadvance": 24, + "chnl": 15, + "x": 389, + "y": 119, + "page": 0 + }, + { + "id": 91, + "index": 62, + "char": "[", + "width": 28, + "height": 56, + "xoffset": -3, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 398, + "y": 52, + "page": 0 + }, + { + "id": 92, + "index": 63, + "char": "\\", + "width": 41, + "height": 56, + "xoffset": -9, + "yoffset": -6, + "xadvance": 24, + "chnl": 15, + "x": 404, + "y": 176, + "page": 0 + }, + { + "id": 93, + "index": 64, + "char": "]", + "width": 28, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 446, + "y": 0, + "page": 0 + }, + { + "id": 123, + "index": 94, + "char": "{", + "width": 30, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 427, + "y": 57, + "page": 0 + }, + { + "id": 125, + "index": 96, + "char": "}", + "width": 30, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 431, + "y": 114, + "page": 0 + }, + { + "id": 166, + "index": 104, + "char": "¦", + "width": 22, + "height": 56, + "xoffset": -4, + "yoffset": -7, + "xadvance": 14, + "chnl": 15, + "x": 458, + "y": 57, + "page": 0 + }, + { + "id": 167, + "index": 105, + "char": "§", + "width": 37, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 25, + "chnl": 15, + "x": 475, + "y": 0, + "page": 0 + }, + { + "id": 198, + "index": 136, + "char": "Æ", + "width": 56, + "height": 50, + "xoffset": -9, + "yoffset": -6, + "xadvance": 42, + "chnl": 15, + "x": 446, + "y": 171, + "page": 0 + }, + { + "id": 229, + "index": 167, + "char": "å", + "width": 37, + "height": 56, + "xoffset": -6, + "yoffset": -11, + "xadvance": 26, + "chnl": 15, + "x": 462, + "y": 114, + "page": 0 + }, + { + "id": 339, + "index": 195, + "char": "œ", + "width": 55, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 43, + "chnl": 15, + "x": 446, + "y": 222, + "page": 0 + }, + { + "id": 190, + "index": 128, + "char": "¾", + "width": 54, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 39, + "chnl": 15, + "x": 375, + "y": 240, + "page": 0 + }, + { + "id": 81, + "index": 52, + "char": "Q", + "width": 45, + "height": 53, + "xoffset": -5, + "yoffset": -6, + "xadvance": 35, + "chnl": 15, + "x": 430, + "y": 265, + "page": 0 + }, + { + "id": 87, + "index": 58, + "char": "W", + "width": 53, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 247, + "y": 244, + "page": 0 + }, + { + "id": 230, + "index": 168, + "char": "æ", + "width": 53, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 41, + "chnl": 15, + "x": 176, + "y": 245, + "page": 0 + }, + { + "id": 231, + "index": 169, + "char": "ç", + "width": 37, + "height": 53, + "xoffset": -6, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 301, + "y": 244, + "page": 0 + }, + { + "id": 37, + "index": 8, + "char": "%", + "width": 52, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 339, + "y": 292, + "page": 0 + }, + { + "id": 224, + "index": 162, + "char": "à", + "width": 37, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 392, + "y": 292, + "page": 0 + }, + { + "id": 225, + "index": 163, + "char": "á", + "width": 37, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 430, + "y": 319, + "page": 0 + }, + { + "id": 226, + "index": 164, + "char": "â", + "width": 37, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 468, + "y": 319, + "page": 0 + }, + { + "id": 232, + "index": 170, + "char": "è", + "width": 38, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 372, + "page": 0 + }, + { + "id": 233, + "index": 171, + "char": "é", + "width": 38, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 298, + "page": 0 + }, + { + "id": 234, + "index": 172, + "char": "ê", + "width": 38, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 245, + "page": 0 + }, + { + "id": 240, + "index": 178, + "char": "ð", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 425, + "page": 0 + }, + { + "id": 242, + "index": 180, + "char": "ò", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 39, + "y": 372, + "page": 0 + }, + { + "id": 243, + "index": 181, + "char": "ó", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 79, + "y": 245, + "page": 0 + }, + { + "id": 244, + "index": 182, + "char": "ô", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 39, + "y": 245, + "page": 0 + }, + { + "id": 249, + "index": 187, + "char": "ù", + "width": 37, + "height": 52, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 119, + "y": 246, + "page": 0 + }, + { + "id": 250, + "index": 188, + "char": "ú", + "width": 37, + "height": 52, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 79, + "y": 298, + "page": 0 + }, + { + "id": 251, + "index": 189, + "char": "û", + "width": 37, + "height": 52, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 39, + "y": 298, + "page": 0 + }, + { + "id": 33, + "index": 4, + "char": "!", + "width": 24, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 14, + "chnl": 15, + "x": 409, + "y": 0, + "page": 0 + }, + { + "id": 38, + "index": 9, + "char": "&", + "width": 46, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 40, + "y": 425, + "page": 0 + }, + { + "id": 48, + "index": 19, + "char": "0", + "width": 40, + "height": 51, + "xoffset": -4, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 79, + "y": 372, + "page": 0 + }, + { + "id": 51, + "index": 22, + "char": "3", + "width": 40, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 117, + "y": 299, + "page": 0 + }, + { + "id": 54, + "index": 25, + "char": "6", + "width": 40, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 87, + "y": 424, + "page": 0 + }, + { + "id": 56, + "index": 27, + "char": "8", + "width": 40, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 30, + "chnl": 15, + "x": 120, + "y": 372, + "page": 0 + }, + { + "id": 57, + "index": 28, + "char": "9", + "width": 40, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 158, + "y": 298, + "page": 0 + }, + { + "id": 63, + "index": 34, + "char": "?", + "width": 37, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 24, + "chnl": 15, + "x": 199, + "y": 288, + "page": 0 + }, + { + "id": 64, + "index": 35, + "char": "@", + "width": 50, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 40, + "chnl": 15, + "x": 237, + "y": 295, + "page": 0 + }, + { + "id": 67, + "index": 38, + "char": "C", + "width": 42, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 288, + "y": 298, + "page": 0 + }, + { + "id": 71, + "index": 42, + "char": "G", + "width": 43, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 331, + "y": 344, + "page": 0 + }, + { + "id": 79, + "index": 50, + "char": "O", + "width": 45, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 35, + "chnl": 15, + "x": 375, + "y": 345, + "page": 0 + }, + { + "id": 83, + "index": 54, + "char": "S", + "width": 40, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 128, + "y": 424, + "page": 0 + }, + { + "id": 98, + "index": 69, + "char": "b", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 199, + "y": 340, + "page": 0 + }, + { + "id": 100, + "index": 71, + "char": "d", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 161, + "y": 350, + "page": 0 + }, + { + "id": 103, + "index": 74, + "char": "g", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 237, + "y": 347, + "page": 0 + }, + { + "id": 104, + "index": 75, + "char": "h", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 199, + "y": 392, + "page": 0 + }, + { + "id": 105, + "index": 76, + "char": "i", + "width": 23, + "height": 51, + "xoffset": -5, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 476, + "y": 265, + "page": 0 + }, + { + "id": 107, + "index": 78, + "char": "k", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 169, + "y": 444, + "page": 0 + }, + { + "id": 108, + "index": 79, + "char": "l", + "width": 22, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 481, + "y": 57, + "page": 0 + }, + { + "id": 112, + "index": 83, + "char": "p", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 207, + "y": 444, + "page": 0 + }, + { + "id": 113, + "index": 84, + "char": "q", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 275, + "y": 350, + "page": 0 + }, + { + "id": 116, + "index": 87, + "char": "t", + "width": 31, + "height": 51, + "xoffset": -7, + "yoffset": -7, + "xadvance": 19, + "chnl": 15, + "x": 313, + "y": 396, + "page": 0 + }, + { + "id": 161, + "index": 99, + "char": "¡", + "width": 24, + "height": 51, + "xoffset": -5, + "yoffset": 3, + "xadvance": 14, + "chnl": 15, + "x": 245, + "y": 399, + "page": 0 + }, + { + "id": 162, + "index": 100, + "char": "¢", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 26, + "chnl": 15, + "x": 270, + "y": 402, + "page": 0 + }, + { + "id": 169, + "index": 107, + "char": "©", + "width": 51, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 245, + "y": 454, + "page": 0 + }, + { + "id": 174, + "index": 112, + "char": "®", + "width": 51, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 297, + "y": 454, + "page": 0 + }, + { + "id": 188, + "index": 126, + "char": "¼", + "width": 48, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 345, + "y": 397, + "page": 0 + }, + { + "id": 189, + "index": 127, + "char": "½", + "width": 49, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 36, + "chnl": 15, + "x": 349, + "y": 449, + "page": 0 + }, + { + "id": 191, + "index": 129, + "char": "¿", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": 3, + "xadvance": 24, + "chnl": 15, + "x": 394, + "y": 397, + "page": 0 + }, + { + "id": 216, + "index": 154, + "char": "Ø", + "width": 45, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 35, + "chnl": 15, + "x": 399, + "y": 449, + "page": 0 + }, + { + "id": 227, + "index": 165, + "char": "ã", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 432, + "y": 372, + "page": 0 + }, + { + "id": 228, + "index": 166, + "char": "ä", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 470, + "y": 372, + "page": 0 + }, + { + "id": 235, + "index": 173, + "char": "ë", + "width": 38, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 445, + "y": 424, + "page": 0 + }, + { + "id": 236, + "index": 174, + "char": "ì", + "width": 30, + "height": 51, + "xoffset": -10, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 500, + "y": 265, + "page": 0 + }, + { + "id": 237, + "index": 175, + "char": "í", + "width": 30, + "height": 51, + "xoffset": -7, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 506, + "y": 317, + "page": 0 + }, + { + "id": 238, + "index": 176, + "char": "î", + "width": 35, + "height": 51, + "xoffset": -11, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 500, + "y": 109, + "page": 0 + }, + { + "id": 239, + "index": 177, + "char": "ï", + "width": 31, + "height": 51, + "xoffset": -9, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 504, + "y": 57, + "page": 0 + }, + { + "id": 241, + "index": 179, + "char": "ñ", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 513, + "y": 0, + "page": 0 + }, + { + "id": 245, + "index": 183, + "char": "õ", + "width": 39, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 484, + "y": 424, + "page": 0 + }, + { + "id": 246, + "index": 184, + "char": "ö", + "width": 39, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 508, + "y": 369, + "page": 0 + }, + { + "id": 252, + "index": 190, + "char": "ü", + "width": 37, + "height": 51, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 524, + "y": 421, + "page": 0 + }, + { + "id": 35, + "index": 6, + "char": "#", + "width": 45, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 503, + "y": 161, + "page": 0 + }, + { + "id": 49, + "index": 20, + "char": "1", + "width": 27, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 20, + "chnl": 15, + "x": 503, + "y": 212, + "page": 0 + }, + { + "id": 50, + "index": 21, + "char": "2", + "width": 39, + "height": 50, + "xoffset": -5, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 531, + "y": 212, + "page": 0 + }, + { + "id": 52, + "index": 23, + "char": "4", + "width": 44, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 531, + "y": 263, + "page": 0 + }, + { + "id": 53, + "index": 24, + "char": "5", + "width": 39, + "height": 50, + "xoffset": -5, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 537, + "y": 314, + "page": 0 + }, + { + "id": 55, + "index": 26, + "char": "7", + "width": 39, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 26, + "chnl": 15, + "x": 548, + "y": 365, + "page": 0 + }, + { + "id": 65, + "index": 36, + "char": "A", + "width": 47, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 562, + "y": 416, + "page": 0 + }, + { + "id": 66, + "index": 37, + "char": "B", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 536, + "y": 52, + "page": 0 + }, + { + "id": 68, + "index": 39, + "char": "D", + "width": 43, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 551, + "y": 0, + "page": 0 + }, + { + "id": 69, + "index": 40, + "char": "E", + "width": 39, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 536, + "y": 103, + "page": 0 + }, + { + "id": 70, + "index": 41, + "char": "F", + "width": 38, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 549, + "y": 154, + "page": 0 + }, + { + "id": 72, + "index": 43, + "char": "H", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 576, + "y": 103, + "page": 0 + }, + { + "id": 73, + "index": 44, + "char": "I", + "width": 22, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 14, + "chnl": 15, + "x": 579, + "y": 51, + "page": 0 + }, + { + "id": 74, + "index": 45, + "char": "J", + "width": 38, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 595, + "y": 0, + "page": 0 + }, + { + "id": 75, + "index": 46, + "char": "K", + "width": 44, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 602, + "y": 51, + "page": 0 + }, + { + "id": 76, + "index": 47, + "char": "L", + "width": 38, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 634, + "y": 0, + "page": 0 + }, + { + "id": 77, + "index": 48, + "char": "M", + "width": 47, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 39, + "chnl": 15, + "x": 571, + "y": 205, + "page": 0 + }, + { + "id": 78, + "index": 49, + "char": "N", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 588, + "y": 154, + "page": 0 + }, + { + "id": 80, + "index": 51, + "char": "P", + "width": 41, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 619, + "y": 102, + "page": 0 + }, + { + "id": 82, + "index": 53, + "char": "R", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 647, + "y": 51, + "page": 0 + }, + { + "id": 84, + "index": 55, + "char": "T", + "width": 41, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 673, + "y": 0, + "page": 0 + }, + { + "id": 85, + "index": 56, + "char": "U", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 576, + "y": 256, + "page": 0 + }, + { + "id": 86, + "index": 57, + "char": "V", + "width": 45, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 32, + "chnl": 15, + "x": 577, + "y": 307, + "page": 0 + }, + { + "id": 88, + "index": 59, + "char": "X", + "width": 44, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 32, + "chnl": 15, + "x": 588, + "y": 358, + "page": 0 + }, + { + "id": 89, + "index": 60, + "char": "Y", + "width": 46, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 619, + "y": 205, + "page": 0 + }, + { + "id": 90, + "index": 61, + "char": "Z", + "width": 42, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 619, + "y": 256, + "page": 0 + }, + { + "id": 102, + "index": 73, + "char": "f", + "width": 31, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 17, + "chnl": 15, + "x": 623, + "y": 307, + "page": 0 + }, + { + "id": 109, + "index": 80, + "char": "m", + "width": 50, + "height": 41, + "xoffset": -4, + "yoffset": 3, + "xadvance": 41, + "chnl": 15, + "x": 562, + "y": 467, + "page": 0 + }, + { + "id": 121, + "index": 92, + "char": "y", + "width": 40, + "height": 50, + "xoffset": -7, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 631, + "y": 153, + "page": 0 + }, + { + "id": 163, + "index": 101, + "char": "£", + "width": 40, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 661, + "y": 102, + "page": 0 + }, + { + "id": 165, + "index": 103, + "char": "¥", + "width": 43, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 28, + "chnl": 15, + "x": 690, + "y": 51, + "page": 0 + }, + { + "id": 181, + "index": 119, + "char": "µ", + "width": 37, + "height": 50, + "xoffset": -4, + "yoffset": 3, + "xadvance": 28, + "chnl": 15, + "x": 715, + "y": 0, + "page": 0 + }, + { + "id": 208, + "index": 146, + "char": "Ð", + "width": 46, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 610, + "y": 409, + "page": 0 + }, + { + "id": 222, + "index": 160, + "char": "Þ", + "width": 41, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 633, + "y": 358, + "page": 0 + }, + { + "id": 119, + "index": 90, + "char": "w", + "width": 48, + "height": 41, + "xoffset": -6, + "yoffset": 3, + "xadvance": 35, + "chnl": 15, + "x": 655, + "y": 307, + "page": 0 + }, + { + "id": 256, + "index": 0, + "char": "Ā", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 662, + "y": 256, + "page": 0 + }, + { + "id": 257, + "index": 0, + "char": "ā", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 666, + "y": 204, + "page": 0 + }, + { + "id": 258, + "index": 0, + "char": "Ă", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 672, + "y": 153, + "page": 0 + }, + { + "id": 259, + "index": 0, + "char": "ă", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 613, + "y": 460, + "page": 0 + }, + { + "id": 260, + "index": 0, + "char": "Ą", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 643, + "y": 460, + "page": 0 + }, + { + "id": 261, + "index": 0, + "char": "ą", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 657, + "y": 409, + "page": 0 + }, + { + "id": 262, + "index": 0, + "char": "Ć", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 692, + "y": 253, + "page": 0 + }, + { + "id": 263, + "index": 0, + "char": "ć", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 696, + "y": 202, + "page": 0 + }, + { + "id": 264, + "index": 0, + "char": "Ĉ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 673, + "y": 458, + "page": 0 + }, + { + "id": 265, + "index": 0, + "char": "ĉ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 675, + "y": 349, + "page": 0 + }, + { + "id": 266, + "index": 0, + "char": "Ċ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 687, + "y": 398, + "page": 0 + }, + { + "id": 267, + "index": 0, + "char": "ċ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 703, + "y": 447, + "page": 0 + }, + { + "id": 268, + "index": 0, + "char": "Č", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 705, + "y": 302, + "page": 0 + }, + { + "id": 269, + "index": 0, + "char": "č", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 722, + "y": 251, + "page": 0 + }, + { + "id": 270, + "index": 0, + "char": "Ď", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 717, + "y": 351, + "page": 0 + }, + { + "id": 271, + "index": 0, + "char": "ď", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 735, + "y": 300, + "page": 0 + }, + { + "id": 272, + "index": 0, + "char": "Đ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 702, + "y": 102, + "page": 0 + }, + { + "id": 273, + "index": 0, + "char": "đ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 702, + "y": 151, + "page": 0 + }, + { + "id": 274, + "index": 0, + "char": "Ē", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 726, + "y": 200, + "page": 0 + }, + { + "id": 275, + "index": 0, + "char": "ē", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 752, + "y": 249, + "page": 0 + }, + { + "id": 276, + "index": 0, + "char": "Ĕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 732, + "y": 102, + "page": 0 + }, + { + "id": 277, + "index": 0, + "char": "ĕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 732, + "y": 151, + "page": 0 + }, + { + "id": 278, + "index": 0, + "char": "Ė", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 756, + "y": 200, + "page": 0 + }, + { + "id": 279, + "index": 0, + "char": "ė", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 734, + "y": 51, + "page": 0 + }, + { + "id": 280, + "index": 0, + "char": "Ę", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 753, + "y": 0, + "page": 0 + }, + { + "id": 281, + "index": 0, + "char": "ę", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 762, + "y": 100, + "page": 0 + }, + { + "id": 282, + "index": 0, + "char": "Ě", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 762, + "y": 149, + "page": 0 + }, + { + "id": 283, + "index": 0, + "char": "ě", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 764, + "y": 49, + "page": 0 + }, + { + "id": 284, + "index": 0, + "char": "Ĝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 783, + "y": 0, + "page": 0 + }, + { + "id": 285, + "index": 0, + "char": "ĝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 733, + "y": 400, + "page": 0 + }, + { + "id": 286, + "index": 0, + "char": "Ğ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 747, + "y": 349, + "page": 0 + }, + { + "id": 287, + "index": 0, + "char": "ğ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 765, + "y": 298, + "page": 0 + }, + { + "id": 288, + "index": 0, + "char": "Ġ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 782, + "y": 249, + "page": 0 + }, + { + "id": 289, + "index": 0, + "char": "ġ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 786, + "y": 198, + "page": 0 + }, + { + "id": 290, + "index": 0, + "char": "Ģ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 733, + "y": 449, + "page": 0 + }, + { + "id": 291, + "index": 0, + "char": "ģ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 792, + "y": 98, + "page": 0 + }, + { + "id": 292, + "index": 0, + "char": "Ĥ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 794, + "y": 49, + "page": 0 + }, + { + "id": 293, + "index": 0, + "char": "ĥ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 813, + "y": 0, + "page": 0 + }, + { + "id": 294, + "index": 0, + "char": "Ħ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 792, + "y": 147, + "page": 0 + }, + { + "id": 295, + "index": 0, + "char": "ħ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 763, + "y": 398, + "page": 0 + }, + { + "id": 296, + "index": 0, + "char": "Ĩ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 777, + "y": 347, + "page": 0 + }, + { + "id": 297, + "index": 0, + "char": "ĩ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 795, + "y": 298, + "page": 0 + }, + { + "id": 298, + "index": 0, + "char": "Ī", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 812, + "y": 247, + "page": 0 + }, + { + "id": 299, + "index": 0, + "char": "ī", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 816, + "y": 196, + "page": 0 + }, + { + "id": 300, + "index": 0, + "char": "Ĭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 763, + "y": 447, + "page": 0 + }, + { + "id": 301, + "index": 0, + "char": "ĭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 822, + "y": 98, + "page": 0 + }, + { + "id": 302, + "index": 0, + "char": "Į", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 822, + "y": 147, + "page": 0 + }, + { + "id": 303, + "index": 0, + "char": "į", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 824, + "y": 49, + "page": 0 + }, + { + "id": 304, + "index": 0, + "char": "İ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 843, + "y": 0, + "page": 0 + }, + { + "id": 305, + "index": 0, + "char": "ı", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 793, + "y": 396, + "page": 0 + }, + { + "id": 306, + "index": 0, + "char": "IJ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 807, + "y": 347, + "page": 0 + }, + { + "id": 307, + "index": 0, + "char": "ij", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 825, + "y": 296, + "page": 0 + }, + { + "id": 308, + "index": 0, + "char": "Ĵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 842, + "y": 245, + "page": 0 + }, + { + "id": 309, + "index": 0, + "char": "ĵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 846, + "y": 196, + "page": 0 + }, + { + "id": 310, + "index": 0, + "char": "Ķ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 793, + "y": 445, + "page": 0 + }, + { + "id": 311, + "index": 0, + "char": "ķ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 852, + "y": 98, + "page": 0 + }, + { + "id": 312, + "index": 0, + "char": "ĸ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 852, + "y": 147, + "page": 0 + }, + { + "id": 313, + "index": 0, + "char": "Ĺ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 854, + "y": 49, + "page": 0 + }, + { + "id": 314, + "index": 0, + "char": "ĺ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 873, + "y": 0, + "page": 0 + }, + { + "id": 315, + "index": 0, + "char": "Ļ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 823, + "y": 396, + "page": 0 + }, + { + "id": 316, + "index": 0, + "char": "ļ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 837, + "y": 345, + "page": 0 + }, + { + "id": 317, + "index": 0, + "char": "Ľ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 855, + "y": 294, + "page": 0 + }, + { + "id": 318, + "index": 0, + "char": "ľ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 872, + "y": 245, + "page": 0 + }, + { + "id": 319, + "index": 0, + "char": "Ŀ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 876, + "y": 196, + "page": 0 + }, + { + "id": 320, + "index": 0, + "char": "ŀ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 823, + "y": 445, + "page": 0 + }, + { + "id": 321, + "index": 0, + "char": "Ł", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 882, + "y": 98, + "page": 0 + }, + { + "id": 322, + "index": 0, + "char": "ł", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 882, + "y": 147, + "page": 0 + }, + { + "id": 323, + "index": 0, + "char": "Ń", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 884, + "y": 49, + "page": 0 + }, + { + "id": 324, + "index": 0, + "char": "ń", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 903, + "y": 0, + "page": 0 + }, + { + "id": 325, + "index": 0, + "char": "Ņ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 933, + "y": 0, + "page": 0 + }, + { + "id": 326, + "index": 0, + "char": "ņ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 963, + "y": 0, + "page": 0 + }, + { + "id": 327, + "index": 0, + "char": "Ň", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 993, + "y": 0, + "page": 0 + }, + { + "id": 328, + "index": 0, + "char": "ň", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 853, + "y": 394, + "page": 0 + }, + { + "id": 329, + "index": 0, + "char": "ʼn", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 867, + "y": 343, + "page": 0 + }, + { + "id": 330, + "index": 0, + "char": "Ŋ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 885, + "y": 294, + "page": 0 + }, + { + "id": 331, + "index": 0, + "char": "ŋ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 902, + "y": 245, + "page": 0 + }, + { + "id": 332, + "index": 0, + "char": "Ō", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 906, + "y": 196, + "page": 0 + }, + { + "id": 333, + "index": 0, + "char": "ō", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 853, + "y": 443, + "page": 0 + }, + { + "id": 334, + "index": 0, + "char": "Ŏ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 912, + "y": 98, + "page": 0 + }, + { + "id": 335, + "index": 0, + "char": "ŏ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 912, + "y": 147, + "page": 0 + }, + { + "id": 336, + "index": 0, + "char": "Ő", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 914, + "y": 49, + "page": 0 + }, + { + "id": 337, + "index": 0, + "char": "ő", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 944, + "y": 49, + "page": 0 + }, + { + "id": 340, + "index": 0, + "char": "Ŕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 974, + "y": 49, + "page": 0 + }, + { + "id": 341, + "index": 0, + "char": "ŕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 942, + "y": 98, + "page": 0 + }, + { + "id": 342, + "index": 0, + "char": "Ŗ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 972, + "y": 98, + "page": 0 + }, + { + "id": 343, + "index": 0, + "char": "ŗ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 942, + "y": 147, + "page": 0 + }, + { + "id": 344, + "index": 0, + "char": "Ř", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 972, + "y": 147, + "page": 0 + }, + { + "id": 345, + "index": 0, + "char": "ř", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 936, + "y": 196, + "page": 0 + }, + { + "id": 346, + "index": 0, + "char": "Ś", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 966, + "y": 196, + "page": 0 + }, + { + "id": 347, + "index": 0, + "char": "ś", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 932, + "y": 245, + "page": 0 + }, + { + "id": 348, + "index": 0, + "char": "Ŝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 962, + "y": 245, + "page": 0 + }, + { + "id": 349, + "index": 0, + "char": "ŝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 992, + "y": 245, + "page": 0 + }, + { + "id": 350, + "index": 0, + "char": "Ş", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 883, + "y": 392, + "page": 0 + }, + { + "id": 351, + "index": 0, + "char": "ş", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 897, + "y": 343, + "page": 0 + }, + { + "id": 352, + "index": 0, + "char": "Š", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 915, + "y": 294, + "page": 0 + }, + { + "id": 353, + "index": 0, + "char": "š", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 883, + "y": 441, + "page": 0 + }, + { + "id": 354, + "index": 0, + "char": "Ţ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 945, + "y": 294, + "page": 0 + }, + { + "id": 355, + "index": 0, + "char": "ţ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 975, + "y": 294, + "page": 0 + }, + { + "id": 356, + "index": 0, + "char": "Ť", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 927, + "y": 343, + "page": 0 + }, + { + "id": 357, + "index": 0, + "char": "ť", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 957, + "y": 343, + "page": 0 + }, + { + "id": 358, + "index": 0, + "char": "Ŧ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 987, + "y": 343, + "page": 0 + }, + { + "id": 359, + "index": 0, + "char": "ŧ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 913, + "y": 392, + "page": 0 + }, + { + "id": 360, + "index": 0, + "char": "Ũ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 913, + "y": 441, + "page": 0 + }, + { + "id": 361, + "index": 0, + "char": "ũ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 943, + "y": 392, + "page": 0 + }, + { + "id": 362, + "index": 0, + "char": "Ū", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 973, + "y": 392, + "page": 0 + }, + { + "id": 363, + "index": 0, + "char": "ū", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 943, + "y": 441, + "page": 0 + }, + { + "id": 364, + "index": 0, + "char": "Ŭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 973, + "y": 441, + "page": 0 + }, + { + "id": 365, + "index": 0, + "char": "ŭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 703, + "y": 496, + "page": 0 + }, + { + "id": 366, + "index": 0, + "char": "Ů", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 673, + "y": 507, + "page": 0 + }, + { + "id": 367, + "index": 0, + "char": "ů", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 524, + "y": 473, + "page": 0 + }, + { + "id": 368, + "index": 0, + "char": "Ű", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 445, + "y": 476, + "page": 0 + }, + { + "id": 369, + "index": 0, + "char": "ű", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 475, + "y": 476, + "page": 0 + }, + { + "id": 370, + "index": 0, + "char": "Ų", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 87, + "y": 476, + "page": 0 + }, + { + "id": 371, + "index": 0, + "char": "ų", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 40, + "y": 477, + "page": 0 + }, + { + "id": 372, + "index": 0, + "char": "Ŵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 0, + "y": 478, + "page": 0 + }, + { + "id": 373, + "index": 0, + "char": "ŵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 117, + "y": 476, + "page": 0 + }, + { + "id": 374, + "index": 0, + "char": "Ŷ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 349, + "y": 501, + "page": 0 + }, + { + "id": 375, + "index": 0, + "char": "ŷ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 379, + "y": 501, + "page": 0 + }, + { + "id": 377, + "index": 0, + "char": "Ź", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 409, + "y": 501, + "page": 0 + }, + { + "id": 378, + "index": 0, + "char": "ź", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 147, + "y": 496, + "page": 0 + }, + { + "id": 379, + "index": 0, + "char": "Ż", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 177, + "y": 496, + "page": 0 + }, + { + "id": 380, + "index": 0, + "char": "ż", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 207, + "y": 496, + "page": 0 + }, + { + "id": 381, + "index": 0, + "char": "Ž", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 70, + "y": 525, + "page": 0 + }, + { + "id": 382, + "index": 0, + "char": "ž", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 30, + "y": 526, + "page": 0 + }, + { + "id": 383, + "index": 0, + "char": "ſ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 0, + "y": 527, + "page": 0 + }, + { + "id": 59, + "index": 30, + "char": ";", + "width": 24, + "height": 46, + "xoffset": -6, + "yoffset": 5, + "xadvance": 11, + "chnl": 15, + "x": 339, + "y": 244, + "page": 0 + }, + { + "id": 177, + "index": 115, + "char": "±", + "width": 39, + "height": 45, + "xoffset": -4, + "yoffset": -1, + "xadvance": 30, + "chnl": 15, + "x": 100, + "y": 525, + "page": 0 + }, + { + "id": 164, + "index": 102, + "char": "¤", + "width": 43, + "height": 43, + "xoffset": -6, + "yoffset": -2, + "xadvance": 31, + "chnl": 15, + "x": 237, + "y": 506, + "page": 0 + }, + { + "id": 97, + "index": 68, + "char": "a", + "width": 37, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 281, + "y": 506, + "page": 0 + }, + { + "id": 99, + "index": 70, + "char": "c", + "width": 37, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 281, + "y": 549, + "page": 0 + }, + { + "id": 101, + "index": 72, + "char": "e", + "width": 38, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 140, + "y": 545, + "page": 0 + }, + { + "id": 111, + "index": 82, + "char": "o", + "width": 39, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 100, + "y": 571, + "page": 0 + }, + { + "id": 115, + "index": 86, + "char": "s", + "width": 35, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 23, + "chnl": 15, + "x": 60, + "y": 574, + "page": 0 + }, + { + "id": 248, + "index": 186, + "char": "ø", + "width": 39, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 179, + "y": 545, + "page": 0 + }, + { + "id": 110, + "index": 81, + "char": "n", + "width": 37, + "height": 41, + "xoffset": -4, + "yoffset": 3, + "xadvance": 28, + "chnl": 15, + "x": 0, + "y": 576, + "page": 0 + }, + { + "id": 114, + "index": 85, + "char": "r", + "width": 31, + "height": 41, + "xoffset": -4, + "yoffset": 3, + "xadvance": 20, + "chnl": 15, + "x": 219, + "y": 550, + "page": 0 + }, + { + "id": 117, + "index": 88, + "char": "u", + "width": 37, + "height": 41, + "xoffset": -5, + "yoffset": 3, + "xadvance": 28, + "chnl": 15, + "x": 140, + "y": 588, + "page": 0 + }, + { + "id": 118, + "index": 89, + "char": "v", + "width": 40, + "height": 41, + "xoffset": -7, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 178, + "y": 588, + "page": 0 + }, + { + "id": 120, + "index": 91, + "char": "x", + "width": 40, + "height": 41, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 96, + "y": 614, + "page": 0 + }, + { + "id": 122, + "index": 93, + "char": "z", + "width": 36, + "height": 41, + "xoffset": -5, + "yoffset": 3, + "xadvance": 25, + "chnl": 15, + "x": 38, + "y": 617, + "page": 0 + }, + { + "id": 171, + "index": 109, + "char": "«", + "width": 40, + "height": 41, + "xoffset": -7, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 554, + "y": 509, + "page": 0 + }, + { + "id": 187, + "index": 125, + "char": "»", + "width": 40, + "height": 41, + "xoffset": -7, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 505, + "y": 522, + "page": 0 + }, + { + "id": 58, + "index": 29, + "char": ":", + "width": 24, + "height": 40, + "xoffset": -6, + "yoffset": 5, + "xadvance": 11, + "chnl": 15, + "x": 169, + "y": 402, + "page": 0 + }, + { + "id": 94, + "index": 65, + "char": "^", + "width": 40, + "height": 34, + "xoffset": -5, + "yoffset": -6, + "xadvance": 30, + "chnl": 15, + "x": 439, + "y": 525, + "page": 0 + }, + { + "id": 95, + "index": 66, + "char": "_", + "width": 40, + "height": 21, + "xoffset": -8, + "yoffset": 32, + "xadvance": 24, + "chnl": 15, + "x": 595, + "y": 509, + "page": 0 + }, + { + "id": 247, + "index": 185, + "char": "÷", + "width": 39, + "height": 40, + "xoffset": -4, + "yoffset": -1, + "xadvance": 30, + "chnl": 15, + "x": 595, + "y": 531, + "page": 0 + }, + { + "id": 43, + "index": 14, + "char": "+", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 546, + "y": 551, + "page": 0 + }, + { + "id": 60, + "index": 31, + "char": "<", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 319, + "y": 550, + "page": 0 + }, + { + "id": 61, + "index": 32, + "char": "=", + "width": 39, + "height": 31, + "xoffset": -4, + "yoffset": 4, + "xadvance": 30, + "chnl": 15, + "x": 359, + "y": 550, + "page": 0 + }, + { + "id": 62, + "index": 33, + "char": ">", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 399, + "y": 550, + "page": 0 + }, + { + "id": 126, + "index": 97, + "char": "~", + "width": 39, + "height": 24, + "xoffset": -4, + "yoffset": 7, + "xadvance": 30, + "chnl": 15, + "x": 359, + "y": 582, + "page": 0 + }, + { + "id": 172, + "index": 110, + "char": "¬", + "width": 39, + "height": 31, + "xoffset": -4, + "yoffset": 5, + "xadvance": 30, + "chnl": 15, + "x": 319, + "y": 590, + "page": 0 + }, + { + "id": 215, + "index": 153, + "char": "×", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 439, + "y": 560, + "page": 0 + }, + { + "id": 42, + "index": 13, + "char": "*", + "width": 37, + "height": 37, + "xoffset": -6, + "yoffset": -6, + "xadvance": 26, + "chnl": 15, + "x": 0, + "y": 618, + "page": 0 + }, + { + "id": 170, + "index": 108, + "char": "ª", + "width": 33, + "height": 37, + "xoffset": -6, + "yoffset": -6, + "xadvance": 21, + "chnl": 15, + "x": 636, + "y": 509, + "page": 0 + }, + { + "id": 178, + "index": 116, + "char": "²", + "width": 30, + "height": 37, + "xoffset": -7, + "yoffset": -14, + "xadvance": 17, + "chnl": 15, + "x": 0, + "y": 656, + "page": 0 + }, + { + "id": 179, + "index": 117, + "char": "³", + "width": 31, + "height": 37, + "xoffset": -7, + "yoffset": -14, + "xadvance": 17, + "chnl": 15, + "x": 635, + "y": 547, + "page": 0 + }, + { + "id": 186, + "index": 124, + "char": "º", + "width": 34, + "height": 37, + "xoffset": -6, + "yoffset": -6, + "xadvance": 22, + "chnl": 15, + "x": 399, + "y": 590, + "page": 0 + }, + { + "id": 185, + "index": 123, + "char": "¹", + "width": 23, + "height": 36, + "xoffset": -8, + "yoffset": -14, + "xadvance": 10, + "chnl": 15, + "x": 480, + "y": 525, + "page": 0 + }, + { + "id": 34, + "index": 5, + "char": "\"", + "width": 33, + "height": 30, + "xoffset": -6, + "yoffset": -6, + "xadvance": 21, + "chnl": 15, + "x": 359, + "y": 607, + "page": 0 + }, + { + "id": 176, + "index": 114, + "char": "°", + "width": 33, + "height": 33, + "xoffset": -7, + "yoffset": -11, + "xadvance": 19, + "chnl": 15, + "x": 586, + "y": 572, + "page": 0 + }, + { + "id": 175, + "index": 113, + "char": "¯", + "width": 32, + "height": 21, + "xoffset": -5, + "yoffset": -5, + "xadvance": 21, + "chnl": 15, + "x": 479, + "y": 564, + "page": 0 + }, + { + "id": 168, + "index": 106, + "char": "¨", + "width": 31, + "height": 23, + "xoffset": -5, + "yoffset": -7, + "xadvance": 21, + "chnl": 15, + "x": 512, + "y": 564, + "page": 0 + }, + { + "id": 39, + "index": 10, + "char": "'", + "width": 23, + "height": 30, + "xoffset": -6, + "yoffset": -6, + "xadvance": 11, + "chnl": 15, + "x": 996, + "y": 196, + "page": 0 + }, + { + "id": 44, + "index": 15, + "char": ",", + "width": 24, + "height": 30, + "xoffset": -6, + "yoffset": 21, + "xadvance": 11, + "chnl": 15, + "x": 251, + "y": 550, + "page": 0 + }, + { + "id": 96, + "index": 67, + "char": "`", + "width": 30, + "height": 23, + "xoffset": -5, + "yoffset": -7, + "xadvance": 21, + "chnl": 15, + "x": 479, + "y": 586, + "page": 0 + }, + { + "id": 180, + "index": 118, + "char": "´", + "width": 30, + "height": 23, + "xoffset": -4, + "yoffset": -7, + "xadvance": 21, + "chnl": 15, + "x": 510, + "y": 588, + "page": 0 + }, + { + "id": 45, + "index": 16, + "char": "-", + "width": 28, + "height": 21, + "xoffset": -4, + "yoffset": 11, + "xadvance": 19, + "chnl": 15, + "x": 319, + "y": 506, + "page": 0 + }, + { + "id": 173, + "index": 111, + "char": "­", + "width": 28, + "height": 21, + "xoffset": -4, + "yoffset": 11, + "xadvance": 19, + "chnl": 15, + "x": 319, + "y": 528, + "page": 0 + }, + { + "id": 184, + "index": 122, + "char": "¸", + "width": 27, + "height": 28, + "xoffset": -3, + "yoffset": 28, + "xadvance": 21, + "chnl": 15, + "x": 251, + "y": 581, + "page": 0 + }, + { + "id": 46, + "index": 17, + "char": ".", + "width": 24, + "height": 24, + "xoffset": -6, + "yoffset": 21, + "xadvance": 11, + "chnl": 15, + "x": 219, + "y": 592, + "page": 0 + }, + { + "id": 183, + "index": 121, + "char": "·", + "width": 24, + "height": 24, + "xoffset": -6, + "yoffset": 7, + "xadvance": 11, + "chnl": 15, + "x": 279, + "y": 592, + "page": 0 + }, + { + "id": 32, + "index": 3, + "char": " ", + "width": 0, + "height": 0, + "xoffset": -8, + "yoffset": 28, + "xadvance": 11, + "chnl": 15, + "x": 66, + "y": 62, + "page": 0 + }, + { + "id": 160, + "index": 98, + "char": " ", + "width": 0, + "height": 0, + "xoffset": -8, + "yoffset": 28, + "xadvance": 11, + "chnl": 15, + "x": 182, + "y": 61, + "page": 0 + } + ], + "info": { + "face": "overpass-bold", + "size": 48, + "bold": 0, + "italic": 0, + "charset": [ + " ", + "!", + "\"", + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "[", + "\\", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + " ", + "¡", + "¢", + "£", + "¤", + "¥", + "¦", + "§", + "¨", + "©", + "ª", + "«", + "¬", + "­", + "®", + "¯", + "°", + "±", + "²", + "³", + "´", + "µ", + "¶", + "·", + "¸", + "¹", + "º", + "»", + "¼", + "½", + "¾", + "¿", + "À", + "Á", + "Â", + "Ã", + "Ä", + "Å", + "Æ", + "Ç", + "È", + "É", + "Ê", + "Ë", + "Ì", + "Í", + "Î", + "Ï", + "Ð", + "Ñ", + "Ò", + "Ó", + "Ô", + "Õ", + "Ö", + "×", + "Ø", + "Ù", + "Ú", + "Û", + "Ü", + "Ý", + "Þ", + "ß", + "à", + "á", + "â", + "ã", + "ä", + "å", + "æ", + "ç", + "è", + "é", + "ê", + "ë", + "ì", + "í", + "î", + "ï", + "ð", + "ñ", + "ò", + "ó", + "ô", + "õ", + "ö", + "÷", + "ø", + "ù", + "ú", + "û", + "ü", + "ý", + "þ", + "ÿ", + "Ā", + "ā", + "Ă", + "ă", + "Ą", + "ą", + "Ć", + "ć", + "Ĉ", + "ĉ", + "Ċ", + "ċ", + "Č", + "č", + "Ď", + "ď", + "Đ", + "đ", + "Ē", + "ē", + "Ĕ", + "ĕ", + "Ė", + "ė", + "Ę", + "ę", + "Ě", + "ě", + "Ĝ", + "ĝ", + "Ğ", + "ğ", + "Ġ", + "ġ", + "Ģ", + "ģ", + "Ĥ", + "ĥ", + "Ħ", + "ħ", + "Ĩ", + "ĩ", + "Ī", + "ī", + "Ĭ", + "ĭ", + "Į", + "į", + "İ", + "ı", + "IJ", + "ij", + "Ĵ", + "ĵ", + "Ķ", + "ķ", + "ĸ", + "Ĺ", + "ĺ", + "Ļ", + "ļ", + "Ľ", + "ľ", + "Ŀ", + "ŀ", + "Ł", + "ł", + "Ń", + "ń", + "Ņ", + "ņ", + "Ň", + "ň", + "ʼn", + "Ŋ", + "ŋ", + "Ō", + "ō", + "Ŏ", + "ŏ", + "Ő", + "ő", + "Œ", + "œ", + "Ŕ", + "ŕ", + "Ŗ", + "ŗ", + "Ř", + "ř", + "Ś", + "ś", + "Ŝ", + "ŝ", + "Ş", + "ş", + "Š", + "š", + "Ţ", + "ţ", + "Ť", + "ť", + "Ŧ", + "ŧ", + "Ũ", + "ũ", + "Ū", + "ū", + "Ŭ", + "ŭ", + "Ů", + "ů", + "Ű", + "ű", + "Ų", + "ų", + "Ŵ", + "ŵ", + "Ŷ", + "ŷ", + "Ÿ", + "Ź", + "ź", + "Ż", + "ż", + "Ž", + "ž", + "ſ" + ], + "unicode": 1, + "stretchH": 100, + "smooth": 1, + "aa": 1, + "padding": [8, 8, 8, 8], + "spacing": [0, 0], + "outline": 0 + }, + "common": { + "lineHeight": 48, + "base": 36, + "scaleW": 1024, + "scaleH": 1024, + "pages": 1, + "packed": 0, + "alphaChnl": 0, + "redChnl": 0, + "greenChnl": 0, + "blueChnl": 0 + }, + "distanceField": { + "fieldType": "msdf", + "distanceRange": 16 + }, + "kernings": [ + { + "first": 32, + "second": 65, + "amount": -2 + }, + { + "first": 32, + "second": 84, + "amount": -1 + }, + { + "first": 32, + "second": 86, + "amount": -2 + }, + { + "first": 32, + "second": 87, + "amount": 0 + }, + { + "first": 32, + "second": 89, + "amount": -2 + }, + { + "first": 32, + "second": 192, + "amount": -2 + }, + { + "first": 32, + "second": 193, + "amount": -2 + }, + { + "first": 32, + "second": 194, + "amount": -2 + }, + { + "first": 32, + "second": 195, + "amount": -2 + }, + { + "first": 32, + "second": 196, + "amount": -2 + }, + { + "first": 32, + "second": 197, + "amount": -2 + }, + { + "first": 32, + "second": 221, + "amount": -2 + }, + { + "first": 32, + "second": 376, + "amount": -2 + }, + { + "first": 38, + "second": 74, + "amount": 0 + }, + { + "first": 38, + "second": 83, + "amount": 0 + }, + { + "first": 38, + "second": 84, + "amount": -5 + }, + { + "first": 38, + "second": 86, + "amount": -6 + }, + { + "first": 38, + "second": 88, + "amount": 0 + }, + { + "first": 38, + "second": 89, + "amount": -6 + }, + { + "first": 38, + "second": 117, + "amount": -1 + }, + { + "first": 38, + "second": 119, + "amount": -1 + }, + { + "first": 38, + "second": 221, + "amount": -6 + }, + { + "first": 38, + "second": 250, + "amount": -1 + }, + { + "first": 38, + "second": 376, + "amount": -6 + }, + { + "first": 40, + "second": 65, + "amount": -1 + }, + { + "first": 40, + "second": 74, + "amount": -1 + }, + { + "first": 40, + "second": 84, + "amount": 0 + }, + { + "first": 40, + "second": 86, + "amount": -1 + }, + { + "first": 40, + "second": 87, + "amount": 0 + }, + { + "first": 40, + "second": 89, + "amount": -1 + }, + { + "first": 40, + "second": 106, + "amount": 4 + }, + { + "first": 40, + "second": 192, + "amount": -1 + }, + { + "first": 40, + "second": 193, + "amount": -1 + }, + { + "first": 40, + "second": 194, + "amount": -1 + }, + { + "first": 40, + "second": 195, + "amount": -1 + }, + { + "first": 40, + "second": 196, + "amount": -1 + }, + { + "first": 40, + "second": 197, + "amount": -1 + }, + { + "first": 40, + "second": 221, + "amount": -1 + }, + { + "first": 40, + "second": 376, + "amount": -1 + }, + { + "first": 42, + "second": 65, + "amount": -4 + }, + { + "first": 42, + "second": 74, + "amount": -7 + }, + { + "first": 42, + "second": 99, + "amount": -2 + }, + { + "first": 42, + "second": 100, + "amount": -2 + }, + { + "first": 42, + "second": 101, + "amount": -2 + }, + { + "first": 42, + "second": 103, + "amount": -2 + }, + { + "first": 42, + "second": 111, + "amount": -2 + }, + { + "first": 42, + "second": 113, + "amount": -2 + }, + { + "first": 42, + "second": 115, + "amount": -1 + }, + { + "first": 42, + "second": 192, + "amount": -4 + }, + { + "first": 42, + "second": 193, + "amount": -4 + }, + { + "first": 42, + "second": 194, + "amount": -4 + }, + { + "first": 42, + "second": 195, + "amount": -4 + }, + { + "first": 42, + "second": 196, + "amount": -4 + }, + { + "first": 42, + "second": 197, + "amount": -4 + }, + { + "first": 42, + "second": 231, + "amount": -2 + }, + { + "first": 42, + "second": 248, + "amount": -2 + }, + { + "first": 42, + "second": 339, + "amount": -2 + }, + { + "first": 44, + "second": 64, + "amount": -1 + }, + { + "first": 44, + "second": 67, + "amount": -1 + }, + { + "first": 44, + "second": 71, + "amount": -1 + }, + { + "first": 44, + "second": 79, + "amount": -1 + }, + { + "first": 44, + "second": 81, + "amount": -1 + }, + { + "first": 44, + "second": 83, + "amount": 0 + }, + { + "first": 44, + "second": 84, + "amount": -4 + }, + { + "first": 44, + "second": 86, + "amount": -5 + }, + { + "first": 44, + "second": 87, + "amount": -1 + }, + { + "first": 44, + "second": 89, + "amount": -5 + }, + { + "first": 44, + "second": 99, + "amount": -1 + }, + { + "first": 44, + "second": 100, + "amount": -1 + }, + { + "first": 44, + "second": 101, + "amount": -1 + }, + { + "first": 44, + "second": 102, + "amount": 0 + }, + { + "first": 44, + "second": 103, + "amount": -1 + }, + { + "first": 44, + "second": 106, + "amount": 0 + }, + { + "first": 44, + "second": 111, + "amount": -1 + }, + { + "first": 44, + "second": 113, + "amount": -1 + }, + { + "first": 44, + "second": 119, + "amount": -1 + }, + { + "first": 44, + "second": 169, + "amount": -1 + }, + { + "first": 44, + "second": 174, + "amount": -1 + }, + { + "first": 44, + "second": 199, + "amount": -1 + }, + { + "first": 44, + "second": 210, + "amount": -1 + }, + { + "first": 44, + "second": 211, + "amount": -1 + }, + { + "first": 44, + "second": 212, + "amount": -1 + }, + { + "first": 44, + "second": 213, + "amount": -1 + }, + { + "first": 44, + "second": 214, + "amount": -1 + }, + { + "first": 44, + "second": 216, + "amount": -1 + }, + { + "first": 44, + "second": 221, + "amount": -5 + }, + { + "first": 44, + "second": 231, + "amount": -1 + }, + { + "first": 44, + "second": 248, + "amount": -1 + }, + { + "first": 44, + "second": 338, + "amount": -1 + }, + { + "first": 44, + "second": 339, + "amount": -1 + }, + { + "first": 44, + "second": 376, + "amount": -5 + }, + { + "first": 45, + "second": 84, + "amount": -2 + }, + { + "first": 45, + "second": 86, + "amount": -1 + }, + { + "first": 45, + "second": 88, + "amount": -1 + }, + { + "first": 45, + "second": 89, + "amount": -1 + }, + { + "first": 45, + "second": 116, + "amount": 0 + }, + { + "first": 45, + "second": 221, + "amount": -1 + }, + { + "first": 45, + "second": 376, + "amount": -1 + }, + { + "first": 46, + "second": 64, + "amount": -1 + }, + { + "first": 46, + "second": 67, + "amount": -1 + }, + { + "first": 46, + "second": 71, + "amount": -1 + }, + { + "first": 46, + "second": 79, + "amount": -1 + }, + { + "first": 46, + "second": 81, + "amount": -1 + }, + { + "first": 46, + "second": 83, + "amount": 0 + }, + { + "first": 46, + "second": 84, + "amount": -4 + }, + { + "first": 46, + "second": 86, + "amount": -5 + }, + { + "first": 46, + "second": 87, + "amount": -1 + }, + { + "first": 46, + "second": 89, + "amount": -5 + }, + { + "first": 46, + "second": 99, + "amount": -1 + }, + { + "first": 46, + "second": 100, + "amount": -1 + }, + { + "first": 46, + "second": 101, + "amount": -1 + }, + { + "first": 46, + "second": 102, + "amount": 0 + }, + { + "first": 46, + "second": 103, + "amount": -1 + }, + { + "first": 46, + "second": 106, + "amount": 0 + }, + { + "first": 46, + "second": 111, + "amount": -1 + }, + { + "first": 46, + "second": 113, + "amount": -1 + }, + { + "first": 46, + "second": 119, + "amount": -1 + }, + { + "first": 46, + "second": 169, + "amount": -1 + }, + { + "first": 46, + "second": 174, + "amount": -1 + }, + { + "first": 46, + "second": 199, + "amount": -1 + }, + { + "first": 46, + "second": 210, + "amount": -1 + }, + { + "first": 46, + "second": 211, + "amount": -1 + }, + { + "first": 46, + "second": 212, + "amount": -1 + }, + { + "first": 46, + "second": 213, + "amount": -1 + }, + { + "first": 46, + "second": 214, + "amount": -1 + }, + { + "first": 46, + "second": 216, + "amount": -1 + }, + { + "first": 46, + "second": 221, + "amount": -5 + }, + { + "first": 46, + "second": 231, + "amount": -1 + }, + { + "first": 46, + "second": 248, + "amount": -1 + }, + { + "first": 46, + "second": 338, + "amount": -1 + }, + { + "first": 46, + "second": 339, + "amount": -1 + }, + { + "first": 46, + "second": 376, + "amount": -5 + }, + { + "first": 47, + "second": 64, + "amount": -1 + }, + { + "first": 47, + "second": 65, + "amount": -4 + }, + { + "first": 47, + "second": 67, + "amount": -1 + }, + { + "first": 47, + "second": 71, + "amount": -1 + }, + { + "first": 47, + "second": 74, + "amount": -6 + }, + { + "first": 47, + "second": 79, + "amount": -1 + }, + { + "first": 47, + "second": 81, + "amount": -1 + }, + { + "first": 47, + "second": 83, + "amount": 0 + }, + { + "first": 47, + "second": 84, + "amount": 0 + }, + { + "first": 47, + "second": 86, + "amount": 0 + }, + { + "first": 47, + "second": 87, + "amount": 0 + }, + { + "first": 47, + "second": 88, + "amount": -1 + }, + { + "first": 47, + "second": 89, + "amount": 0 + }, + { + "first": 47, + "second": 97, + "amount": -3 + }, + { + "first": 47, + "second": 98, + "amount": 0 + }, + { + "first": 47, + "second": 99, + "amount": -2 + }, + { + "first": 47, + "second": 100, + "amount": -2 + }, + { + "first": 47, + "second": 101, + "amount": -2 + }, + { + "first": 47, + "second": 103, + "amount": -2 + }, + { + "first": 47, + "second": 104, + "amount": 0 + }, + { + "first": 47, + "second": 105, + "amount": 0 + }, + { + "first": 47, + "second": 107, + "amount": 0 + }, + { + "first": 47, + "second": 108, + "amount": 0 + }, + { + "first": 47, + "second": 109, + "amount": -1 + }, + { + "first": 47, + "second": 110, + "amount": -1 + }, + { + "first": 47, + "second": 111, + "amount": -2 + }, + { + "first": 47, + "second": 112, + "amount": -1 + }, + { + "first": 47, + "second": 113, + "amount": -2 + }, + { + "first": 47, + "second": 114, + "amount": -1 + }, + { + "first": 47, + "second": 115, + "amount": -1 + }, + { + "first": 47, + "second": 117, + "amount": -1 + }, + { + "first": 47, + "second": 120, + "amount": -1 + }, + { + "first": 47, + "second": 169, + "amount": -1 + }, + { + "first": 47, + "second": 174, + "amount": -1 + }, + { + "first": 47, + "second": 181, + "amount": -1 + }, + { + "first": 47, + "second": 192, + "amount": -4 + }, + { + "first": 47, + "second": 193, + "amount": -4 + }, + { + "first": 47, + "second": 194, + "amount": -4 + }, + { + "first": 47, + "second": 195, + "amount": -4 + }, + { + "first": 47, + "second": 196, + "amount": -4 + }, + { + "first": 47, + "second": 197, + "amount": -4 + }, + { + "first": 47, + "second": 199, + "amount": -1 + }, + { + "first": 47, + "second": 210, + "amount": -1 + }, + { + "first": 47, + "second": 211, + "amount": -1 + }, + { + "first": 47, + "second": 212, + "amount": -1 + }, + { + "first": 47, + "second": 213, + "amount": -1 + }, + { + "first": 47, + "second": 214, + "amount": -1 + }, + { + "first": 47, + "second": 216, + "amount": -1 + }, + { + "first": 47, + "second": 221, + "amount": 0 + }, + { + "first": 47, + "second": 223, + "amount": 0 + }, + { + "first": 47, + "second": 230, + "amount": -3 + }, + { + "first": 47, + "second": 231, + "amount": -2 + }, + { + "first": 47, + "second": 232, + "amount": -1 + }, + { + "first": 47, + "second": 233, + "amount": -1 + }, + { + "first": 47, + "second": 234, + "amount": -1 + }, + { + "first": 47, + "second": 235, + "amount": -1 + }, + { + "first": 47, + "second": 240, + "amount": -1 + }, + { + "first": 47, + "second": 242, + "amount": -1 + }, + { + "first": 47, + "second": 243, + "amount": -1 + }, + { + "first": 47, + "second": 244, + "amount": -1 + }, + { + "first": 47, + "second": 245, + "amount": -1 + }, + { + "first": 47, + "second": 246, + "amount": -1 + }, + { + "first": 47, + "second": 248, + "amount": -2 + }, + { + "first": 47, + "second": 250, + "amount": -1 + }, + { + "first": 47, + "second": 254, + "amount": 0 + }, + { + "first": 47, + "second": 338, + "amount": -1 + }, + { + "first": 47, + "second": 339, + "amount": -2 + }, + { + "first": 47, + "second": 376, + "amount": 0 + }, + { + "first": 48, + "second": 65, + "amount": -1 + }, + { + "first": 48, + "second": 84, + "amount": -1 + }, + { + "first": 48, + "second": 86, + "amount": -2 + }, + { + "first": 48, + "second": 87, + "amount": 0 + }, + { + "first": 48, + "second": 88, + "amount": -1 + }, + { + "first": 48, + "second": 89, + "amount": -2 + }, + { + "first": 48, + "second": 115, + "amount": 0 + }, + { + "first": 48, + "second": 119, + "amount": 0 + }, + { + "first": 48, + "second": 192, + "amount": -1 + }, + { + "first": 48, + "second": 193, + "amount": -1 + }, + { + "first": 48, + "second": 194, + "amount": -1 + }, + { + "first": 48, + "second": 195, + "amount": -1 + }, + { + "first": 48, + "second": 196, + "amount": -1 + }, + { + "first": 48, + "second": 197, + "amount": -1 + }, + { + "first": 48, + "second": 221, + "amount": -2 + }, + { + "first": 48, + "second": 376, + "amount": -2 + }, + { + "first": 49, + "second": 65, + "amount": -1 + }, + { + "first": 49, + "second": 86, + "amount": -2 + }, + { + "first": 49, + "second": 87, + "amount": 0 + }, + { + "first": 49, + "second": 88, + "amount": -1 + }, + { + "first": 49, + "second": 89, + "amount": -2 + }, + { + "first": 49, + "second": 90, + "amount": -1 + }, + { + "first": 49, + "second": 97, + "amount": -1 + }, + { + "first": 49, + "second": 115, + "amount": 0 + }, + { + "first": 49, + "second": 192, + "amount": -1 + }, + { + "first": 49, + "second": 193, + "amount": -1 + }, + { + "first": 49, + "second": 194, + "amount": -1 + }, + { + "first": 49, + "second": 195, + "amount": -1 + }, + { + "first": 49, + "second": 196, + "amount": -1 + }, + { + "first": 49, + "second": 197, + "amount": -1 + }, + { + "first": 49, + "second": 221, + "amount": -2 + }, + { + "first": 49, + "second": 230, + "amount": -1 + }, + { + "first": 49, + "second": 376, + "amount": -2 + }, + { + "first": 50, + "second": 84, + "amount": 0 + }, + { + "first": 50, + "second": 86, + "amount": -1 + }, + { + "first": 50, + "second": 87, + "amount": 0 + }, + { + "first": 50, + "second": 88, + "amount": 0 + }, + { + "first": 50, + "second": 89, + "amount": -1 + }, + { + "first": 50, + "second": 221, + "amount": -1 + }, + { + "first": 50, + "second": 376, + "amount": -1 + }, + { + "first": 51, + "second": 65, + "amount": -1 + }, + { + "first": 51, + "second": 84, + "amount": -1 + }, + { + "first": 51, + "second": 86, + "amount": -1 + }, + { + "first": 51, + "second": 87, + "amount": 0 + }, + { + "first": 51, + "second": 88, + "amount": -1 + }, + { + "first": 51, + "second": 89, + "amount": -1 + }, + { + "first": 51, + "second": 90, + "amount": 0 + }, + { + "first": 51, + "second": 192, + "amount": -1 + }, + { + "first": 51, + "second": 193, + "amount": -1 + }, + { + "first": 51, + "second": 194, + "amount": -1 + }, + { + "first": 51, + "second": 195, + "amount": -1 + }, + { + "first": 51, + "second": 196, + "amount": -1 + }, + { + "first": 51, + "second": 197, + "amount": -1 + }, + { + "first": 51, + "second": 221, + "amount": -1 + }, + { + "first": 51, + "second": 376, + "amount": -1 + }, + { + "first": 52, + "second": 65, + "amount": 0 + }, + { + "first": 52, + "second": 84, + "amount": -1 + }, + { + "first": 52, + "second": 86, + "amount": -2 + }, + { + "first": 52, + "second": 87, + "amount": -1 + }, + { + "first": 52, + "second": 88, + "amount": 0 + }, + { + "first": 52, + "second": 89, + "amount": -2 + }, + { + "first": 52, + "second": 115, + "amount": 0 + }, + { + "first": 52, + "second": 119, + "amount": 0 + }, + { + "first": 52, + "second": 192, + "amount": 0 + }, + { + "first": 52, + "second": 193, + "amount": 0 + }, + { + "first": 52, + "second": 194, + "amount": 0 + }, + { + "first": 52, + "second": 195, + "amount": 0 + }, + { + "first": 52, + "second": 196, + "amount": 0 + }, + { + "first": 52, + "second": 197, + "amount": 0 + }, + { + "first": 52, + "second": 221, + "amount": -2 + }, + { + "first": 52, + "second": 376, + "amount": -2 + }, + { + "first": 53, + "second": 65, + "amount": -1 + }, + { + "first": 53, + "second": 84, + "amount": 0 + }, + { + "first": 53, + "second": 86, + "amount": -1 + }, + { + "first": 53, + "second": 88, + "amount": 0 + }, + { + "first": 53, + "second": 89, + "amount": -1 + }, + { + "first": 53, + "second": 90, + "amount": 0 + }, + { + "first": 53, + "second": 119, + "amount": 0 + }, + { + "first": 53, + "second": 192, + "amount": -1 + }, + { + "first": 53, + "second": 193, + "amount": -1 + }, + { + "first": 53, + "second": 194, + "amount": -1 + }, + { + "first": 53, + "second": 195, + "amount": -1 + }, + { + "first": 53, + "second": 196, + "amount": -1 + }, + { + "first": 53, + "second": 197, + "amount": -1 + }, + { + "first": 53, + "second": 221, + "amount": -1 + }, + { + "first": 53, + "second": 376, + "amount": -1 + }, + { + "first": 54, + "second": 65, + "amount": -1 + }, + { + "first": 54, + "second": 84, + "amount": -2 + }, + { + "first": 54, + "second": 86, + "amount": -2 + }, + { + "first": 54, + "second": 87, + "amount": 0 + }, + { + "first": 54, + "second": 88, + "amount": -1 + }, + { + "first": 54, + "second": 89, + "amount": -2 + }, + { + "first": 54, + "second": 90, + "amount": 0 + }, + { + "first": 54, + "second": 119, + "amount": 0 + }, + { + "first": 54, + "second": 192, + "amount": -1 + }, + { + "first": 54, + "second": 193, + "amount": -1 + }, + { + "first": 54, + "second": 194, + "amount": -1 + }, + { + "first": 54, + "second": 195, + "amount": -1 + }, + { + "first": 54, + "second": 196, + "amount": -1 + }, + { + "first": 54, + "second": 197, + "amount": -1 + }, + { + "first": 54, + "second": 221, + "amount": -2 + }, + { + "first": 54, + "second": 376, + "amount": -2 + }, + { + "first": 55, + "second": 65, + "amount": -3 + }, + { + "first": 55, + "second": 83, + "amount": 0 + }, + { + "first": 55, + "second": 87, + "amount": 0 + }, + { + "first": 55, + "second": 88, + "amount": 0 + }, + { + "first": 55, + "second": 97, + "amount": -1 + }, + { + "first": 55, + "second": 99, + "amount": -2 + }, + { + "first": 55, + "second": 100, + "amount": -2 + }, + { + "first": 55, + "second": 101, + "amount": -2 + }, + { + "first": 55, + "second": 103, + "amount": -2 + }, + { + "first": 55, + "second": 109, + "amount": 0 + }, + { + "first": 55, + "second": 110, + "amount": 0 + }, + { + "first": 55, + "second": 111, + "amount": -2 + }, + { + "first": 55, + "second": 112, + "amount": 0 + }, + { + "first": 55, + "second": 113, + "amount": -2 + }, + { + "first": 55, + "second": 114, + "amount": 0 + }, + { + "first": 55, + "second": 115, + "amount": -1 + }, + { + "first": 55, + "second": 117, + "amount": -1 + }, + { + "first": 55, + "second": 119, + "amount": 0 + }, + { + "first": 55, + "second": 181, + "amount": 0 + }, + { + "first": 55, + "second": 192, + "amount": -3 + }, + { + "first": 55, + "second": 193, + "amount": -3 + }, + { + "first": 55, + "second": 194, + "amount": -3 + }, + { + "first": 55, + "second": 195, + "amount": -3 + }, + { + "first": 55, + "second": 196, + "amount": -3 + }, + { + "first": 55, + "second": 197, + "amount": -3 + }, + { + "first": 55, + "second": 230, + "amount": -1 + }, + { + "first": 55, + "second": 231, + "amount": -2 + }, + { + "first": 55, + "second": 248, + "amount": -2 + }, + { + "first": 55, + "second": 250, + "amount": -1 + }, + { + "first": 55, + "second": 339, + "amount": -2 + }, + { + "first": 56, + "second": 65, + "amount": -1 + }, + { + "first": 56, + "second": 84, + "amount": -1 + }, + { + "first": 56, + "second": 86, + "amount": -3 + }, + { + "first": 56, + "second": 87, + "amount": 0 + }, + { + "first": 56, + "second": 88, + "amount": 0 + }, + { + "first": 56, + "second": 89, + "amount": -3 + }, + { + "first": 56, + "second": 90, + "amount": 0 + }, + { + "first": 56, + "second": 119, + "amount": 0 + }, + { + "first": 56, + "second": 192, + "amount": -1 + }, + { + "first": 56, + "second": 193, + "amount": -1 + }, + { + "first": 56, + "second": 194, + "amount": -1 + }, + { + "first": 56, + "second": 195, + "amount": -1 + }, + { + "first": 56, + "second": 196, + "amount": -1 + }, + { + "first": 56, + "second": 197, + "amount": -1 + }, + { + "first": 56, + "second": 221, + "amount": -3 + }, + { + "first": 56, + "second": 376, + "amount": -3 + }, + { + "first": 57, + "second": 65, + "amount": -2 + }, + { + "first": 57, + "second": 84, + "amount": 0 + }, + { + "first": 57, + "second": 86, + "amount": -2 + }, + { + "first": 57, + "second": 87, + "amount": 0 + }, + { + "first": 57, + "second": 88, + "amount": -1 + }, + { + "first": 57, + "second": 89, + "amount": -2 + }, + { + "first": 57, + "second": 90, + "amount": 0 + }, + { + "first": 57, + "second": 115, + "amount": 0 + }, + { + "first": 57, + "second": 119, + "amount": 0 + }, + { + "first": 57, + "second": 192, + "amount": -2 + }, + { + "first": 57, + "second": 193, + "amount": -2 + }, + { + "first": 57, + "second": 194, + "amount": -2 + }, + { + "first": 57, + "second": 195, + "amount": -2 + }, + { + "first": 57, + "second": 196, + "amount": -2 + }, + { + "first": 57, + "second": 197, + "amount": -2 + }, + { + "first": 57, + "second": 221, + "amount": -2 + }, + { + "first": 57, + "second": 376, + "amount": -2 + }, + { + "first": 58, + "second": 84, + "amount": -1 + }, + { + "first": 58, + "second": 86, + "amount": -4 + }, + { + "first": 58, + "second": 87, + "amount": 0 + }, + { + "first": 58, + "second": 89, + "amount": -4 + }, + { + "first": 58, + "second": 106, + "amount": 0 + }, + { + "first": 58, + "second": 221, + "amount": -4 + }, + { + "first": 58, + "second": 376, + "amount": -4 + }, + { + "first": 59, + "second": 84, + "amount": -1 + }, + { + "first": 59, + "second": 86, + "amount": -4 + }, + { + "first": 59, + "second": 87, + "amount": 0 + }, + { + "first": 59, + "second": 89, + "amount": -4 + }, + { + "first": 59, + "second": 106, + "amount": 0 + }, + { + "first": 59, + "second": 221, + "amount": -4 + }, + { + "first": 59, + "second": 376, + "amount": -4 + }, + { + "first": 64, + "second": 74, + "amount": -5 + }, + { + "first": 64, + "second": 86, + "amount": -2 + }, + { + "first": 64, + "second": 89, + "amount": -2 + }, + { + "first": 64, + "second": 221, + "amount": -2 + }, + { + "first": 64, + "second": 376, + "amount": -2 + }, + { + "first": 65, + "second": 32, + "amount": -2 + }, + { + "first": 65, + "second": 38, + "amount": -1 + }, + { + "first": 65, + "second": 41, + "amount": -1 + }, + { + "first": 65, + "second": 42, + "amount": -4 + }, + { + "first": 65, + "second": 48, + "amount": -1 + }, + { + "first": 65, + "second": 49, + "amount": 0 + }, + { + "first": 65, + "second": 53, + "amount": 0 + }, + { + "first": 65, + "second": 54, + "amount": -1 + }, + { + "first": 65, + "second": 55, + "amount": -3 + }, + { + "first": 65, + "second": 56, + "amount": -1 + }, + { + "first": 65, + "second": 57, + "amount": -2 + }, + { + "first": 65, + "second": 64, + "amount": -2 + }, + { + "first": 65, + "second": 66, + "amount": -1 + }, + { + "first": 65, + "second": 67, + "amount": -2 + }, + { + "first": 65, + "second": 68, + "amount": -1 + }, + { + "first": 65, + "second": 69, + "amount": -1 + }, + { + "first": 65, + "second": 70, + "amount": -1 + }, + { + "first": 65, + "second": 71, + "amount": -2 + }, + { + "first": 65, + "second": 72, + "amount": -1 + }, + { + "first": 65, + "second": 73, + "amount": -1 + }, + { + "first": 65, + "second": 74, + "amount": 0 + }, + { + "first": 65, + "second": 75, + "amount": -1 + }, + { + "first": 65, + "second": 76, + "amount": -1 + }, + { + "first": 65, + "second": 77, + "amount": -1 + }, + { + "first": 65, + "second": 78, + "amount": -1 + }, + { + "first": 65, + "second": 79, + "amount": -2 + }, + { + "first": 65, + "second": 80, + "amount": -1 + }, + { + "first": 65, + "second": 81, + "amount": -2 + }, + { + "first": 65, + "second": 82, + "amount": -1 + }, + { + "first": 65, + "second": 83, + "amount": -1 + }, + { + "first": 65, + "second": 84, + "amount": -5 + }, + { + "first": 65, + "second": 85, + "amount": -1 + }, + { + "first": 65, + "second": 86, + "amount": -5 + }, + { + "first": 65, + "second": 87, + "amount": -2 + }, + { + "first": 65, + "second": 88, + "amount": -1 + }, + { + "first": 65, + "second": 89, + "amount": -5 + }, + { + "first": 65, + "second": 92, + "amount": -4 + }, + { + "first": 65, + "second": 97, + "amount": 0 + }, + { + "first": 65, + "second": 99, + "amount": 0 + }, + { + "first": 65, + "second": 100, + "amount": 0 + }, + { + "first": 65, + "second": 101, + "amount": 0 + }, + { + "first": 65, + "second": 102, + "amount": 0 + }, + { + "first": 65, + "second": 103, + "amount": 0 + }, + { + "first": 65, + "second": 106, + "amount": 3 + }, + { + "first": 65, + "second": 111, + "amount": 0 + }, + { + "first": 65, + "second": 113, + "amount": 0 + }, + { + "first": 65, + "second": 115, + "amount": 0 + }, + { + "first": 65, + "second": 116, + "amount": -1 + }, + { + "first": 65, + "second": 117, + "amount": 0 + }, + { + "first": 65, + "second": 118, + "amount": -2 + }, + { + "first": 65, + "second": 119, + "amount": -2 + }, + { + "first": 65, + "second": 121, + "amount": -2 + }, + { + "first": 65, + "second": 169, + "amount": -2 + }, + { + "first": 65, + "second": 171, + "amount": 0 + }, + { + "first": 65, + "second": 174, + "amount": -2 + }, + { + "first": 65, + "second": 199, + "amount": -2 + }, + { + "first": 65, + "second": 200, + "amount": -1 + }, + { + "first": 65, + "second": 201, + "amount": -1 + }, + { + "first": 65, + "second": 202, + "amount": -1 + }, + { + "first": 65, + "second": 203, + "amount": -1 + }, + { + "first": 65, + "second": 204, + "amount": -1 + }, + { + "first": 65, + "second": 205, + "amount": -1 + }, + { + "first": 65, + "second": 206, + "amount": -1 + }, + { + "first": 65, + "second": 207, + "amount": -1 + }, + { + "first": 65, + "second": 209, + "amount": -1 + }, + { + "first": 65, + "second": 210, + "amount": -2 + }, + { + "first": 65, + "second": 211, + "amount": -2 + }, + { + "first": 65, + "second": 212, + "amount": -2 + }, + { + "first": 65, + "second": 213, + "amount": -2 + }, + { + "first": 65, + "second": 214, + "amount": -2 + }, + { + "first": 65, + "second": 216, + "amount": -2 + }, + { + "first": 65, + "second": 217, + "amount": -1 + }, + { + "first": 65, + "second": 218, + "amount": -1 + }, + { + "first": 65, + "second": 219, + "amount": -1 + }, + { + "first": 65, + "second": 220, + "amount": -1 + }, + { + "first": 65, + "second": 221, + "amount": -5 + }, + { + "first": 65, + "second": 222, + "amount": -1 + }, + { + "first": 65, + "second": 230, + "amount": 0 + }, + { + "first": 65, + "second": 231, + "amount": 0 + }, + { + "first": 65, + "second": 232, + "amount": 0 + }, + { + "first": 65, + "second": 233, + "amount": 0 + }, + { + "first": 65, + "second": 234, + "amount": 0 + }, + { + "first": 65, + "second": 235, + "amount": 0 + }, + { + "first": 65, + "second": 240, + "amount": 0 + }, + { + "first": 65, + "second": 242, + "amount": 0 + }, + { + "first": 65, + "second": 243, + "amount": 0 + }, + { + "first": 65, + "second": 244, + "amount": 0 + }, + { + "first": 65, + "second": 245, + "amount": 0 + }, + { + "first": 65, + "second": 246, + "amount": 0 + }, + { + "first": 65, + "second": 248, + "amount": 0 + }, + { + "first": 65, + "second": 250, + "amount": 0 + }, + { + "first": 65, + "second": 338, + "amount": -2 + }, + { + "first": 65, + "second": 339, + "amount": 0 + }, + { + "first": 65, + "second": 376, + "amount": -5 + }, + { + "first": 66, + "second": 44, + "amount": 0 + }, + { + "first": 66, + "second": 46, + "amount": 0 + }, + { + "first": 66, + "second": 47, + "amount": -1 + }, + { + "first": 66, + "second": 48, + "amount": -1 + }, + { + "first": 66, + "second": 50, + "amount": -1 + }, + { + "first": 66, + "second": 55, + "amount": -1 + }, + { + "first": 66, + "second": 57, + "amount": -1 + }, + { + "first": 66, + "second": 64, + "amount": 0 + }, + { + "first": 66, + "second": 65, + "amount": -1 + }, + { + "first": 66, + "second": 67, + "amount": 0 + }, + { + "first": 66, + "second": 71, + "amount": 0 + }, + { + "first": 66, + "second": 79, + "amount": 0 + }, + { + "first": 66, + "second": 81, + "amount": 0 + }, + { + "first": 66, + "second": 84, + "amount": 0 + }, + { + "first": 66, + "second": 85, + "amount": 0 + }, + { + "first": 66, + "second": 86, + "amount": -1 + }, + { + "first": 66, + "second": 87, + "amount": -1 + }, + { + "first": 66, + "second": 88, + "amount": -1 + }, + { + "first": 66, + "second": 89, + "amount": -1 + }, + { + "first": 66, + "second": 92, + "amount": -1 + }, + { + "first": 66, + "second": 102, + "amount": 0 + }, + { + "first": 66, + "second": 116, + "amount": 0 + }, + { + "first": 66, + "second": 118, + "amount": 0 + }, + { + "first": 66, + "second": 119, + "amount": 0 + }, + { + "first": 66, + "second": 120, + "amount": -1 + }, + { + "first": 66, + "second": 121, + "amount": 0 + }, + { + "first": 66, + "second": 125, + "amount": 0 + }, + { + "first": 66, + "second": 169, + "amount": 0 + }, + { + "first": 66, + "second": 174, + "amount": 0 + }, + { + "first": 66, + "second": 192, + "amount": -1 + }, + { + "first": 66, + "second": 193, + "amount": -1 + }, + { + "first": 66, + "second": 194, + "amount": -1 + }, + { + "first": 66, + "second": 195, + "amount": -1 + }, + { + "first": 66, + "second": 196, + "amount": -1 + }, + { + "first": 66, + "second": 197, + "amount": -1 + }, + { + "first": 66, + "second": 199, + "amount": 0 + }, + { + "first": 66, + "second": 210, + "amount": 0 + }, + { + "first": 66, + "second": 211, + "amount": 0 + }, + { + "first": 66, + "second": 212, + "amount": 0 + }, + { + "first": 66, + "second": 213, + "amount": 0 + }, + { + "first": 66, + "second": 214, + "amount": 0 + }, + { + "first": 66, + "second": 216, + "amount": 0 + }, + { + "first": 66, + "second": 217, + "amount": 0 + }, + { + "first": 66, + "second": 218, + "amount": 0 + }, + { + "first": 66, + "second": 219, + "amount": 0 + }, + { + "first": 66, + "second": 220, + "amount": 0 + }, + { + "first": 66, + "second": 221, + "amount": -1 + }, + { + "first": 66, + "second": 338, + "amount": 0 + }, + { + "first": 66, + "second": 376, + "amount": -1 + }, + { + "first": 67, + "second": 47, + "amount": -1 + }, + { + "first": 67, + "second": 64, + "amount": 0 + }, + { + "first": 67, + "second": 65, + "amount": -1 + }, + { + "first": 67, + "second": 67, + "amount": 0 + }, + { + "first": 67, + "second": 71, + "amount": 0 + }, + { + "first": 67, + "second": 74, + "amount": -1 + }, + { + "first": 67, + "second": 79, + "amount": 0 + }, + { + "first": 67, + "second": 81, + "amount": 0 + }, + { + "first": 67, + "second": 84, + "amount": 0 + }, + { + "first": 67, + "second": 86, + "amount": 0 + }, + { + "first": 67, + "second": 87, + "amount": 0 + }, + { + "first": 67, + "second": 88, + "amount": -1 + }, + { + "first": 67, + "second": 89, + "amount": 0 + }, + { + "first": 67, + "second": 90, + "amount": -1 + }, + { + "first": 67, + "second": 99, + "amount": 0 + }, + { + "first": 67, + "second": 100, + "amount": 0 + }, + { + "first": 67, + "second": 101, + "amount": 0 + }, + { + "first": 67, + "second": 103, + "amount": 0 + }, + { + "first": 67, + "second": 111, + "amount": 0 + }, + { + "first": 67, + "second": 113, + "amount": 0 + }, + { + "first": 67, + "second": 115, + "amount": 0 + }, + { + "first": 67, + "second": 118, + "amount": 0 + }, + { + "first": 67, + "second": 119, + "amount": 0 + }, + { + "first": 67, + "second": 120, + "amount": -1 + }, + { + "first": 67, + "second": 121, + "amount": 0 + }, + { + "first": 67, + "second": 122, + "amount": 0 + }, + { + "first": 67, + "second": 169, + "amount": 0 + }, + { + "first": 67, + "second": 171, + "amount": 0 + }, + { + "first": 67, + "second": 174, + "amount": 0 + }, + { + "first": 67, + "second": 187, + "amount": 0 + }, + { + "first": 67, + "second": 192, + "amount": -1 + }, + { + "first": 67, + "second": 193, + "amount": -1 + }, + { + "first": 67, + "second": 194, + "amount": -1 + }, + { + "first": 67, + "second": 195, + "amount": -1 + }, + { + "first": 67, + "second": 196, + "amount": -1 + }, + { + "first": 67, + "second": 197, + "amount": -1 + }, + { + "first": 67, + "second": 199, + "amount": 0 + }, + { + "first": 67, + "second": 210, + "amount": 0 + }, + { + "first": 67, + "second": 211, + "amount": 0 + }, + { + "first": 67, + "second": 212, + "amount": 0 + }, + { + "first": 67, + "second": 213, + "amount": 0 + }, + { + "first": 67, + "second": 214, + "amount": 0 + }, + { + "first": 67, + "second": 216, + "amount": 0 + }, + { + "first": 67, + "second": 221, + "amount": 0 + }, + { + "first": 67, + "second": 231, + "amount": 0 + }, + { + "first": 67, + "second": 248, + "amount": 0 + }, + { + "first": 67, + "second": 338, + "amount": 0 + }, + { + "first": 67, + "second": 339, + "amount": 0 + }, + { + "first": 67, + "second": 376, + "amount": 0 + }, + { + "first": 68, + "second": 44, + "amount": -1 + }, + { + "first": 68, + "second": 46, + "amount": -1 + }, + { + "first": 68, + "second": 47, + "amount": -1 + }, + { + "first": 68, + "second": 65, + "amount": -2 + }, + { + "first": 68, + "second": 74, + "amount": 0 + }, + { + "first": 68, + "second": 84, + "amount": -2 + }, + { + "first": 68, + "second": 86, + "amount": -3 + }, + { + "first": 68, + "second": 87, + "amount": -1 + }, + { + "first": 68, + "second": 88, + "amount": -1 + }, + { + "first": 68, + "second": 89, + "amount": -3 + }, + { + "first": 68, + "second": 90, + "amount": -1 + }, + { + "first": 68, + "second": 97, + "amount": 0 + }, + { + "first": 68, + "second": 99, + "amount": 0 + }, + { + "first": 68, + "second": 100, + "amount": 0 + }, + { + "first": 68, + "second": 101, + "amount": 0 + }, + { + "first": 68, + "second": 103, + "amount": 0 + }, + { + "first": 68, + "second": 111, + "amount": 0 + }, + { + "first": 68, + "second": 113, + "amount": 0 + }, + { + "first": 68, + "second": 115, + "amount": 0 + }, + { + "first": 68, + "second": 118, + "amount": 0 + }, + { + "first": 68, + "second": 119, + "amount": 0 + }, + { + "first": 68, + "second": 120, + "amount": 0 + }, + { + "first": 68, + "second": 121, + "amount": 0 + }, + { + "first": 68, + "second": 122, + "amount": 0 + }, + { + "first": 68, + "second": 192, + "amount": -2 + }, + { + "first": 68, + "second": 193, + "amount": -2 + }, + { + "first": 68, + "second": 194, + "amount": -2 + }, + { + "first": 68, + "second": 195, + "amount": -2 + }, + { + "first": 68, + "second": 196, + "amount": -2 + }, + { + "first": 68, + "second": 197, + "amount": -2 + }, + { + "first": 68, + "second": 221, + "amount": -3 + }, + { + "first": 68, + "second": 230, + "amount": 0 + }, + { + "first": 68, + "second": 231, + "amount": 0 + }, + { + "first": 68, + "second": 248, + "amount": 0 + }, + { + "first": 68, + "second": 339, + "amount": 0 + }, + { + "first": 68, + "second": 376, + "amount": -3 + }, + { + "first": 69, + "second": 38, + "amount": 0 + }, + { + "first": 69, + "second": 64, + "amount": -1 + }, + { + "first": 69, + "second": 67, + "amount": -1 + }, + { + "first": 69, + "second": 71, + "amount": -1 + }, + { + "first": 69, + "second": 74, + "amount": 0 + }, + { + "first": 69, + "second": 79, + "amount": -1 + }, + { + "first": 69, + "second": 81, + "amount": -1 + }, + { + "first": 69, + "second": 87, + "amount": 0 + }, + { + "first": 69, + "second": 97, + "amount": 0 + }, + { + "first": 69, + "second": 99, + "amount": 0 + }, + { + "first": 69, + "second": 100, + "amount": 0 + }, + { + "first": 69, + "second": 101, + "amount": 0 + }, + { + "first": 69, + "second": 102, + "amount": -1 + }, + { + "first": 69, + "second": 103, + "amount": 0 + }, + { + "first": 69, + "second": 106, + "amount": 1 + }, + { + "first": 69, + "second": 111, + "amount": 0 + }, + { + "first": 69, + "second": 113, + "amount": 0 + }, + { + "first": 69, + "second": 115, + "amount": 0 + }, + { + "first": 69, + "second": 116, + "amount": 0 + }, + { + "first": 69, + "second": 118, + "amount": -1 + }, + { + "first": 69, + "second": 119, + "amount": -1 + }, + { + "first": 69, + "second": 121, + "amount": -1 + }, + { + "first": 69, + "second": 169, + "amount": -1 + }, + { + "first": 69, + "second": 171, + "amount": -1 + }, + { + "first": 69, + "second": 174, + "amount": -1 + }, + { + "first": 69, + "second": 180, + "amount": 0 + }, + { + "first": 69, + "second": 199, + "amount": -1 + }, + { + "first": 69, + "second": 210, + "amount": -1 + }, + { + "first": 69, + "second": 211, + "amount": -1 + }, + { + "first": 69, + "second": 212, + "amount": -1 + }, + { + "first": 69, + "second": 213, + "amount": -1 + }, + { + "first": 69, + "second": 214, + "amount": -1 + }, + { + "first": 69, + "second": 216, + "amount": -1 + }, + { + "first": 69, + "second": 224, + "amount": 0 + }, + { + "first": 69, + "second": 225, + "amount": 0 + }, + { + "first": 69, + "second": 226, + "amount": 0 + }, + { + "first": 69, + "second": 227, + "amount": 0 + }, + { + "first": 69, + "second": 228, + "amount": 0 + }, + { + "first": 69, + "second": 229, + "amount": 0 + }, + { + "first": 69, + "second": 230, + "amount": 0 + }, + { + "first": 69, + "second": 231, + "amount": 0 + }, + { + "first": 69, + "second": 248, + "amount": 0 + }, + { + "first": 69, + "second": 338, + "amount": -1 + }, + { + "first": 69, + "second": 339, + "amount": 0 + }, + { + "first": 70, + "second": 38, + "amount": -3 + }, + { + "first": 70, + "second": 44, + "amount": -4 + }, + { + "first": 70, + "second": 46, + "amount": -4 + }, + { + "first": 70, + "second": 47, + "amount": -7 + }, + { + "first": 70, + "second": 48, + "amount": -1 + }, + { + "first": 70, + "second": 50, + "amount": 0 + }, + { + "first": 70, + "second": 52, + "amount": -4 + }, + { + "first": 70, + "second": 54, + "amount": -1 + }, + { + "first": 70, + "second": 55, + "amount": 0 + }, + { + "first": 70, + "second": 56, + "amount": -1 + }, + { + "first": 70, + "second": 58, + "amount": -1 + }, + { + "first": 70, + "second": 59, + "amount": -1 + }, + { + "first": 70, + "second": 64, + "amount": -1 + }, + { + "first": 70, + "second": 65, + "amount": -6 + }, + { + "first": 70, + "second": 67, + "amount": -1 + }, + { + "first": 70, + "second": 71, + "amount": -1 + }, + { + "first": 70, + "second": 74, + "amount": -7 + }, + { + "first": 70, + "second": 79, + "amount": -1 + }, + { + "first": 70, + "second": 81, + "amount": -1 + }, + { + "first": 70, + "second": 83, + "amount": 0 + }, + { + "first": 70, + "second": 86, + "amount": 0 + }, + { + "first": 70, + "second": 89, + "amount": 0 + }, + { + "first": 70, + "second": 97, + "amount": -2 + }, + { + "first": 70, + "second": 99, + "amount": -2 + }, + { + "first": 70, + "second": 100, + "amount": -2 + }, + { + "first": 70, + "second": 101, + "amount": -2 + }, + { + "first": 70, + "second": 102, + "amount": 0 + }, + { + "first": 70, + "second": 103, + "amount": -2 + }, + { + "first": 70, + "second": 109, + "amount": -2 + }, + { + "first": 70, + "second": 110, + "amount": -2 + }, + { + "first": 70, + "second": 111, + "amount": -2 + }, + { + "first": 70, + "second": 112, + "amount": -2 + }, + { + "first": 70, + "second": 113, + "amount": -2 + }, + { + "first": 70, + "second": 114, + "amount": -2 + }, + { + "first": 70, + "second": 115, + "amount": -2 + }, + { + "first": 70, + "second": 116, + "amount": 0 + }, + { + "first": 70, + "second": 117, + "amount": -2 + }, + { + "first": 70, + "second": 118, + "amount": -1 + }, + { + "first": 70, + "second": 119, + "amount": -1 + }, + { + "first": 70, + "second": 120, + "amount": -2 + }, + { + "first": 70, + "second": 121, + "amount": -1 + }, + { + "first": 70, + "second": 122, + "amount": -1 + }, + { + "first": 70, + "second": 169, + "amount": -1 + }, + { + "first": 70, + "second": 171, + "amount": -1 + }, + { + "first": 70, + "second": 174, + "amount": -1 + }, + { + "first": 70, + "second": 180, + "amount": -1 + }, + { + "first": 70, + "second": 181, + "amount": -2 + }, + { + "first": 70, + "second": 192, + "amount": -6 + }, + { + "first": 70, + "second": 193, + "amount": -6 + }, + { + "first": 70, + "second": 194, + "amount": -6 + }, + { + "first": 70, + "second": 195, + "amount": -6 + }, + { + "first": 70, + "second": 196, + "amount": -6 + }, + { + "first": 70, + "second": 197, + "amount": -6 + }, + { + "first": 70, + "second": 199, + "amount": -1 + }, + { + "first": 70, + "second": 210, + "amount": -1 + }, + { + "first": 70, + "second": 211, + "amount": -1 + }, + { + "first": 70, + "second": 212, + "amount": -1 + }, + { + "first": 70, + "second": 213, + "amount": -1 + }, + { + "first": 70, + "second": 214, + "amount": -1 + }, + { + "first": 70, + "second": 216, + "amount": -1 + }, + { + "first": 70, + "second": 221, + "amount": 0 + }, + { + "first": 70, + "second": 224, + "amount": -1 + }, + { + "first": 70, + "second": 225, + "amount": -1 + }, + { + "first": 70, + "second": 226, + "amount": -1 + }, + { + "first": 70, + "second": 227, + "amount": -1 + }, + { + "first": 70, + "second": 228, + "amount": -1 + }, + { + "first": 70, + "second": 229, + "amount": -1 + }, + { + "first": 70, + "second": 230, + "amount": -2 + }, + { + "first": 70, + "second": 231, + "amount": -2 + }, + { + "first": 70, + "second": 232, + "amount": -1 + }, + { + "first": 70, + "second": 233, + "amount": -1 + }, + { + "first": 70, + "second": 234, + "amount": -1 + }, + { + "first": 70, + "second": 235, + "amount": -1 + }, + { + "first": 70, + "second": 236, + "amount": 2 + }, + { + "first": 70, + "second": 237, + "amount": 2 + }, + { + "first": 70, + "second": 238, + "amount": 2 + }, + { + "first": 70, + "second": 239, + "amount": 2 + }, + { + "first": 70, + "second": 240, + "amount": -1 + }, + { + "first": 70, + "second": 242, + "amount": -1 + }, + { + "first": 70, + "second": 243, + "amount": -1 + }, + { + "first": 70, + "second": 244, + "amount": -1 + }, + { + "first": 70, + "second": 245, + "amount": -1 + }, + { + "first": 70, + "second": 246, + "amount": -1 + }, + { + "first": 70, + "second": 248, + "amount": -2 + }, + { + "first": 70, + "second": 249, + "amount": -1 + }, + { + "first": 70, + "second": 250, + "amount": -2 + }, + { + "first": 70, + "second": 251, + "amount": -1 + }, + { + "first": 70, + "second": 252, + "amount": -1 + }, + { + "first": 70, + "second": 338, + "amount": -1 + }, + { + "first": 70, + "second": 339, + "amount": -2 + }, + { + "first": 70, + "second": 376, + "amount": 0 + }, + { + "first": 71, + "second": 47, + "amount": -1 + }, + { + "first": 71, + "second": 51, + "amount": 0 + }, + { + "first": 71, + "second": 63, + "amount": 0 + }, + { + "first": 71, + "second": 65, + "amount": -1 + }, + { + "first": 71, + "second": 74, + "amount": 0 + }, + { + "first": 71, + "second": 86, + "amount": 0 + }, + { + "first": 71, + "second": 88, + "amount": 0 + }, + { + "first": 71, + "second": 89, + "amount": 0 + }, + { + "first": 71, + "second": 102, + "amount": 0 + }, + { + "first": 71, + "second": 115, + "amount": 0 + }, + { + "first": 71, + "second": 118, + "amount": -1 + }, + { + "first": 71, + "second": 119, + "amount": -1 + }, + { + "first": 71, + "second": 120, + "amount": -1 + }, + { + "first": 71, + "second": 121, + "amount": -1 + }, + { + "first": 71, + "second": 122, + "amount": 0 + }, + { + "first": 71, + "second": 192, + "amount": -1 + }, + { + "first": 71, + "second": 193, + "amount": -1 + }, + { + "first": 71, + "second": 194, + "amount": -1 + }, + { + "first": 71, + "second": 195, + "amount": -1 + }, + { + "first": 71, + "second": 196, + "amount": -1 + }, + { + "first": 71, + "second": 197, + "amount": -1 + }, + { + "first": 71, + "second": 221, + "amount": 0 + }, + { + "first": 71, + "second": 376, + "amount": 0 + }, + { + "first": 72, + "second": 65, + "amount": -1 + }, + { + "first": 72, + "second": 74, + "amount": 0 + }, + { + "first": 72, + "second": 86, + "amount": -1 + }, + { + "first": 72, + "second": 87, + "amount": -1 + }, + { + "first": 72, + "second": 89, + "amount": -1 + }, + { + "first": 72, + "second": 192, + "amount": -1 + }, + { + "first": 72, + "second": 193, + "amount": -1 + }, + { + "first": 72, + "second": 194, + "amount": -1 + }, + { + "first": 72, + "second": 195, + "amount": -1 + }, + { + "first": 72, + "second": 196, + "amount": -1 + }, + { + "first": 72, + "second": 197, + "amount": -1 + }, + { + "first": 72, + "second": 221, + "amount": -1 + }, + { + "first": 72, + "second": 376, + "amount": -1 + }, + { + "first": 73, + "second": 65, + "amount": -1 + }, + { + "first": 73, + "second": 74, + "amount": 0 + }, + { + "first": 73, + "second": 86, + "amount": -1 + }, + { + "first": 73, + "second": 87, + "amount": -1 + }, + { + "first": 73, + "second": 89, + "amount": -1 + }, + { + "first": 73, + "second": 192, + "amount": -1 + }, + { + "first": 73, + "second": 193, + "amount": -1 + }, + { + "first": 73, + "second": 194, + "amount": -1 + }, + { + "first": 73, + "second": 195, + "amount": -1 + }, + { + "first": 73, + "second": 196, + "amount": -1 + }, + { + "first": 73, + "second": 197, + "amount": -1 + }, + { + "first": 73, + "second": 221, + "amount": -1 + }, + { + "first": 73, + "second": 376, + "amount": -1 + }, + { + "first": 74, + "second": 44, + "amount": -2 + }, + { + "first": 74, + "second": 46, + "amount": -2 + }, + { + "first": 74, + "second": 47, + "amount": -3 + }, + { + "first": 74, + "second": 65, + "amount": -2 + }, + { + "first": 74, + "second": 88, + "amount": 0 + }, + { + "first": 74, + "second": 192, + "amount": -2 + }, + { + "first": 74, + "second": 193, + "amount": -2 + }, + { + "first": 74, + "second": 194, + "amount": -2 + }, + { + "first": 74, + "second": 195, + "amount": -2 + }, + { + "first": 74, + "second": 196, + "amount": -2 + }, + { + "first": 74, + "second": 197, + "amount": -2 + }, + { + "first": 75, + "second": 38, + "amount": -1 + }, + { + "first": 75, + "second": 48, + "amount": -1 + }, + { + "first": 75, + "second": 49, + "amount": 0 + }, + { + "first": 75, + "second": 51, + "amount": -1 + }, + { + "first": 75, + "second": 52, + "amount": 0 + }, + { + "first": 75, + "second": 53, + "amount": 0 + }, + { + "first": 75, + "second": 54, + "amount": -1 + }, + { + "first": 75, + "second": 55, + "amount": -1 + }, + { + "first": 75, + "second": 56, + "amount": -1 + }, + { + "first": 75, + "second": 57, + "amount": -2 + }, + { + "first": 75, + "second": 63, + "amount": -2 + }, + { + "first": 75, + "second": 64, + "amount": -2 + }, + { + "first": 75, + "second": 67, + "amount": -2 + }, + { + "first": 75, + "second": 71, + "amount": -2 + }, + { + "first": 75, + "second": 74, + "amount": 0 + }, + { + "first": 75, + "second": 79, + "amount": -2 + }, + { + "first": 75, + "second": 81, + "amount": -2 + }, + { + "first": 75, + "second": 83, + "amount": -1 + }, + { + "first": 75, + "second": 84, + "amount": -1 + }, + { + "first": 75, + "second": 85, + "amount": -1 + }, + { + "first": 75, + "second": 86, + "amount": -1 + }, + { + "first": 75, + "second": 87, + "amount": -1 + }, + { + "first": 75, + "second": 89, + "amount": -1 + }, + { + "first": 75, + "second": 97, + "amount": 0 + }, + { + "first": 75, + "second": 99, + "amount": -1 + }, + { + "first": 75, + "second": 100, + "amount": -1 + }, + { + "first": 75, + "second": 101, + "amount": -1 + }, + { + "first": 75, + "second": 102, + "amount": 0 + }, + { + "first": 75, + "second": 103, + "amount": -1 + }, + { + "first": 75, + "second": 111, + "amount": -1 + }, + { + "first": 75, + "second": 113, + "amount": -1 + }, + { + "first": 75, + "second": 115, + "amount": -1 + }, + { + "first": 75, + "second": 116, + "amount": 0 + }, + { + "first": 75, + "second": 117, + "amount": 0 + }, + { + "first": 75, + "second": 118, + "amount": -2 + }, + { + "first": 75, + "second": 119, + "amount": -2 + }, + { + "first": 75, + "second": 121, + "amount": -2 + }, + { + "first": 75, + "second": 169, + "amount": -2 + }, + { + "first": 75, + "second": 174, + "amount": -2 + }, + { + "first": 75, + "second": 199, + "amount": -2 + }, + { + "first": 75, + "second": 210, + "amount": -2 + }, + { + "first": 75, + "second": 211, + "amount": -2 + }, + { + "first": 75, + "second": 212, + "amount": -2 + }, + { + "first": 75, + "second": 213, + "amount": -2 + }, + { + "first": 75, + "second": 214, + "amount": -2 + }, + { + "first": 75, + "second": 216, + "amount": -2 + }, + { + "first": 75, + "second": 217, + "amount": -1 + }, + { + "first": 75, + "second": 218, + "amount": -1 + }, + { + "first": 75, + "second": 219, + "amount": -1 + }, + { + "first": 75, + "second": 220, + "amount": -1 + }, + { + "first": 75, + "second": 221, + "amount": -1 + }, + { + "first": 75, + "second": 230, + "amount": 0 + }, + { + "first": 75, + "second": 231, + "amount": -1 + }, + { + "first": 75, + "second": 248, + "amount": -1 + }, + { + "first": 75, + "second": 250, + "amount": 0 + }, + { + "first": 75, + "second": 338, + "amount": -2 + }, + { + "first": 75, + "second": 339, + "amount": -1 + }, + { + "first": 75, + "second": 376, + "amount": -1 + }, + { + "first": 76, + "second": 42, + "amount": -5 + }, + { + "first": 76, + "second": 48, + "amount": 0 + }, + { + "first": 76, + "second": 52, + "amount": 1 + }, + { + "first": 76, + "second": 55, + "amount": -2 + }, + { + "first": 76, + "second": 57, + "amount": -1 + }, + { + "first": 76, + "second": 63, + "amount": -2 + }, + { + "first": 76, + "second": 64, + "amount": -1 + }, + { + "first": 76, + "second": 65, + "amount": 0 + }, + { + "first": 76, + "second": 67, + "amount": -1 + }, + { + "first": 76, + "second": 71, + "amount": -1 + }, + { + "first": 76, + "second": 74, + "amount": 1 + }, + { + "first": 76, + "second": 79, + "amount": -1 + }, + { + "first": 76, + "second": 81, + "amount": -1 + }, + { + "first": 76, + "second": 84, + "amount": -4 + }, + { + "first": 76, + "second": 86, + "amount": -4 + }, + { + "first": 76, + "second": 89, + "amount": -4 + }, + { + "first": 76, + "second": 90, + "amount": 1 + }, + { + "first": 76, + "second": 92, + "amount": -2 + }, + { + "first": 76, + "second": 102, + "amount": 0 + }, + { + "first": 76, + "second": 116, + "amount": 0 + }, + { + "first": 76, + "second": 118, + "amount": -2 + }, + { + "first": 76, + "second": 119, + "amount": -1 + }, + { + "first": 76, + "second": 121, + "amount": -2 + }, + { + "first": 76, + "second": 169, + "amount": -1 + }, + { + "first": 76, + "second": 174, + "amount": -1 + }, + { + "first": 76, + "second": 192, + "amount": 0 + }, + { + "first": 76, + "second": 193, + "amount": 0 + }, + { + "first": 76, + "second": 194, + "amount": 0 + }, + { + "first": 76, + "second": 195, + "amount": 0 + }, + { + "first": 76, + "second": 196, + "amount": 0 + }, + { + "first": 76, + "second": 197, + "amount": 0 + }, + { + "first": 76, + "second": 198, + "amount": 2 + }, + { + "first": 76, + "second": 199, + "amount": -1 + }, + { + "first": 76, + "second": 210, + "amount": -1 + }, + { + "first": 76, + "second": 211, + "amount": -1 + }, + { + "first": 76, + "second": 212, + "amount": -1 + }, + { + "first": 76, + "second": 213, + "amount": -1 + }, + { + "first": 76, + "second": 214, + "amount": -1 + }, + { + "first": 76, + "second": 216, + "amount": -1 + }, + { + "first": 76, + "second": 221, + "amount": -4 + }, + { + "first": 76, + "second": 338, + "amount": -1 + }, + { + "first": 76, + "second": 376, + "amount": -4 + }, + { + "first": 77, + "second": 65, + "amount": -1 + }, + { + "first": 77, + "second": 74, + "amount": 0 + }, + { + "first": 77, + "second": 86, + "amount": -1 + }, + { + "first": 77, + "second": 87, + "amount": -1 + }, + { + "first": 77, + "second": 89, + "amount": -1 + }, + { + "first": 77, + "second": 192, + "amount": -1 + }, + { + "first": 77, + "second": 193, + "amount": -1 + }, + { + "first": 77, + "second": 194, + "amount": -1 + }, + { + "first": 77, + "second": 195, + "amount": -1 + }, + { + "first": 77, + "second": 196, + "amount": -1 + }, + { + "first": 77, + "second": 197, + "amount": -1 + }, + { + "first": 77, + "second": 221, + "amount": -1 + }, + { + "first": 77, + "second": 376, + "amount": -1 + }, + { + "first": 78, + "second": 65, + "amount": -1 + }, + { + "first": 78, + "second": 74, + "amount": 0 + }, + { + "first": 78, + "second": 86, + "amount": -1 + }, + { + "first": 78, + "second": 87, + "amount": -1 + }, + { + "first": 78, + "second": 89, + "amount": -1 + }, + { + "first": 78, + "second": 192, + "amount": -1 + }, + { + "first": 78, + "second": 193, + "amount": -1 + }, + { + "first": 78, + "second": 194, + "amount": -1 + }, + { + "first": 78, + "second": 195, + "amount": -1 + }, + { + "first": 78, + "second": 196, + "amount": -1 + }, + { + "first": 78, + "second": 197, + "amount": -1 + }, + { + "first": 78, + "second": 221, + "amount": -1 + }, + { + "first": 78, + "second": 376, + "amount": -1 + }, + { + "first": 79, + "second": 44, + "amount": -1 + }, + { + "first": 79, + "second": 46, + "amount": -1 + }, + { + "first": 79, + "second": 47, + "amount": -1 + }, + { + "first": 79, + "second": 65, + "amount": -2 + }, + { + "first": 79, + "second": 74, + "amount": 0 + }, + { + "first": 79, + "second": 84, + "amount": -2 + }, + { + "first": 79, + "second": 86, + "amount": -3 + }, + { + "first": 79, + "second": 87, + "amount": -1 + }, + { + "first": 79, + "second": 88, + "amount": -1 + }, + { + "first": 79, + "second": 89, + "amount": -3 + }, + { + "first": 79, + "second": 90, + "amount": -1 + }, + { + "first": 79, + "second": 97, + "amount": 0 + }, + { + "first": 79, + "second": 99, + "amount": 0 + }, + { + "first": 79, + "second": 100, + "amount": 0 + }, + { + "first": 79, + "second": 101, + "amount": 0 + }, + { + "first": 79, + "second": 103, + "amount": 0 + }, + { + "first": 79, + "second": 111, + "amount": 0 + }, + { + "first": 79, + "second": 113, + "amount": 0 + }, + { + "first": 79, + "second": 115, + "amount": 0 + }, + { + "first": 79, + "second": 118, + "amount": 0 + }, + { + "first": 79, + "second": 119, + "amount": 0 + }, + { + "first": 79, + "second": 120, + "amount": 0 + }, + { + "first": 79, + "second": 121, + "amount": 0 + }, + { + "first": 79, + "second": 122, + "amount": 0 + }, + { + "first": 79, + "second": 192, + "amount": -2 + }, + { + "first": 79, + "second": 193, + "amount": -2 + }, + { + "first": 79, + "second": 194, + "amount": -2 + }, + { + "first": 79, + "second": 195, + "amount": -2 + }, + { + "first": 79, + "second": 196, + "amount": -2 + }, + { + "first": 79, + "second": 197, + "amount": -2 + }, + { + "first": 79, + "second": 221, + "amount": -3 + }, + { + "first": 79, + "second": 230, + "amount": 0 + }, + { + "first": 79, + "second": 231, + "amount": 0 + }, + { + "first": 79, + "second": 248, + "amount": 0 + }, + { + "first": 79, + "second": 339, + "amount": 0 + }, + { + "first": 79, + "second": 376, + "amount": -3 + }, + { + "first": 80, + "second": 38, + "amount": -2 + }, + { + "first": 80, + "second": 41, + "amount": -1 + }, + { + "first": 80, + "second": 44, + "amount": -6 + }, + { + "first": 80, + "second": 46, + "amount": -6 + }, + { + "first": 80, + "second": 47, + "amount": -5 + }, + { + "first": 80, + "second": 50, + "amount": 0 + }, + { + "first": 80, + "second": 51, + "amount": -1 + }, + { + "first": 80, + "second": 52, + "amount": -1 + }, + { + "first": 80, + "second": 54, + "amount": -1 + }, + { + "first": 80, + "second": 56, + "amount": 0 + }, + { + "first": 80, + "second": 64, + "amount": 0 + }, + { + "first": 80, + "second": 65, + "amount": -5 + }, + { + "first": 80, + "second": 67, + "amount": 0 + }, + { + "first": 80, + "second": 71, + "amount": 0 + }, + { + "first": 80, + "second": 74, + "amount": -6 + }, + { + "first": 80, + "second": 79, + "amount": 0 + }, + { + "first": 80, + "second": 81, + "amount": 0 + }, + { + "first": 80, + "second": 83, + "amount": -1 + }, + { + "first": 80, + "second": 84, + "amount": 0 + }, + { + "first": 80, + "second": 85, + "amount": 0 + }, + { + "first": 80, + "second": 86, + "amount": -1 + }, + { + "first": 80, + "second": 87, + "amount": -1 + }, + { + "first": 80, + "second": 88, + "amount": -2 + }, + { + "first": 80, + "second": 89, + "amount": -1 + }, + { + "first": 80, + "second": 90, + "amount": -1 + }, + { + "first": 80, + "second": 97, + "amount": -1 + }, + { + "first": 80, + "second": 99, + "amount": -1 + }, + { + "first": 80, + "second": 100, + "amount": -1 + }, + { + "first": 80, + "second": 101, + "amount": -1 + }, + { + "first": 80, + "second": 102, + "amount": 0 + }, + { + "first": 80, + "second": 103, + "amount": -1 + }, + { + "first": 80, + "second": 111, + "amount": -1 + }, + { + "first": 80, + "second": 113, + "amount": -1 + }, + { + "first": 80, + "second": 115, + "amount": 0 + }, + { + "first": 80, + "second": 116, + "amount": 1 + }, + { + "first": 80, + "second": 117, + "amount": 0 + }, + { + "first": 80, + "second": 118, + "amount": 0 + }, + { + "first": 80, + "second": 119, + "amount": 0 + }, + { + "first": 80, + "second": 120, + "amount": 0 + }, + { + "first": 80, + "second": 121, + "amount": 0 + }, + { + "first": 80, + "second": 122, + "amount": 0 + }, + { + "first": 80, + "second": 169, + "amount": 0 + }, + { + "first": 80, + "second": 171, + "amount": -1 + }, + { + "first": 80, + "second": 174, + "amount": 0 + }, + { + "first": 80, + "second": 180, + "amount": -1 + }, + { + "first": 80, + "second": 192, + "amount": -5 + }, + { + "first": 80, + "second": 193, + "amount": -5 + }, + { + "first": 80, + "second": 194, + "amount": -5 + }, + { + "first": 80, + "second": 195, + "amount": -5 + }, + { + "first": 80, + "second": 196, + "amount": -5 + }, + { + "first": 80, + "second": 197, + "amount": -5 + }, + { + "first": 80, + "second": 198, + "amount": -5 + }, + { + "first": 80, + "second": 199, + "amount": 0 + }, + { + "first": 80, + "second": 210, + "amount": 0 + }, + { + "first": 80, + "second": 211, + "amount": 0 + }, + { + "first": 80, + "second": 212, + "amount": 0 + }, + { + "first": 80, + "second": 213, + "amount": 0 + }, + { + "first": 80, + "second": 214, + "amount": 0 + }, + { + "first": 80, + "second": 216, + "amount": 0 + }, + { + "first": 80, + "second": 217, + "amount": 0 + }, + { + "first": 80, + "second": 218, + "amount": 0 + }, + { + "first": 80, + "second": 219, + "amount": 0 + }, + { + "first": 80, + "second": 220, + "amount": 0 + }, + { + "first": 80, + "second": 221, + "amount": -1 + }, + { + "first": 80, + "second": 224, + "amount": -1 + }, + { + "first": 80, + "second": 225, + "amount": -1 + }, + { + "first": 80, + "second": 226, + "amount": -1 + }, + { + "first": 80, + "second": 227, + "amount": -1 + }, + { + "first": 80, + "second": 228, + "amount": -1 + }, + { + "first": 80, + "second": 229, + "amount": -1 + }, + { + "first": 80, + "second": 230, + "amount": -1 + }, + { + "first": 80, + "second": 231, + "amount": -1 + }, + { + "first": 80, + "second": 236, + "amount": 3 + }, + { + "first": 80, + "second": 237, + "amount": 3 + }, + { + "first": 80, + "second": 238, + "amount": 3 + }, + { + "first": 80, + "second": 239, + "amount": 3 + }, + { + "first": 80, + "second": 248, + "amount": -1 + }, + { + "first": 80, + "second": 250, + "amount": 0 + }, + { + "first": 80, + "second": 253, + "amount": 0 + }, + { + "first": 80, + "second": 255, + "amount": 0 + }, + { + "first": 80, + "second": 338, + "amount": 0 + }, + { + "first": 80, + "second": 339, + "amount": -1 + }, + { + "first": 80, + "second": 376, + "amount": -1 + }, + { + "first": 81, + "second": 44, + "amount": -1 + }, + { + "first": 81, + "second": 46, + "amount": -1 + }, + { + "first": 81, + "second": 47, + "amount": -1 + }, + { + "first": 81, + "second": 65, + "amount": -2 + }, + { + "first": 81, + "second": 74, + "amount": 0 + }, + { + "first": 81, + "second": 84, + "amount": -2 + }, + { + "first": 81, + "second": 86, + "amount": -3 + }, + { + "first": 81, + "second": 87, + "amount": -1 + }, + { + "first": 81, + "second": 88, + "amount": -1 + }, + { + "first": 81, + "second": 89, + "amount": -3 + }, + { + "first": 81, + "second": 90, + "amount": -1 + }, + { + "first": 81, + "second": 97, + "amount": 0 + }, + { + "first": 81, + "second": 99, + "amount": 0 + }, + { + "first": 81, + "second": 100, + "amount": 0 + }, + { + "first": 81, + "second": 101, + "amount": 0 + }, + { + "first": 81, + "second": 103, + "amount": 0 + }, + { + "first": 81, + "second": 111, + "amount": 0 + }, + { + "first": 81, + "second": 113, + "amount": 0 + }, + { + "first": 81, + "second": 115, + "amount": 0 + }, + { + "first": 81, + "second": 118, + "amount": 0 + }, + { + "first": 81, + "second": 119, + "amount": 0 + }, + { + "first": 81, + "second": 120, + "amount": 0 + }, + { + "first": 81, + "second": 121, + "amount": 0 + }, + { + "first": 81, + "second": 122, + "amount": 0 + }, + { + "first": 81, + "second": 192, + "amount": -2 + }, + { + "first": 81, + "second": 193, + "amount": -2 + }, + { + "first": 81, + "second": 194, + "amount": -2 + }, + { + "first": 81, + "second": 195, + "amount": -2 + }, + { + "first": 81, + "second": 196, + "amount": -2 + }, + { + "first": 81, + "second": 197, + "amount": -2 + }, + { + "first": 81, + "second": 221, + "amount": -3 + }, + { + "first": 81, + "second": 230, + "amount": 0 + }, + { + "first": 81, + "second": 231, + "amount": 0 + }, + { + "first": 81, + "second": 248, + "amount": 0 + }, + { + "first": 81, + "second": 339, + "amount": 0 + }, + { + "first": 81, + "second": 376, + "amount": -3 + }, + { + "first": 82, + "second": 38, + "amount": -1 + }, + { + "first": 82, + "second": 44, + "amount": 0 + }, + { + "first": 82, + "second": 46, + "amount": 0 + }, + { + "first": 82, + "second": 48, + "amount": 0 + }, + { + "first": 82, + "second": 50, + "amount": 0 + }, + { + "first": 82, + "second": 51, + "amount": 0 + }, + { + "first": 82, + "second": 52, + "amount": 0 + }, + { + "first": 82, + "second": 54, + "amount": 0 + }, + { + "first": 82, + "second": 58, + "amount": 0 + }, + { + "first": 82, + "second": 59, + "amount": 0 + }, + { + "first": 82, + "second": 64, + "amount": 0 + }, + { + "first": 82, + "second": 65, + "amount": -1 + }, + { + "first": 82, + "second": 67, + "amount": 0 + }, + { + "first": 82, + "second": 71, + "amount": 0 + }, + { + "first": 82, + "second": 74, + "amount": -1 + }, + { + "first": 82, + "second": 79, + "amount": 0 + }, + { + "first": 82, + "second": 81, + "amount": 0 + }, + { + "first": 82, + "second": 83, + "amount": 0 + }, + { + "first": 82, + "second": 84, + "amount": -1 + }, + { + "first": 82, + "second": 85, + "amount": 0 + }, + { + "first": 82, + "second": 86, + "amount": 0 + }, + { + "first": 82, + "second": 87, + "amount": 0 + }, + { + "first": 82, + "second": 88, + "amount": -1 + }, + { + "first": 82, + "second": 89, + "amount": -1 + }, + { + "first": 82, + "second": 90, + "amount": 0 + }, + { + "first": 82, + "second": 97, + "amount": -1 + }, + { + "first": 82, + "second": 99, + "amount": -1 + }, + { + "first": 82, + "second": 100, + "amount": -1 + }, + { + "first": 82, + "second": 101, + "amount": -1 + }, + { + "first": 82, + "second": 103, + "amount": -1 + }, + { + "first": 82, + "second": 111, + "amount": -1 + }, + { + "first": 82, + "second": 113, + "amount": -1 + }, + { + "first": 82, + "second": 115, + "amount": 0 + }, + { + "first": 82, + "second": 117, + "amount": 0 + }, + { + "first": 82, + "second": 118, + "amount": 0 + }, + { + "first": 82, + "second": 119, + "amount": 0 + }, + { + "first": 82, + "second": 120, + "amount": 0 + }, + { + "first": 82, + "second": 121, + "amount": 0 + }, + { + "first": 82, + "second": 122, + "amount": 0 + }, + { + "first": 82, + "second": 169, + "amount": 0 + }, + { + "first": 82, + "second": 171, + "amount": -1 + }, + { + "first": 82, + "second": 174, + "amount": 0 + }, + { + "first": 82, + "second": 180, + "amount": -1 + }, + { + "first": 82, + "second": 192, + "amount": -1 + }, + { + "first": 82, + "second": 193, + "amount": -1 + }, + { + "first": 82, + "second": 194, + "amount": -1 + }, + { + "first": 82, + "second": 195, + "amount": -1 + }, + { + "first": 82, + "second": 196, + "amount": -1 + }, + { + "first": 82, + "second": 197, + "amount": -1 + }, + { + "first": 82, + "second": 199, + "amount": 0 + }, + { + "first": 82, + "second": 210, + "amount": 0 + }, + { + "first": 82, + "second": 211, + "amount": 0 + }, + { + "first": 82, + "second": 212, + "amount": 0 + }, + { + "first": 82, + "second": 213, + "amount": 0 + }, + { + "first": 82, + "second": 214, + "amount": 0 + }, + { + "first": 82, + "second": 216, + "amount": 0 + }, + { + "first": 82, + "second": 217, + "amount": 0 + }, + { + "first": 82, + "second": 218, + "amount": 0 + }, + { + "first": 82, + "second": 219, + "amount": 0 + }, + { + "first": 82, + "second": 220, + "amount": 0 + }, + { + "first": 82, + "second": 221, + "amount": -1 + }, + { + "first": 82, + "second": 224, + "amount": -1 + }, + { + "first": 82, + "second": 225, + "amount": -1 + }, + { + "first": 82, + "second": 226, + "amount": -1 + }, + { + "first": 82, + "second": 227, + "amount": -1 + }, + { + "first": 82, + "second": 228, + "amount": -1 + }, + { + "first": 82, + "second": 229, + "amount": -1 + }, + { + "first": 82, + "second": 230, + "amount": -1 + }, + { + "first": 82, + "second": 231, + "amount": -1 + }, + { + "first": 82, + "second": 248, + "amount": -1 + }, + { + "first": 82, + "second": 250, + "amount": 0 + }, + { + "first": 82, + "second": 338, + "amount": 0 + }, + { + "first": 82, + "second": 339, + "amount": -1 + }, + { + "first": 82, + "second": 376, + "amount": -1 + }, + { + "first": 83, + "second": 47, + "amount": -1 + }, + { + "first": 83, + "second": 55, + "amount": 0 + }, + { + "first": 83, + "second": 65, + "amount": -1 + }, + { + "first": 83, + "second": 86, + "amount": -2 + }, + { + "first": 83, + "second": 88, + "amount": -1 + }, + { + "first": 83, + "second": 89, + "amount": -2 + }, + { + "first": 83, + "second": 90, + "amount": 0 + }, + { + "first": 83, + "second": 99, + "amount": 0 + }, + { + "first": 83, + "second": 100, + "amount": 0 + }, + { + "first": 83, + "second": 101, + "amount": 0 + }, + { + "first": 83, + "second": 102, + "amount": 0 + }, + { + "first": 83, + "second": 103, + "amount": 0 + }, + { + "first": 83, + "second": 111, + "amount": 0 + }, + { + "first": 83, + "second": 113, + "amount": 0 + }, + { + "first": 83, + "second": 118, + "amount": -1 + }, + { + "first": 83, + "second": 119, + "amount": 0 + }, + { + "first": 83, + "second": 120, + "amount": -1 + }, + { + "first": 83, + "second": 121, + "amount": -1 + }, + { + "first": 83, + "second": 192, + "amount": -1 + }, + { + "first": 83, + "second": 193, + "amount": -1 + }, + { + "first": 83, + "second": 194, + "amount": -1 + }, + { + "first": 83, + "second": 195, + "amount": -1 + }, + { + "first": 83, + "second": 196, + "amount": -1 + }, + { + "first": 83, + "second": 197, + "amount": -1 + }, + { + "first": 83, + "second": 221, + "amount": -2 + }, + { + "first": 83, + "second": 231, + "amount": 0 + }, + { + "first": 83, + "second": 248, + "amount": 0 + }, + { + "first": 83, + "second": 339, + "amount": 0 + }, + { + "first": 83, + "second": 376, + "amount": -2 + }, + { + "first": 84, + "second": 32, + "amount": -1 + }, + { + "first": 84, + "second": 38, + "amount": -3 + }, + { + "first": 84, + "second": 41, + "amount": 0 + }, + { + "first": 84, + "second": 44, + "amount": -4 + }, + { + "first": 84, + "second": 45, + "amount": -2 + }, + { + "first": 84, + "second": 46, + "amount": -4 + }, + { + "first": 84, + "second": 47, + "amount": -5 + }, + { + "first": 84, + "second": 48, + "amount": -1 + }, + { + "first": 84, + "second": 49, + "amount": 0 + }, + { + "first": 84, + "second": 50, + "amount": -1 + }, + { + "first": 84, + "second": 51, + "amount": -1 + }, + { + "first": 84, + "second": 52, + "amount": -3 + }, + { + "first": 84, + "second": 54, + "amount": -3 + }, + { + "first": 84, + "second": 55, + "amount": 0 + }, + { + "first": 84, + "second": 56, + "amount": -1 + }, + { + "first": 84, + "second": 58, + "amount": -1 + }, + { + "first": 84, + "second": 59, + "amount": -1 + }, + { + "first": 84, + "second": 64, + "amount": -2 + }, + { + "first": 84, + "second": 65, + "amount": -5 + }, + { + "first": 84, + "second": 67, + "amount": -2 + }, + { + "first": 84, + "second": 71, + "amount": -2 + }, + { + "first": 84, + "second": 74, + "amount": -5 + }, + { + "first": 84, + "second": 79, + "amount": -2 + }, + { + "first": 84, + "second": 81, + "amount": -2 + }, + { + "first": 84, + "second": 88, + "amount": 0 + }, + { + "first": 84, + "second": 92, + "amount": 0 + }, + { + "first": 84, + "second": 97, + "amount": -3 + }, + { + "first": 84, + "second": 99, + "amount": -4 + }, + { + "first": 84, + "second": 100, + "amount": -4 + }, + { + "first": 84, + "second": 101, + "amount": -4 + }, + { + "first": 84, + "second": 102, + "amount": -1 + }, + { + "first": 84, + "second": 103, + "amount": -4 + }, + { + "first": 84, + "second": 109, + "amount": -2 + }, + { + "first": 84, + "second": 110, + "amount": -2 + }, + { + "first": 84, + "second": 111, + "amount": -4 + }, + { + "first": 84, + "second": 112, + "amount": -2 + }, + { + "first": 84, + "second": 113, + "amount": -4 + }, + { + "first": 84, + "second": 114, + "amount": -2 + }, + { + "first": 84, + "second": 115, + "amount": -3 + }, + { + "first": 84, + "second": 116, + "amount": -1 + }, + { + "first": 84, + "second": 117, + "amount": -3 + }, + { + "first": 84, + "second": 118, + "amount": -2 + }, + { + "first": 84, + "second": 119, + "amount": -2 + }, + { + "first": 84, + "second": 120, + "amount": -2 + }, + { + "first": 84, + "second": 121, + "amount": -2 + }, + { + "first": 84, + "second": 122, + "amount": -2 + }, + { + "first": 84, + "second": 169, + "amount": -2 + }, + { + "first": 84, + "second": 171, + "amount": -3 + }, + { + "first": 84, + "second": 174, + "amount": -2 + }, + { + "first": 84, + "second": 180, + "amount": -2 + }, + { + "first": 84, + "second": 181, + "amount": -2 + }, + { + "first": 84, + "second": 187, + "amount": -2 + }, + { + "first": 84, + "second": 192, + "amount": -5 + }, + { + "first": 84, + "second": 193, + "amount": -5 + }, + { + "first": 84, + "second": 194, + "amount": -5 + }, + { + "first": 84, + "second": 195, + "amount": -5 + }, + { + "first": 84, + "second": 196, + "amount": -5 + }, + { + "first": 84, + "second": 197, + "amount": -5 + }, + { + "first": 84, + "second": 198, + "amount": -4 + }, + { + "first": 84, + "second": 199, + "amount": -2 + }, + { + "first": 84, + "second": 210, + "amount": -2 + }, + { + "first": 84, + "second": 211, + "amount": -2 + }, + { + "first": 84, + "second": 212, + "amount": -2 + }, + { + "first": 84, + "second": 213, + "amount": -2 + }, + { + "first": 84, + "second": 214, + "amount": -2 + }, + { + "first": 84, + "second": 216, + "amount": -2 + }, + { + "first": 84, + "second": 224, + "amount": -2 + }, + { + "first": 84, + "second": 225, + "amount": -2 + }, + { + "first": 84, + "second": 226, + "amount": -2 + }, + { + "first": 84, + "second": 227, + "amount": -2 + }, + { + "first": 84, + "second": 228, + "amount": -2 + }, + { + "first": 84, + "second": 229, + "amount": -2 + }, + { + "first": 84, + "second": 230, + "amount": -3 + }, + { + "first": 84, + "second": 231, + "amount": -4 + }, + { + "first": 84, + "second": 232, + "amount": -3 + }, + { + "first": 84, + "second": 233, + "amount": -3 + }, + { + "first": 84, + "second": 234, + "amount": -3 + }, + { + "first": 84, + "second": 235, + "amount": -3 + }, + { + "first": 84, + "second": 236, + "amount": 3 + }, + { + "first": 84, + "second": 237, + "amount": 3 + }, + { + "first": 84, + "second": 238, + "amount": 3 + }, + { + "first": 84, + "second": 239, + "amount": 3 + }, + { + "first": 84, + "second": 240, + "amount": -3 + }, + { + "first": 84, + "second": 242, + "amount": -3 + }, + { + "first": 84, + "second": 243, + "amount": -3 + }, + { + "first": 84, + "second": 244, + "amount": -3 + }, + { + "first": 84, + "second": 245, + "amount": -3 + }, + { + "first": 84, + "second": 246, + "amount": -3 + }, + { + "first": 84, + "second": 248, + "amount": -4 + }, + { + "first": 84, + "second": 249, + "amount": -1 + }, + { + "first": 84, + "second": 250, + "amount": -3 + }, + { + "first": 84, + "second": 251, + "amount": -1 + }, + { + "first": 84, + "second": 252, + "amount": -1 + }, + { + "first": 84, + "second": 253, + "amount": -1 + }, + { + "first": 84, + "second": 255, + "amount": -1 + }, + { + "first": 84, + "second": 338, + "amount": -2 + }, + { + "first": 84, + "second": 339, + "amount": -4 + }, + { + "first": 85, + "second": 44, + "amount": -2 + }, + { + "first": 85, + "second": 46, + "amount": -2 + }, + { + "first": 85, + "second": 47, + "amount": -2 + }, + { + "first": 85, + "second": 65, + "amount": -1 + }, + { + "first": 85, + "second": 88, + "amount": 0 + }, + { + "first": 85, + "second": 115, + "amount": 0 + }, + { + "first": 85, + "second": 120, + "amount": 0 + }, + { + "first": 85, + "second": 192, + "amount": -1 + }, + { + "first": 85, + "second": 193, + "amount": -1 + }, + { + "first": 85, + "second": 194, + "amount": -1 + }, + { + "first": 85, + "second": 195, + "amount": -1 + }, + { + "first": 85, + "second": 196, + "amount": -1 + }, + { + "first": 85, + "second": 197, + "amount": -1 + }, + { + "first": 86, + "second": 32, + "amount": -2 + }, + { + "first": 86, + "second": 38, + "amount": -3 + }, + { + "first": 86, + "second": 41, + "amount": -1 + }, + { + "first": 86, + "second": 44, + "amount": -5 + }, + { + "first": 86, + "second": 45, + "amount": -1 + }, + { + "first": 86, + "second": 46, + "amount": -5 + }, + { + "first": 86, + "second": 47, + "amount": -6 + }, + { + "first": 86, + "second": 48, + "amount": -2 + }, + { + "first": 86, + "second": 50, + "amount": -1 + }, + { + "first": 86, + "second": 51, + "amount": -1 + }, + { + "first": 86, + "second": 52, + "amount": -4 + }, + { + "first": 86, + "second": 53, + "amount": -1 + }, + { + "first": 86, + "second": 54, + "amount": -3 + }, + { + "first": 86, + "second": 55, + "amount": 0 + }, + { + "first": 86, + "second": 56, + "amount": -3 + }, + { + "first": 86, + "second": 57, + "amount": -2 + }, + { + "first": 86, + "second": 58, + "amount": -4 + }, + { + "first": 86, + "second": 59, + "amount": -4 + }, + { + "first": 86, + "second": 64, + "amount": -3 + }, + { + "first": 86, + "second": 65, + "amount": -5 + }, + { + "first": 86, + "second": 66, + "amount": -1 + }, + { + "first": 86, + "second": 67, + "amount": -3 + }, + { + "first": 86, + "second": 68, + "amount": -1 + }, + { + "first": 86, + "second": 69, + "amount": -1 + }, + { + "first": 86, + "second": 70, + "amount": -1 + }, + { + "first": 86, + "second": 71, + "amount": -3 + }, + { + "first": 86, + "second": 72, + "amount": -1 + }, + { + "first": 86, + "second": 73, + "amount": -1 + }, + { + "first": 86, + "second": 74, + "amount": -6 + }, + { + "first": 86, + "second": 75, + "amount": -1 + }, + { + "first": 86, + "second": 76, + "amount": -1 + }, + { + "first": 86, + "second": 77, + "amount": -1 + }, + { + "first": 86, + "second": 78, + "amount": -1 + }, + { + "first": 86, + "second": 79, + "amount": -3 + }, + { + "first": 86, + "second": 80, + "amount": -1 + }, + { + "first": 86, + "second": 81, + "amount": -3 + }, + { + "first": 86, + "second": 82, + "amount": -1 + }, + { + "first": 86, + "second": 83, + "amount": -1 + }, + { + "first": 86, + "second": 88, + "amount": -1 + }, + { + "first": 86, + "second": 97, + "amount": -4 + }, + { + "first": 86, + "second": 99, + "amount": -4 + }, + { + "first": 86, + "second": 100, + "amount": -4 + }, + { + "first": 86, + "second": 101, + "amount": -4 + }, + { + "first": 86, + "second": 102, + "amount": -1 + }, + { + "first": 86, + "second": 103, + "amount": -4 + }, + { + "first": 86, + "second": 109, + "amount": -1 + }, + { + "first": 86, + "second": 110, + "amount": -1 + }, + { + "first": 86, + "second": 111, + "amount": -4 + }, + { + "first": 86, + "second": 112, + "amount": -1 + }, + { + "first": 86, + "second": 113, + "amount": -4 + }, + { + "first": 86, + "second": 114, + "amount": -1 + }, + { + "first": 86, + "second": 115, + "amount": -3 + }, + { + "first": 86, + "second": 117, + "amount": -1 + }, + { + "first": 86, + "second": 118, + "amount": -2 + }, + { + "first": 86, + "second": 119, + "amount": -1 + }, + { + "first": 86, + "second": 120, + "amount": -3 + }, + { + "first": 86, + "second": 121, + "amount": -2 + }, + { + "first": 86, + "second": 122, + "amount": -2 + }, + { + "first": 86, + "second": 169, + "amount": -3 + }, + { + "first": 86, + "second": 171, + "amount": -4 + }, + { + "first": 86, + "second": 174, + "amount": -3 + }, + { + "first": 86, + "second": 180, + "amount": -3 + }, + { + "first": 86, + "second": 181, + "amount": -1 + }, + { + "first": 86, + "second": 187, + "amount": -1 + }, + { + "first": 86, + "second": 192, + "amount": -5 + }, + { + "first": 86, + "second": 193, + "amount": -5 + }, + { + "first": 86, + "second": 194, + "amount": -5 + }, + { + "first": 86, + "second": 195, + "amount": -5 + }, + { + "first": 86, + "second": 196, + "amount": -5 + }, + { + "first": 86, + "second": 197, + "amount": -5 + }, + { + "first": 86, + "second": 198, + "amount": -5 + }, + { + "first": 86, + "second": 199, + "amount": -3 + }, + { + "first": 86, + "second": 200, + "amount": -1 + }, + { + "first": 86, + "second": 201, + "amount": -1 + }, + { + "first": 86, + "second": 202, + "amount": -1 + }, + { + "first": 86, + "second": 203, + "amount": -1 + }, + { + "first": 86, + "second": 204, + "amount": -1 + }, + { + "first": 86, + "second": 205, + "amount": -1 + }, + { + "first": 86, + "second": 206, + "amount": -1 + }, + { + "first": 86, + "second": 207, + "amount": -1 + }, + { + "first": 86, + "second": 209, + "amount": -1 + }, + { + "first": 86, + "second": 210, + "amount": -3 + }, + { + "first": 86, + "second": 211, + "amount": -3 + }, + { + "first": 86, + "second": 212, + "amount": -3 + }, + { + "first": 86, + "second": 213, + "amount": -3 + }, + { + "first": 86, + "second": 214, + "amount": -3 + }, + { + "first": 86, + "second": 216, + "amount": -3 + }, + { + "first": 86, + "second": 222, + "amount": -1 + }, + { + "first": 86, + "second": 224, + "amount": -3 + }, + { + "first": 86, + "second": 225, + "amount": -3 + }, + { + "first": 86, + "second": 226, + "amount": -3 + }, + { + "first": 86, + "second": 227, + "amount": -3 + }, + { + "first": 86, + "second": 228, + "amount": -3 + }, + { + "first": 86, + "second": 229, + "amount": -3 + }, + { + "first": 86, + "second": 230, + "amount": -4 + }, + { + "first": 86, + "second": 231, + "amount": -4 + }, + { + "first": 86, + "second": 232, + "amount": -3 + }, + { + "first": 86, + "second": 233, + "amount": -3 + }, + { + "first": 86, + "second": 234, + "amount": -3 + }, + { + "first": 86, + "second": 235, + "amount": -3 + }, + { + "first": 86, + "second": 236, + "amount": 3 + }, + { + "first": 86, + "second": 237, + "amount": 3 + }, + { + "first": 86, + "second": 238, + "amount": 3 + }, + { + "first": 86, + "second": 239, + "amount": 3 + }, + { + "first": 86, + "second": 240, + "amount": -3 + }, + { + "first": 86, + "second": 241, + "amount": -1 + }, + { + "first": 86, + "second": 242, + "amount": -3 + }, + { + "first": 86, + "second": 243, + "amount": -3 + }, + { + "first": 86, + "second": 244, + "amount": -3 + }, + { + "first": 86, + "second": 245, + "amount": -3 + }, + { + "first": 86, + "second": 246, + "amount": -3 + }, + { + "first": 86, + "second": 248, + "amount": -4 + }, + { + "first": 86, + "second": 249, + "amount": -1 + }, + { + "first": 86, + "second": 250, + "amount": -1 + }, + { + "first": 86, + "second": 251, + "amount": -1 + }, + { + "first": 86, + "second": 252, + "amount": -1 + }, + { + "first": 86, + "second": 253, + "amount": -2 + }, + { + "first": 86, + "second": 255, + "amount": -2 + }, + { + "first": 86, + "second": 338, + "amount": -3 + }, + { + "first": 86, + "second": 339, + "amount": -4 + }, + { + "first": 87, + "second": 32, + "amount": 0 + }, + { + "first": 87, + "second": 41, + "amount": 0 + }, + { + "first": 87, + "second": 44, + "amount": -1 + }, + { + "first": 87, + "second": 46, + "amount": -1 + }, + { + "first": 87, + "second": 47, + "amount": -4 + }, + { + "first": 87, + "second": 48, + "amount": 0 + }, + { + "first": 87, + "second": 50, + "amount": 0 + }, + { + "first": 87, + "second": 51, + "amount": 0 + }, + { + "first": 87, + "second": 52, + "amount": -1 + }, + { + "first": 87, + "second": 53, + "amount": 0 + }, + { + "first": 87, + "second": 54, + "amount": 0 + }, + { + "first": 87, + "second": 56, + "amount": 0 + }, + { + "first": 87, + "second": 57, + "amount": 0 + }, + { + "first": 87, + "second": 58, + "amount": 0 + }, + { + "first": 87, + "second": 59, + "amount": 0 + }, + { + "first": 87, + "second": 63, + "amount": 0 + }, + { + "first": 87, + "second": 64, + "amount": -1 + }, + { + "first": 87, + "second": 65, + "amount": -2 + }, + { + "first": 87, + "second": 66, + "amount": -1 + }, + { + "first": 87, + "second": 67, + "amount": -1 + }, + { + "first": 87, + "second": 68, + "amount": -1 + }, + { + "first": 87, + "second": 69, + "amount": -1 + }, + { + "first": 87, + "second": 70, + "amount": -1 + }, + { + "first": 87, + "second": 71, + "amount": -1 + }, + { + "first": 87, + "second": 72, + "amount": -1 + }, + { + "first": 87, + "second": 73, + "amount": -1 + }, + { + "first": 87, + "second": 74, + "amount": -2 + }, + { + "first": 87, + "second": 75, + "amount": -1 + }, + { + "first": 87, + "second": 76, + "amount": -1 + }, + { + "first": 87, + "second": 77, + "amount": -1 + }, + { + "first": 87, + "second": 78, + "amount": -1 + }, + { + "first": 87, + "second": 79, + "amount": -1 + }, + { + "first": 87, + "second": 80, + "amount": -1 + }, + { + "first": 87, + "second": 81, + "amount": -1 + }, + { + "first": 87, + "second": 82, + "amount": -1 + }, + { + "first": 87, + "second": 88, + "amount": -1 + }, + { + "first": 87, + "second": 92, + "amount": 0 + }, + { + "first": 87, + "second": 97, + "amount": -2 + }, + { + "first": 87, + "second": 99, + "amount": -1 + }, + { + "first": 87, + "second": 100, + "amount": -1 + }, + { + "first": 87, + "second": 101, + "amount": -1 + }, + { + "first": 87, + "second": 102, + "amount": 0 + }, + { + "first": 87, + "second": 103, + "amount": -1 + }, + { + "first": 87, + "second": 109, + "amount": -1 + }, + { + "first": 87, + "second": 110, + "amount": -1 + }, + { + "first": 87, + "second": 111, + "amount": -1 + }, + { + "first": 87, + "second": 112, + "amount": -1 + }, + { + "first": 87, + "second": 113, + "amount": -1 + }, + { + "first": 87, + "second": 114, + "amount": -1 + }, + { + "first": 87, + "second": 118, + "amount": 0 + }, + { + "first": 87, + "second": 119, + "amount": -1 + }, + { + "first": 87, + "second": 120, + "amount": -1 + }, + { + "first": 87, + "second": 121, + "amount": 0 + }, + { + "first": 87, + "second": 122, + "amount": 0 + }, + { + "first": 87, + "second": 169, + "amount": -1 + }, + { + "first": 87, + "second": 171, + "amount": -1 + }, + { + "first": 87, + "second": 174, + "amount": -1 + }, + { + "first": 87, + "second": 180, + "amount": -3 + }, + { + "first": 87, + "second": 181, + "amount": -1 + }, + { + "first": 87, + "second": 192, + "amount": -2 + }, + { + "first": 87, + "second": 193, + "amount": -2 + }, + { + "first": 87, + "second": 194, + "amount": -2 + }, + { + "first": 87, + "second": 195, + "amount": -2 + }, + { + "first": 87, + "second": 196, + "amount": -2 + }, + { + "first": 87, + "second": 197, + "amount": -2 + }, + { + "first": 87, + "second": 198, + "amount": -2 + }, + { + "first": 87, + "second": 199, + "amount": -1 + }, + { + "first": 87, + "second": 200, + "amount": -1 + }, + { + "first": 87, + "second": 201, + "amount": -1 + }, + { + "first": 87, + "second": 202, + "amount": -1 + }, + { + "first": 87, + "second": 203, + "amount": -1 + }, + { + "first": 87, + "second": 204, + "amount": -1 + }, + { + "first": 87, + "second": 205, + "amount": -1 + }, + { + "first": 87, + "second": 206, + "amount": -1 + }, + { + "first": 87, + "second": 207, + "amount": -1 + }, + { + "first": 87, + "second": 209, + "amount": -1 + }, + { + "first": 87, + "second": 210, + "amount": -1 + }, + { + "first": 87, + "second": 211, + "amount": -1 + }, + { + "first": 87, + "second": 212, + "amount": -1 + }, + { + "first": 87, + "second": 213, + "amount": -1 + }, + { + "first": 87, + "second": 214, + "amount": -1 + }, + { + "first": 87, + "second": 216, + "amount": -1 + }, + { + "first": 87, + "second": 222, + "amount": -1 + }, + { + "first": 87, + "second": 224, + "amount": -3 + }, + { + "first": 87, + "second": 225, + "amount": -3 + }, + { + "first": 87, + "second": 226, + "amount": -3 + }, + { + "first": 87, + "second": 227, + "amount": -3 + }, + { + "first": 87, + "second": 228, + "amount": -3 + }, + { + "first": 87, + "second": 229, + "amount": -3 + }, + { + "first": 87, + "second": 230, + "amount": -2 + }, + { + "first": 87, + "second": 231, + "amount": -1 + }, + { + "first": 87, + "second": 236, + "amount": 3 + }, + { + "first": 87, + "second": 237, + "amount": 3 + }, + { + "first": 87, + "second": 238, + "amount": 3 + }, + { + "first": 87, + "second": 239, + "amount": 3 + }, + { + "first": 87, + "second": 248, + "amount": -1 + }, + { + "first": 87, + "second": 249, + "amount": 0 + }, + { + "first": 87, + "second": 251, + "amount": 0 + }, + { + "first": 87, + "second": 252, + "amount": 0 + }, + { + "first": 87, + "second": 338, + "amount": -1 + }, + { + "first": 87, + "second": 339, + "amount": -1 + }, + { + "first": 88, + "second": 38, + "amount": -1 + }, + { + "first": 88, + "second": 45, + "amount": -1 + }, + { + "first": 88, + "second": 48, + "amount": -1 + }, + { + "first": 88, + "second": 51, + "amount": 0 + }, + { + "first": 88, + "second": 52, + "amount": 0 + }, + { + "first": 88, + "second": 54, + "amount": -1 + }, + { + "first": 88, + "second": 56, + "amount": 0 + }, + { + "first": 88, + "second": 57, + "amount": -1 + }, + { + "first": 88, + "second": 63, + "amount": 0 + }, + { + "first": 88, + "second": 64, + "amount": -2 + }, + { + "first": 88, + "second": 65, + "amount": -1 + }, + { + "first": 88, + "second": 66, + "amount": 0 + }, + { + "first": 88, + "second": 67, + "amount": -2 + }, + { + "first": 88, + "second": 68, + "amount": 0 + }, + { + "first": 88, + "second": 69, + "amount": 0 + }, + { + "first": 88, + "second": 70, + "amount": 0 + }, + { + "first": 88, + "second": 71, + "amount": -2 + }, + { + "first": 88, + "second": 72, + "amount": 0 + }, + { + "first": 88, + "second": 73, + "amount": 0 + }, + { + "first": 88, + "second": 74, + "amount": -1 + }, + { + "first": 88, + "second": 75, + "amount": 0 + }, + { + "first": 88, + "second": 76, + "amount": 0 + }, + { + "first": 88, + "second": 77, + "amount": 0 + }, + { + "first": 88, + "second": 78, + "amount": 0 + }, + { + "first": 88, + "second": 79, + "amount": -2 + }, + { + "first": 88, + "second": 80, + "amount": 0 + }, + { + "first": 88, + "second": 81, + "amount": -2 + }, + { + "first": 88, + "second": 82, + "amount": 0 + }, + { + "first": 88, + "second": 83, + "amount": -1 + }, + { + "first": 88, + "second": 84, + "amount": 0 + }, + { + "first": 88, + "second": 85, + "amount": 0 + }, + { + "first": 88, + "second": 86, + "amount": -1 + }, + { + "first": 88, + "second": 87, + "amount": 0 + }, + { + "first": 88, + "second": 89, + "amount": -1 + }, + { + "first": 88, + "second": 90, + "amount": 0 + }, + { + "first": 88, + "second": 97, + "amount": -1 + }, + { + "first": 88, + "second": 98, + "amount": 0 + }, + { + "first": 88, + "second": 99, + "amount": -1 + }, + { + "first": 88, + "second": 100, + "amount": -1 + }, + { + "first": 88, + "second": 101, + "amount": -1 + }, + { + "first": 88, + "second": 102, + "amount": -1 + }, + { + "first": 88, + "second": 103, + "amount": -1 + }, + { + "first": 88, + "second": 104, + "amount": 0 + }, + { + "first": 88, + "second": 105, + "amount": 0 + }, + { + "first": 88, + "second": 106, + "amount": 0 + }, + { + "first": 88, + "second": 107, + "amount": 0 + }, + { + "first": 88, + "second": 108, + "amount": 0 + }, + { + "first": 88, + "second": 109, + "amount": 0 + }, + { + "first": 88, + "second": 110, + "amount": 0 + }, + { + "first": 88, + "second": 111, + "amount": -1 + }, + { + "first": 88, + "second": 112, + "amount": 0 + }, + { + "first": 88, + "second": 113, + "amount": -1 + }, + { + "first": 88, + "second": 114, + "amount": 0 + }, + { + "first": 88, + "second": 115, + "amount": -1 + }, + { + "first": 88, + "second": 116, + "amount": -1 + }, + { + "first": 88, + "second": 117, + "amount": -1 + }, + { + "first": 88, + "second": 118, + "amount": -1 + }, + { + "first": 88, + "second": 119, + "amount": -1 + }, + { + "first": 88, + "second": 120, + "amount": 0 + }, + { + "first": 88, + "second": 121, + "amount": -1 + }, + { + "first": 88, + "second": 122, + "amount": 0 + }, + { + "first": 88, + "second": 169, + "amount": -2 + }, + { + "first": 88, + "second": 171, + "amount": -1 + }, + { + "first": 88, + "second": 174, + "amount": -2 + }, + { + "first": 88, + "second": 181, + "amount": 0 + }, + { + "first": 88, + "second": 192, + "amount": -1 + }, + { + "first": 88, + "second": 193, + "amount": -1 + }, + { + "first": 88, + "second": 194, + "amount": -1 + }, + { + "first": 88, + "second": 195, + "amount": -1 + }, + { + "first": 88, + "second": 196, + "amount": -1 + }, + { + "first": 88, + "second": 197, + "amount": -1 + }, + { + "first": 88, + "second": 199, + "amount": -2 + }, + { + "first": 88, + "second": 200, + "amount": 0 + }, + { + "first": 88, + "second": 201, + "amount": 0 + }, + { + "first": 88, + "second": 202, + "amount": 0 + }, + { + "first": 88, + "second": 203, + "amount": 0 + }, + { + "first": 88, + "second": 204, + "amount": 0 + }, + { + "first": 88, + "second": 205, + "amount": 0 + }, + { + "first": 88, + "second": 206, + "amount": 0 + }, + { + "first": 88, + "second": 207, + "amount": 0 + }, + { + "first": 88, + "second": 209, + "amount": 0 + }, + { + "first": 88, + "second": 210, + "amount": -2 + }, + { + "first": 88, + "second": 211, + "amount": -2 + }, + { + "first": 88, + "second": 212, + "amount": -2 + }, + { + "first": 88, + "second": 213, + "amount": -2 + }, + { + "first": 88, + "second": 214, + "amount": -2 + }, + { + "first": 88, + "second": 216, + "amount": -2 + }, + { + "first": 88, + "second": 217, + "amount": 0 + }, + { + "first": 88, + "second": 218, + "amount": 0 + }, + { + "first": 88, + "second": 219, + "amount": 0 + }, + { + "first": 88, + "second": 220, + "amount": 0 + }, + { + "first": 88, + "second": 221, + "amount": -1 + }, + { + "first": 88, + "second": 222, + "amount": 0 + }, + { + "first": 88, + "second": 223, + "amount": 0 + }, + { + "first": 88, + "second": 230, + "amount": -1 + }, + { + "first": 88, + "second": 231, + "amount": -1 + }, + { + "first": 88, + "second": 248, + "amount": -1 + }, + { + "first": 88, + "second": 250, + "amount": -1 + }, + { + "first": 88, + "second": 254, + "amount": 0 + }, + { + "first": 88, + "second": 338, + "amount": -2 + }, + { + "first": 88, + "second": 339, + "amount": -1 + }, + { + "first": 88, + "second": 376, + "amount": -1 + }, + { + "first": 89, + "second": 32, + "amount": -2 + }, + { + "first": 89, + "second": 38, + "amount": -3 + }, + { + "first": 89, + "second": 41, + "amount": -1 + }, + { + "first": 89, + "second": 44, + "amount": -5 + }, + { + "first": 89, + "second": 45, + "amount": -1 + }, + { + "first": 89, + "second": 46, + "amount": -5 + }, + { + "first": 89, + "second": 47, + "amount": -6 + }, + { + "first": 89, + "second": 48, + "amount": -2 + }, + { + "first": 89, + "second": 50, + "amount": -1 + }, + { + "first": 89, + "second": 51, + "amount": -1 + }, + { + "first": 89, + "second": 52, + "amount": -4 + }, + { + "first": 89, + "second": 53, + "amount": -1 + }, + { + "first": 89, + "second": 54, + "amount": -3 + }, + { + "first": 89, + "second": 55, + "amount": 0 + }, + { + "first": 89, + "second": 56, + "amount": -3 + }, + { + "first": 89, + "second": 57, + "amount": -2 + }, + { + "first": 89, + "second": 58, + "amount": -4 + }, + { + "first": 89, + "second": 59, + "amount": -4 + }, + { + "first": 89, + "second": 64, + "amount": -3 + }, + { + "first": 89, + "second": 65, + "amount": -5 + }, + { + "first": 89, + "second": 66, + "amount": -1 + }, + { + "first": 89, + "second": 67, + "amount": -3 + }, + { + "first": 89, + "second": 68, + "amount": -1 + }, + { + "first": 89, + "second": 69, + "amount": -1 + }, + { + "first": 89, + "second": 70, + "amount": -1 + }, + { + "first": 89, + "second": 71, + "amount": -3 + }, + { + "first": 89, + "second": 72, + "amount": -1 + }, + { + "first": 89, + "second": 73, + "amount": -1 + }, + { + "first": 89, + "second": 74, + "amount": -6 + }, + { + "first": 89, + "second": 75, + "amount": -1 + }, + { + "first": 89, + "second": 76, + "amount": -1 + }, + { + "first": 89, + "second": 77, + "amount": -1 + }, + { + "first": 89, + "second": 78, + "amount": -1 + }, + { + "first": 89, + "second": 79, + "amount": -3 + }, + { + "first": 89, + "second": 80, + "amount": -1 + }, + { + "first": 89, + "second": 81, + "amount": -3 + }, + { + "first": 89, + "second": 82, + "amount": -1 + }, + { + "first": 89, + "second": 83, + "amount": -1 + }, + { + "first": 89, + "second": 88, + "amount": -1 + }, + { + "first": 89, + "second": 97, + "amount": -4 + }, + { + "first": 89, + "second": 99, + "amount": -4 + }, + { + "first": 89, + "second": 100, + "amount": -4 + }, + { + "first": 89, + "second": 101, + "amount": -4 + }, + { + "first": 89, + "second": 102, + "amount": -1 + }, + { + "first": 89, + "second": 103, + "amount": -4 + }, + { + "first": 89, + "second": 109, + "amount": -1 + }, + { + "first": 89, + "second": 110, + "amount": -1 + }, + { + "first": 89, + "second": 111, + "amount": -4 + }, + { + "first": 89, + "second": 112, + "amount": -1 + }, + { + "first": 89, + "second": 113, + "amount": -4 + }, + { + "first": 89, + "second": 114, + "amount": -1 + }, + { + "first": 89, + "second": 115, + "amount": -3 + }, + { + "first": 89, + "second": 117, + "amount": -1 + }, + { + "first": 89, + "second": 118, + "amount": -2 + }, + { + "first": 89, + "second": 119, + "amount": -1 + }, + { + "first": 89, + "second": 120, + "amount": -3 + }, + { + "first": 89, + "second": 121, + "amount": -2 + }, + { + "first": 89, + "second": 122, + "amount": -2 + }, + { + "first": 89, + "second": 169, + "amount": -3 + }, + { + "first": 89, + "second": 171, + "amount": -4 + }, + { + "first": 89, + "second": 174, + "amount": -3 + }, + { + "first": 89, + "second": 180, + "amount": -3 + }, + { + "first": 89, + "second": 181, + "amount": -1 + }, + { + "first": 89, + "second": 187, + "amount": -1 + }, + { + "first": 89, + "second": 192, + "amount": -5 + }, + { + "first": 89, + "second": 193, + "amount": -5 + }, + { + "first": 89, + "second": 194, + "amount": -5 + }, + { + "first": 89, + "second": 195, + "amount": -5 + }, + { + "first": 89, + "second": 196, + "amount": -5 + }, + { + "first": 89, + "second": 197, + "amount": -5 + }, + { + "first": 89, + "second": 198, + "amount": -5 + }, + { + "first": 89, + "second": 199, + "amount": -3 + }, + { + "first": 89, + "second": 200, + "amount": -1 + }, + { + "first": 89, + "second": 201, + "amount": -1 + }, + { + "first": 89, + "second": 202, + "amount": -1 + }, + { + "first": 89, + "second": 203, + "amount": -1 + }, + { + "first": 89, + "second": 204, + "amount": -1 + }, + { + "first": 89, + "second": 205, + "amount": -1 + }, + { + "first": 89, + "second": 206, + "amount": -1 + }, + { + "first": 89, + "second": 207, + "amount": -1 + }, + { + "first": 89, + "second": 209, + "amount": -1 + }, + { + "first": 89, + "second": 210, + "amount": -3 + }, + { + "first": 89, + "second": 211, + "amount": -3 + }, + { + "first": 89, + "second": 212, + "amount": -3 + }, + { + "first": 89, + "second": 213, + "amount": -3 + }, + { + "first": 89, + "second": 214, + "amount": -3 + }, + { + "first": 89, + "second": 216, + "amount": -3 + }, + { + "first": 89, + "second": 222, + "amount": -1 + }, + { + "first": 89, + "second": 224, + "amount": -3 + }, + { + "first": 89, + "second": 225, + "amount": -3 + }, + { + "first": 89, + "second": 226, + "amount": -3 + }, + { + "first": 89, + "second": 227, + "amount": -3 + }, + { + "first": 89, + "second": 228, + "amount": -3 + }, + { + "first": 89, + "second": 229, + "amount": -3 + }, + { + "first": 89, + "second": 230, + "amount": -4 + }, + { + "first": 89, + "second": 231, + "amount": -4 + }, + { + "first": 89, + "second": 232, + "amount": -3 + }, + { + "first": 89, + "second": 233, + "amount": -3 + }, + { + "first": 89, + "second": 234, + "amount": -3 + }, + { + "first": 89, + "second": 235, + "amount": -3 + }, + { + "first": 89, + "second": 236, + "amount": 3 + }, + { + "first": 89, + "second": 237, + "amount": 3 + }, + { + "first": 89, + "second": 238, + "amount": 3 + }, + { + "first": 89, + "second": 239, + "amount": 3 + }, + { + "first": 89, + "second": 240, + "amount": -3 + }, + { + "first": 89, + "second": 241, + "amount": -1 + }, + { + "first": 89, + "second": 242, + "amount": -3 + }, + { + "first": 89, + "second": 243, + "amount": -3 + }, + { + "first": 89, + "second": 244, + "amount": -3 + }, + { + "first": 89, + "second": 245, + "amount": -3 + }, + { + "first": 89, + "second": 246, + "amount": -3 + }, + { + "first": 89, + "second": 248, + "amount": -4 + }, + { + "first": 89, + "second": 249, + "amount": -1 + }, + { + "first": 89, + "second": 250, + "amount": -1 + }, + { + "first": 89, + "second": 251, + "amount": -1 + }, + { + "first": 89, + "second": 252, + "amount": -1 + }, + { + "first": 89, + "second": 253, + "amount": -2 + }, + { + "first": 89, + "second": 255, + "amount": -2 + }, + { + "first": 89, + "second": 338, + "amount": -3 + }, + { + "first": 89, + "second": 339, + "amount": -4 + }, + { + "first": 90, + "second": 54, + "amount": 0 + }, + { + "first": 90, + "second": 56, + "amount": 0 + }, + { + "first": 90, + "second": 64, + "amount": -1 + }, + { + "first": 90, + "second": 67, + "amount": -1 + }, + { + "first": 90, + "second": 71, + "amount": -1 + }, + { + "first": 90, + "second": 79, + "amount": -1 + }, + { + "first": 90, + "second": 81, + "amount": -1 + }, + { + "first": 90, + "second": 83, + "amount": 0 + }, + { + "first": 90, + "second": 99, + "amount": 0 + }, + { + "first": 90, + "second": 100, + "amount": 0 + }, + { + "first": 90, + "second": 101, + "amount": 0 + }, + { + "first": 90, + "second": 102, + "amount": 0 + }, + { + "first": 90, + "second": 103, + "amount": 0 + }, + { + "first": 90, + "second": 111, + "amount": 0 + }, + { + "first": 90, + "second": 113, + "amount": 0 + }, + { + "first": 90, + "second": 115, + "amount": 0 + }, + { + "first": 90, + "second": 116, + "amount": 0 + }, + { + "first": 90, + "second": 118, + "amount": 0 + }, + { + "first": 90, + "second": 119, + "amount": -1 + }, + { + "first": 90, + "second": 120, + "amount": 0 + }, + { + "first": 90, + "second": 121, + "amount": 0 + }, + { + "first": 90, + "second": 169, + "amount": -1 + }, + { + "first": 90, + "second": 174, + "amount": -1 + }, + { + "first": 90, + "second": 199, + "amount": -1 + }, + { + "first": 90, + "second": 210, + "amount": -1 + }, + { + "first": 90, + "second": 211, + "amount": -1 + }, + { + "first": 90, + "second": 212, + "amount": -1 + }, + { + "first": 90, + "second": 213, + "amount": -1 + }, + { + "first": 90, + "second": 214, + "amount": -1 + }, + { + "first": 90, + "second": 216, + "amount": -1 + }, + { + "first": 90, + "second": 231, + "amount": 0 + }, + { + "first": 90, + "second": 248, + "amount": 0 + }, + { + "first": 90, + "second": 338, + "amount": -1 + }, + { + "first": 90, + "second": 339, + "amount": 0 + }, + { + "first": 91, + "second": 74, + "amount": -1 + }, + { + "first": 91, + "second": 106, + "amount": 4 + }, + { + "first": 92, + "second": 74, + "amount": -1 + }, + { + "first": 92, + "second": 83, + "amount": -1 + }, + { + "first": 92, + "second": 84, + "amount": -3 + }, + { + "first": 92, + "second": 85, + "amount": -2 + }, + { + "first": 92, + "second": 86, + "amount": -3 + }, + { + "first": 92, + "second": 88, + "amount": -1 + }, + { + "first": 92, + "second": 89, + "amount": -3 + }, + { + "first": 92, + "second": 97, + "amount": -1 + }, + { + "first": 92, + "second": 106, + "amount": 2 + }, + { + "first": 92, + "second": 120, + "amount": 0 + }, + { + "first": 92, + "second": 217, + "amount": -2 + }, + { + "first": 92, + "second": 218, + "amount": -2 + }, + { + "first": 92, + "second": 219, + "amount": -2 + }, + { + "first": 92, + "second": 220, + "amount": -2 + }, + { + "first": 92, + "second": 221, + "amount": -3 + }, + { + "first": 92, + "second": 230, + "amount": -1 + }, + { + "first": 92, + "second": 376, + "amount": -3 + }, + { + "first": 95, + "second": 106, + "amount": 3 + }, + { + "first": 97, + "second": 55, + "amount": -1 + }, + { + "first": 97, + "second": 84, + "amount": -2 + }, + { + "first": 97, + "second": 86, + "amount": -3 + }, + { + "first": 97, + "second": 87, + "amount": -1 + }, + { + "first": 97, + "second": 89, + "amount": -3 + }, + { + "first": 97, + "second": 92, + "amount": -1 + }, + { + "first": 97, + "second": 106, + "amount": 1 + }, + { + "first": 97, + "second": 118, + "amount": 0 + }, + { + "first": 97, + "second": 119, + "amount": 0 + }, + { + "first": 97, + "second": 121, + "amount": 0 + }, + { + "first": 97, + "second": 221, + "amount": -3 + }, + { + "first": 97, + "second": 253, + "amount": 0 + }, + { + "first": 97, + "second": 255, + "amount": 0 + }, + { + "first": 97, + "second": 376, + "amount": -3 + }, + { + "first": 98, + "second": 42, + "amount": -2 + }, + { + "first": 98, + "second": 44, + "amount": -1 + }, + { + "first": 98, + "second": 46, + "amount": -1 + }, + { + "first": 98, + "second": 55, + "amount": -2 + }, + { + "first": 98, + "second": 64, + "amount": 0 + }, + { + "first": 98, + "second": 65, + "amount": 0 + }, + { + "first": 98, + "second": 67, + "amount": 0 + }, + { + "first": 98, + "second": 71, + "amount": 0 + }, + { + "first": 98, + "second": 79, + "amount": 0 + }, + { + "first": 98, + "second": 81, + "amount": 0 + }, + { + "first": 98, + "second": 83, + "amount": 0 + }, + { + "first": 98, + "second": 84, + "amount": -4 + }, + { + "first": 98, + "second": 86, + "amount": -4 + }, + { + "first": 98, + "second": 87, + "amount": -1 + }, + { + "first": 98, + "second": 88, + "amount": -2 + }, + { + "first": 98, + "second": 89, + "amount": -4 + }, + { + "first": 98, + "second": 90, + "amount": 0 + }, + { + "first": 98, + "second": 102, + "amount": 0 + }, + { + "first": 98, + "second": 115, + "amount": 0 + }, + { + "first": 98, + "second": 116, + "amount": 0 + }, + { + "first": 98, + "second": 118, + "amount": 0 + }, + { + "first": 98, + "second": 119, + "amount": 0 + }, + { + "first": 98, + "second": 120, + "amount": -1 + }, + { + "first": 98, + "second": 121, + "amount": 0 + }, + { + "first": 98, + "second": 122, + "amount": 0 + }, + { + "first": 98, + "second": 169, + "amount": 0 + }, + { + "first": 98, + "second": 174, + "amount": 0 + }, + { + "first": 98, + "second": 192, + "amount": 0 + }, + { + "first": 98, + "second": 193, + "amount": 0 + }, + { + "first": 98, + "second": 194, + "amount": 0 + }, + { + "first": 98, + "second": 195, + "amount": 0 + }, + { + "first": 98, + "second": 196, + "amount": 0 + }, + { + "first": 98, + "second": 197, + "amount": 0 + }, + { + "first": 98, + "second": 199, + "amount": 0 + }, + { + "first": 98, + "second": 210, + "amount": 0 + }, + { + "first": 98, + "second": 211, + "amount": 0 + }, + { + "first": 98, + "second": 212, + "amount": 0 + }, + { + "first": 98, + "second": 213, + "amount": 0 + }, + { + "first": 98, + "second": 214, + "amount": 0 + }, + { + "first": 98, + "second": 216, + "amount": 0 + }, + { + "first": 98, + "second": 221, + "amount": -4 + }, + { + "first": 98, + "second": 338, + "amount": 0 + }, + { + "first": 98, + "second": 376, + "amount": -4 + }, + { + "first": 99, + "second": 47, + "amount": -1 + }, + { + "first": 99, + "second": 64, + "amount": 0 + }, + { + "first": 99, + "second": 67, + "amount": 0 + }, + { + "first": 99, + "second": 71, + "amount": 0 + }, + { + "first": 99, + "second": 79, + "amount": 0 + }, + { + "first": 99, + "second": 81, + "amount": 0 + }, + { + "first": 99, + "second": 84, + "amount": -4 + }, + { + "first": 99, + "second": 85, + "amount": 0 + }, + { + "first": 99, + "second": 86, + "amount": -2 + }, + { + "first": 99, + "second": 87, + "amount": -1 + }, + { + "first": 99, + "second": 88, + "amount": -1 + }, + { + "first": 99, + "second": 89, + "amount": -2 + }, + { + "first": 99, + "second": 90, + "amount": 0 + }, + { + "first": 99, + "second": 92, + "amount": -1 + }, + { + "first": 99, + "second": 97, + "amount": 0 + }, + { + "first": 99, + "second": 99, + "amount": 0 + }, + { + "first": 99, + "second": 100, + "amount": 0 + }, + { + "first": 99, + "second": 101, + "amount": 0 + }, + { + "first": 99, + "second": 103, + "amount": 0 + }, + { + "first": 99, + "second": 111, + "amount": 0 + }, + { + "first": 99, + "second": 113, + "amount": 0 + }, + { + "first": 99, + "second": 115, + "amount": 0 + }, + { + "first": 99, + "second": 116, + "amount": 0 + }, + { + "first": 99, + "second": 117, + "amount": 0 + }, + { + "first": 99, + "second": 118, + "amount": 0 + }, + { + "first": 99, + "second": 119, + "amount": 0 + }, + { + "first": 99, + "second": 120, + "amount": -1 + }, + { + "first": 99, + "second": 121, + "amount": 0 + }, + { + "first": 99, + "second": 122, + "amount": 0 + }, + { + "first": 99, + "second": 169, + "amount": 0 + }, + { + "first": 99, + "second": 171, + "amount": 0 + }, + { + "first": 99, + "second": 174, + "amount": 0 + }, + { + "first": 99, + "second": 180, + "amount": 0 + }, + { + "first": 99, + "second": 187, + "amount": 0 + }, + { + "first": 99, + "second": 199, + "amount": 0 + }, + { + "first": 99, + "second": 210, + "amount": 0 + }, + { + "first": 99, + "second": 211, + "amount": 0 + }, + { + "first": 99, + "second": 212, + "amount": 0 + }, + { + "first": 99, + "second": 213, + "amount": 0 + }, + { + "first": 99, + "second": 214, + "amount": 0 + }, + { + "first": 99, + "second": 216, + "amount": 0 + }, + { + "first": 99, + "second": 217, + "amount": 0 + }, + { + "first": 99, + "second": 218, + "amount": 0 + }, + { + "first": 99, + "second": 219, + "amount": 0 + }, + { + "first": 99, + "second": 220, + "amount": 0 + }, + { + "first": 99, + "second": 221, + "amount": -2 + }, + { + "first": 99, + "second": 224, + "amount": 0 + }, + { + "first": 99, + "second": 225, + "amount": 0 + }, + { + "first": 99, + "second": 226, + "amount": 0 + }, + { + "first": 99, + "second": 227, + "amount": 0 + }, + { + "first": 99, + "second": 228, + "amount": 0 + }, + { + "first": 99, + "second": 229, + "amount": 0 + }, + { + "first": 99, + "second": 230, + "amount": 0 + }, + { + "first": 99, + "second": 231, + "amount": 0 + }, + { + "first": 99, + "second": 248, + "amount": 0 + }, + { + "first": 99, + "second": 249, + "amount": 0 + }, + { + "first": 99, + "second": 250, + "amount": 0 + }, + { + "first": 99, + "second": 251, + "amount": 0 + }, + { + "first": 99, + "second": 252, + "amount": 0 + }, + { + "first": 99, + "second": 338, + "amount": 0 + }, + { + "first": 99, + "second": 339, + "amount": 0 + }, + { + "first": 99, + "second": 376, + "amount": -2 + }, + { + "first": 100, + "second": 88, + "amount": 0 + }, + { + "first": 101, + "second": 42, + "amount": -2 + }, + { + "first": 101, + "second": 47, + "amount": -2 + }, + { + "first": 101, + "second": 64, + "amount": 0 + }, + { + "first": 101, + "second": 67, + "amount": 0 + }, + { + "first": 101, + "second": 71, + "amount": 0 + }, + { + "first": 101, + "second": 79, + "amount": 0 + }, + { + "first": 101, + "second": 81, + "amount": 0 + }, + { + "first": 101, + "second": 84, + "amount": -4 + }, + { + "first": 101, + "second": 86, + "amount": -2 + }, + { + "first": 101, + "second": 87, + "amount": -1 + }, + { + "first": 101, + "second": 88, + "amount": -1 + }, + { + "first": 101, + "second": 89, + "amount": -2 + }, + { + "first": 101, + "second": 90, + "amount": 0 + }, + { + "first": 101, + "second": 92, + "amount": -1 + }, + { + "first": 101, + "second": 102, + "amount": 0 + }, + { + "first": 101, + "second": 115, + "amount": 0 + }, + { + "first": 101, + "second": 116, + "amount": 0 + }, + { + "first": 101, + "second": 118, + "amount": 0 + }, + { + "first": 101, + "second": 119, + "amount": 0 + }, + { + "first": 101, + "second": 120, + "amount": -1 + }, + { + "first": 101, + "second": 121, + "amount": 0 + }, + { + "first": 101, + "second": 122, + "amount": 0 + }, + { + "first": 101, + "second": 169, + "amount": 0 + }, + { + "first": 101, + "second": 174, + "amount": 0 + }, + { + "first": 101, + "second": 199, + "amount": 0 + }, + { + "first": 101, + "second": 210, + "amount": 0 + }, + { + "first": 101, + "second": 211, + "amount": 0 + }, + { + "first": 101, + "second": 212, + "amount": 0 + }, + { + "first": 101, + "second": 213, + "amount": 0 + }, + { + "first": 101, + "second": 214, + "amount": 0 + }, + { + "first": 101, + "second": 216, + "amount": 0 + }, + { + "first": 101, + "second": 221, + "amount": -2 + }, + { + "first": 101, + "second": 338, + "amount": 0 + }, + { + "first": 101, + "second": 376, + "amount": -2 + }, + { + "first": 102, + "second": 41, + "amount": 0 + }, + { + "first": 102, + "second": 42, + "amount": 0 + }, + { + "first": 102, + "second": 44, + "amount": 0 + }, + { + "first": 102, + "second": 46, + "amount": 0 + }, + { + "first": 102, + "second": 63, + "amount": 2 + }, + { + "first": 102, + "second": 84, + "amount": 2 + }, + { + "first": 102, + "second": 86, + "amount": 1 + }, + { + "first": 102, + "second": 87, + "amount": 0 + }, + { + "first": 102, + "second": 89, + "amount": 1 + }, + { + "first": 102, + "second": 92, + "amount": 3 + }, + { + "first": 102, + "second": 99, + "amount": 0 + }, + { + "first": 102, + "second": 100, + "amount": 0 + }, + { + "first": 102, + "second": 101, + "amount": 0 + }, + { + "first": 102, + "second": 102, + "amount": 0 + }, + { + "first": 102, + "second": 103, + "amount": 0 + }, + { + "first": 102, + "second": 111, + "amount": 0 + }, + { + "first": 102, + "second": 113, + "amount": 0 + }, + { + "first": 102, + "second": 116, + "amount": 0 + }, + { + "first": 102, + "second": 118, + "amount": 0 + }, + { + "first": 102, + "second": 119, + "amount": 0 + }, + { + "first": 102, + "second": 120, + "amount": 0 + }, + { + "first": 102, + "second": 121, + "amount": 0 + }, + { + "first": 102, + "second": 187, + "amount": 0 + }, + { + "first": 102, + "second": 221, + "amount": 1 + }, + { + "first": 102, + "second": 231, + "amount": 0 + }, + { + "first": 102, + "second": 232, + "amount": 0 + }, + { + "first": 102, + "second": 233, + "amount": 0 + }, + { + "first": 102, + "second": 234, + "amount": 0 + }, + { + "first": 102, + "second": 235, + "amount": 0 + }, + { + "first": 102, + "second": 236, + "amount": 2 + }, + { + "first": 102, + "second": 237, + "amount": 2 + }, + { + "first": 102, + "second": 238, + "amount": 2 + }, + { + "first": 102, + "second": 239, + "amount": 2 + }, + { + "first": 102, + "second": 240, + "amount": 0 + }, + { + "first": 102, + "second": 242, + "amount": 0 + }, + { + "first": 102, + "second": 243, + "amount": 0 + }, + { + "first": 102, + "second": 244, + "amount": 0 + }, + { + "first": 102, + "second": 245, + "amount": 0 + }, + { + "first": 102, + "second": 246, + "amount": 0 + }, + { + "first": 102, + "second": 248, + "amount": 0 + }, + { + "first": 102, + "second": 339, + "amount": 0 + }, + { + "first": 102, + "second": 376, + "amount": 1 + }, + { + "first": 103, + "second": 55, + "amount": -2 + }, + { + "first": 103, + "second": 84, + "amount": -2 + }, + { + "first": 103, + "second": 86, + "amount": -1 + }, + { + "first": 103, + "second": 87, + "amount": -1 + }, + { + "first": 103, + "second": 88, + "amount": 0 + }, + { + "first": 103, + "second": 89, + "amount": -1 + }, + { + "first": 103, + "second": 106, + "amount": 2 + }, + { + "first": 103, + "second": 221, + "amount": -1 + }, + { + "first": 103, + "second": 376, + "amount": -1 + }, + { + "first": 104, + "second": 55, + "amount": -1 + }, + { + "first": 104, + "second": 84, + "amount": -2 + }, + { + "first": 104, + "second": 86, + "amount": -3 + }, + { + "first": 104, + "second": 87, + "amount": -1 + }, + { + "first": 104, + "second": 89, + "amount": -3 + }, + { + "first": 104, + "second": 92, + "amount": -1 + }, + { + "first": 104, + "second": 106, + "amount": 1 + }, + { + "first": 104, + "second": 118, + "amount": 0 + }, + { + "first": 104, + "second": 119, + "amount": 0 + }, + { + "first": 104, + "second": 121, + "amount": 0 + }, + { + "first": 104, + "second": 221, + "amount": -3 + }, + { + "first": 104, + "second": 253, + "amount": 0 + }, + { + "first": 104, + "second": 255, + "amount": 0 + }, + { + "first": 104, + "second": 376, + "amount": -3 + }, + { + "first": 105, + "second": 88, + "amount": 0 + }, + { + "first": 107, + "second": 38, + "amount": -1 + }, + { + "first": 107, + "second": 42, + "amount": -1 + }, + { + "first": 107, + "second": 45, + "amount": -1 + }, + { + "first": 107, + "second": 48, + "amount": 0 + }, + { + "first": 107, + "second": 51, + "amount": 0 + }, + { + "first": 107, + "second": 53, + "amount": 0 + }, + { + "first": 107, + "second": 54, + "amount": 0 + }, + { + "first": 107, + "second": 55, + "amount": -1 + }, + { + "first": 107, + "second": 56, + "amount": 0 + }, + { + "first": 107, + "second": 57, + "amount": 0 + }, + { + "first": 107, + "second": 64, + "amount": -1 + }, + { + "first": 107, + "second": 67, + "amount": -1 + }, + { + "first": 107, + "second": 71, + "amount": -1 + }, + { + "first": 107, + "second": 74, + "amount": 0 + }, + { + "first": 107, + "second": 79, + "amount": -1 + }, + { + "first": 107, + "second": 81, + "amount": -1 + }, + { + "first": 107, + "second": 83, + "amount": -1 + }, + { + "first": 107, + "second": 84, + "amount": -2 + }, + { + "first": 107, + "second": 85, + "amount": 0 + }, + { + "first": 107, + "second": 86, + "amount": -3 + }, + { + "first": 107, + "second": 87, + "amount": -1 + }, + { + "first": 107, + "second": 88, + "amount": 0 + }, + { + "first": 107, + "second": 89, + "amount": -3 + }, + { + "first": 107, + "second": 90, + "amount": 0 + }, + { + "first": 107, + "second": 92, + "amount": -1 + }, + { + "first": 107, + "second": 97, + "amount": -1 + }, + { + "first": 107, + "second": 99, + "amount": 0 + }, + { + "first": 107, + "second": 100, + "amount": 0 + }, + { + "first": 107, + "second": 101, + "amount": 0 + }, + { + "first": 107, + "second": 102, + "amount": 0 + }, + { + "first": 107, + "second": 103, + "amount": 0 + }, + { + "first": 107, + "second": 111, + "amount": 0 + }, + { + "first": 107, + "second": 113, + "amount": 0 + }, + { + "first": 107, + "second": 115, + "amount": 0 + }, + { + "first": 107, + "second": 116, + "amount": 0 + }, + { + "first": 107, + "second": 117, + "amount": -1 + }, + { + "first": 107, + "second": 118, + "amount": 0 + }, + { + "first": 107, + "second": 119, + "amount": -1 + }, + { + "first": 107, + "second": 121, + "amount": 0 + }, + { + "first": 107, + "second": 169, + "amount": -1 + }, + { + "first": 107, + "second": 171, + "amount": -1 + }, + { + "first": 107, + "second": 174, + "amount": -1 + }, + { + "first": 107, + "second": 180, + "amount": -1 + }, + { + "first": 107, + "second": 199, + "amount": -1 + }, + { + "first": 107, + "second": 210, + "amount": -1 + }, + { + "first": 107, + "second": 211, + "amount": -1 + }, + { + "first": 107, + "second": 212, + "amount": -1 + }, + { + "first": 107, + "second": 213, + "amount": -1 + }, + { + "first": 107, + "second": 214, + "amount": -1 + }, + { + "first": 107, + "second": 216, + "amount": -1 + }, + { + "first": 107, + "second": 217, + "amount": 0 + }, + { + "first": 107, + "second": 218, + "amount": 0 + }, + { + "first": 107, + "second": 219, + "amount": 0 + }, + { + "first": 107, + "second": 220, + "amount": 0 + }, + { + "first": 107, + "second": 221, + "amount": -3 + }, + { + "first": 107, + "second": 224, + "amount": -1 + }, + { + "first": 107, + "second": 225, + "amount": -1 + }, + { + "first": 107, + "second": 226, + "amount": -1 + }, + { + "first": 107, + "second": 227, + "amount": -1 + }, + { + "first": 107, + "second": 228, + "amount": -1 + }, + { + "first": 107, + "second": 229, + "amount": -1 + }, + { + "first": 107, + "second": 230, + "amount": -1 + }, + { + "first": 107, + "second": 231, + "amount": 0 + }, + { + "first": 107, + "second": 248, + "amount": 0 + }, + { + "first": 107, + "second": 249, + "amount": 0 + }, + { + "first": 107, + "second": 250, + "amount": -1 + }, + { + "first": 107, + "second": 251, + "amount": 0 + }, + { + "first": 107, + "second": 252, + "amount": 0 + }, + { + "first": 107, + "second": 338, + "amount": -1 + }, + { + "first": 107, + "second": 339, + "amount": 0 + }, + { + "first": 107, + "second": 376, + "amount": -3 + }, + { + "first": 108, + "second": 88, + "amount": 0 + }, + { + "first": 109, + "second": 55, + "amount": -1 + }, + { + "first": 109, + "second": 84, + "amount": -2 + }, + { + "first": 109, + "second": 86, + "amount": -3 + }, + { + "first": 109, + "second": 87, + "amount": -1 + }, + { + "first": 109, + "second": 89, + "amount": -3 + }, + { + "first": 109, + "second": 92, + "amount": -1 + }, + { + "first": 109, + "second": 106, + "amount": 1 + }, + { + "first": 109, + "second": 118, + "amount": 0 + }, + { + "first": 109, + "second": 119, + "amount": 0 + }, + { + "first": 109, + "second": 121, + "amount": 0 + }, + { + "first": 109, + "second": 221, + "amount": -3 + }, + { + "first": 109, + "second": 253, + "amount": 0 + }, + { + "first": 109, + "second": 255, + "amount": 0 + }, + { + "first": 109, + "second": 376, + "amount": -3 + }, + { + "first": 110, + "second": 55, + "amount": -1 + }, + { + "first": 110, + "second": 84, + "amount": -2 + }, + { + "first": 110, + "second": 86, + "amount": -3 + }, + { + "first": 110, + "second": 87, + "amount": -1 + }, + { + "first": 110, + "second": 89, + "amount": -3 + }, + { + "first": 110, + "second": 92, + "amount": -1 + }, + { + "first": 110, + "second": 106, + "amount": 1 + }, + { + "first": 110, + "second": 118, + "amount": 0 + }, + { + "first": 110, + "second": 119, + "amount": 0 + }, + { + "first": 110, + "second": 121, + "amount": 0 + }, + { + "first": 110, + "second": 221, + "amount": -3 + }, + { + "first": 110, + "second": 253, + "amount": 0 + }, + { + "first": 110, + "second": 255, + "amount": 0 + }, + { + "first": 110, + "second": 376, + "amount": -3 + }, + { + "first": 111, + "second": 42, + "amount": -2 + }, + { + "first": 111, + "second": 44, + "amount": -1 + }, + { + "first": 111, + "second": 46, + "amount": -1 + }, + { + "first": 111, + "second": 55, + "amount": -2 + }, + { + "first": 111, + "second": 64, + "amount": 0 + }, + { + "first": 111, + "second": 65, + "amount": 0 + }, + { + "first": 111, + "second": 67, + "amount": 0 + }, + { + "first": 111, + "second": 71, + "amount": 0 + }, + { + "first": 111, + "second": 79, + "amount": 0 + }, + { + "first": 111, + "second": 81, + "amount": 0 + }, + { + "first": 111, + "second": 83, + "amount": 0 + }, + { + "first": 111, + "second": 84, + "amount": -4 + }, + { + "first": 111, + "second": 86, + "amount": -4 + }, + { + "first": 111, + "second": 87, + "amount": -1 + }, + { + "first": 111, + "second": 88, + "amount": -2 + }, + { + "first": 111, + "second": 89, + "amount": -4 + }, + { + "first": 111, + "second": 90, + "amount": 0 + }, + { + "first": 111, + "second": 102, + "amount": 0 + }, + { + "first": 111, + "second": 115, + "amount": 0 + }, + { + "first": 111, + "second": 116, + "amount": 0 + }, + { + "first": 111, + "second": 118, + "amount": 0 + }, + { + "first": 111, + "second": 119, + "amount": 0 + }, + { + "first": 111, + "second": 120, + "amount": -1 + }, + { + "first": 111, + "second": 121, + "amount": 0 + }, + { + "first": 111, + "second": 122, + "amount": 0 + }, + { + "first": 111, + "second": 169, + "amount": 0 + }, + { + "first": 111, + "second": 174, + "amount": 0 + }, + { + "first": 111, + "second": 192, + "amount": 0 + }, + { + "first": 111, + "second": 193, + "amount": 0 + }, + { + "first": 111, + "second": 194, + "amount": 0 + }, + { + "first": 111, + "second": 195, + "amount": 0 + }, + { + "first": 111, + "second": 196, + "amount": 0 + }, + { + "first": 111, + "second": 197, + "amount": 0 + }, + { + "first": 111, + "second": 199, + "amount": 0 + }, + { + "first": 111, + "second": 210, + "amount": 0 + }, + { + "first": 111, + "second": 211, + "amount": 0 + }, + { + "first": 111, + "second": 212, + "amount": 0 + }, + { + "first": 111, + "second": 213, + "amount": 0 + }, + { + "first": 111, + "second": 214, + "amount": 0 + }, + { + "first": 111, + "second": 216, + "amount": 0 + }, + { + "first": 111, + "second": 221, + "amount": -4 + }, + { + "first": 111, + "second": 338, + "amount": 0 + }, + { + "first": 111, + "second": 376, + "amount": -4 + }, + { + "first": 112, + "second": 42, + "amount": -2 + }, + { + "first": 112, + "second": 44, + "amount": -1 + }, + { + "first": 112, + "second": 46, + "amount": -1 + }, + { + "first": 112, + "second": 55, + "amount": -2 + }, + { + "first": 112, + "second": 64, + "amount": 0 + }, + { + "first": 112, + "second": 65, + "amount": 0 + }, + { + "first": 112, + "second": 67, + "amount": 0 + }, + { + "first": 112, + "second": 71, + "amount": 0 + }, + { + "first": 112, + "second": 79, + "amount": 0 + }, + { + "first": 112, + "second": 81, + "amount": 0 + }, + { + "first": 112, + "second": 83, + "amount": 0 + }, + { + "first": 112, + "second": 84, + "amount": -4 + }, + { + "first": 112, + "second": 86, + "amount": -4 + }, + { + "first": 112, + "second": 87, + "amount": -1 + }, + { + "first": 112, + "second": 88, + "amount": -2 + }, + { + "first": 112, + "second": 89, + "amount": -4 + }, + { + "first": 112, + "second": 90, + "amount": 0 + }, + { + "first": 112, + "second": 102, + "amount": 0 + }, + { + "first": 112, + "second": 115, + "amount": 0 + }, + { + "first": 112, + "second": 116, + "amount": 0 + }, + { + "first": 112, + "second": 118, + "amount": 0 + }, + { + "first": 112, + "second": 119, + "amount": 0 + }, + { + "first": 112, + "second": 120, + "amount": -1 + }, + { + "first": 112, + "second": 121, + "amount": 0 + }, + { + "first": 112, + "second": 122, + "amount": 0 + }, + { + "first": 112, + "second": 169, + "amount": 0 + }, + { + "first": 112, + "second": 174, + "amount": 0 + }, + { + "first": 112, + "second": 192, + "amount": 0 + }, + { + "first": 112, + "second": 193, + "amount": 0 + }, + { + "first": 112, + "second": 194, + "amount": 0 + }, + { + "first": 112, + "second": 195, + "amount": 0 + }, + { + "first": 112, + "second": 196, + "amount": 0 + }, + { + "first": 112, + "second": 197, + "amount": 0 + }, + { + "first": 112, + "second": 199, + "amount": 0 + }, + { + "first": 112, + "second": 210, + "amount": 0 + }, + { + "first": 112, + "second": 211, + "amount": 0 + }, + { + "first": 112, + "second": 212, + "amount": 0 + }, + { + "first": 112, + "second": 213, + "amount": 0 + }, + { + "first": 112, + "second": 214, + "amount": 0 + }, + { + "first": 112, + "second": 216, + "amount": 0 + }, + { + "first": 112, + "second": 221, + "amount": -4 + }, + { + "first": 112, + "second": 338, + "amount": 0 + }, + { + "first": 112, + "second": 376, + "amount": -4 + }, + { + "first": 113, + "second": 55, + "amount": -2 + }, + { + "first": 113, + "second": 84, + "amount": -2 + }, + { + "first": 113, + "second": 86, + "amount": -1 + }, + { + "first": 113, + "second": 87, + "amount": -1 + }, + { + "first": 113, + "second": 88, + "amount": 0 + }, + { + "first": 113, + "second": 89, + "amount": -1 + }, + { + "first": 113, + "second": 106, + "amount": 2 + }, + { + "first": 113, + "second": 221, + "amount": -1 + }, + { + "first": 113, + "second": 376, + "amount": -1 + }, + { + "first": 114, + "second": 38, + "amount": -2 + }, + { + "first": 114, + "second": 45, + "amount": 0 + }, + { + "first": 114, + "second": 50, + "amount": -1 + }, + { + "first": 114, + "second": 51, + "amount": -1 + }, + { + "first": 114, + "second": 55, + "amount": -1 + }, + { + "first": 114, + "second": 56, + "amount": 0 + }, + { + "first": 114, + "second": 64, + "amount": -1 + }, + { + "first": 114, + "second": 65, + "amount": -2 + }, + { + "first": 114, + "second": 67, + "amount": -1 + }, + { + "first": 114, + "second": 71, + "amount": -1 + }, + { + "first": 114, + "second": 74, + "amount": -4 + }, + { + "first": 114, + "second": 79, + "amount": -1 + }, + { + "first": 114, + "second": 81, + "amount": -1 + }, + { + "first": 114, + "second": 83, + "amount": 0 + }, + { + "first": 114, + "second": 84, + "amount": -1 + }, + { + "first": 114, + "second": 86, + "amount": -1 + }, + { + "first": 114, + "second": 87, + "amount": 0 + }, + { + "first": 114, + "second": 88, + "amount": -2 + }, + { + "first": 114, + "second": 89, + "amount": -1 + }, + { + "first": 114, + "second": 90, + "amount": -1 + }, + { + "first": 114, + "second": 97, + "amount": -1 + }, + { + "first": 114, + "second": 99, + "amount": 0 + }, + { + "first": 114, + "second": 100, + "amount": 0 + }, + { + "first": 114, + "second": 101, + "amount": 0 + }, + { + "first": 114, + "second": 102, + "amount": 0 + }, + { + "first": 114, + "second": 103, + "amount": 0 + }, + { + "first": 114, + "second": 109, + "amount": 0 + }, + { + "first": 114, + "second": 110, + "amount": 0 + }, + { + "first": 114, + "second": 111, + "amount": 0 + }, + { + "first": 114, + "second": 112, + "amount": 0 + }, + { + "first": 114, + "second": 113, + "amount": 0 + }, + { + "first": 114, + "second": 114, + "amount": 0 + }, + { + "first": 114, + "second": 115, + "amount": 0 + }, + { + "first": 114, + "second": 116, + "amount": 1 + }, + { + "first": 114, + "second": 117, + "amount": 0 + }, + { + "first": 114, + "second": 118, + "amount": 0 + }, + { + "first": 114, + "second": 119, + "amount": 0 + }, + { + "first": 114, + "second": 120, + "amount": 0 + }, + { + "first": 114, + "second": 121, + "amount": 0 + }, + { + "first": 114, + "second": 169, + "amount": -1 + }, + { + "first": 114, + "second": 171, + "amount": -1 + }, + { + "first": 114, + "second": 174, + "amount": -1 + }, + { + "first": 114, + "second": 180, + "amount": -1 + }, + { + "first": 114, + "second": 181, + "amount": 0 + }, + { + "first": 114, + "second": 187, + "amount": 0 + }, + { + "first": 114, + "second": 192, + "amount": -2 + }, + { + "first": 114, + "second": 193, + "amount": -2 + }, + { + "first": 114, + "second": 194, + "amount": -2 + }, + { + "first": 114, + "second": 195, + "amount": -2 + }, + { + "first": 114, + "second": 196, + "amount": -2 + }, + { + "first": 114, + "second": 197, + "amount": -2 + }, + { + "first": 114, + "second": 199, + "amount": -1 + }, + { + "first": 114, + "second": 210, + "amount": -1 + }, + { + "first": 114, + "second": 211, + "amount": -1 + }, + { + "first": 114, + "second": 212, + "amount": -1 + }, + { + "first": 114, + "second": 213, + "amount": -1 + }, + { + "first": 114, + "second": 214, + "amount": -1 + }, + { + "first": 114, + "second": 216, + "amount": -1 + }, + { + "first": 114, + "second": 221, + "amount": -1 + }, + { + "first": 114, + "second": 224, + "amount": -1 + }, + { + "first": 114, + "second": 225, + "amount": -1 + }, + { + "first": 114, + "second": 226, + "amount": -1 + }, + { + "first": 114, + "second": 227, + "amount": -1 + }, + { + "first": 114, + "second": 228, + "amount": -1 + }, + { + "first": 114, + "second": 229, + "amount": -1 + }, + { + "first": 114, + "second": 230, + "amount": -1 + }, + { + "first": 114, + "second": 231, + "amount": 0 + }, + { + "first": 114, + "second": 248, + "amount": 0 + }, + { + "first": 114, + "second": 250, + "amount": 0 + }, + { + "first": 114, + "second": 338, + "amount": -1 + }, + { + "first": 114, + "second": 339, + "amount": 0 + }, + { + "first": 114, + "second": 376, + "amount": -1 + }, + { + "first": 115, + "second": 42, + "amount": -1 + }, + { + "first": 115, + "second": 48, + "amount": 0 + }, + { + "first": 115, + "second": 52, + "amount": 0 + }, + { + "first": 115, + "second": 55, + "amount": -2 + }, + { + "first": 115, + "second": 57, + "amount": -1 + }, + { + "first": 115, + "second": 64, + "amount": 0 + }, + { + "first": 115, + "second": 65, + "amount": 0 + }, + { + "first": 115, + "second": 67, + "amount": 0 + }, + { + "first": 115, + "second": 71, + "amount": 0 + }, + { + "first": 115, + "second": 79, + "amount": 0 + }, + { + "first": 115, + "second": 81, + "amount": 0 + }, + { + "first": 115, + "second": 84, + "amount": -3 + }, + { + "first": 115, + "second": 85, + "amount": 0 + }, + { + "first": 115, + "second": 86, + "amount": -4 + }, + { + "first": 115, + "second": 88, + "amount": -1 + }, + { + "first": 115, + "second": 89, + "amount": -4 + }, + { + "first": 115, + "second": 90, + "amount": 0 + }, + { + "first": 115, + "second": 97, + "amount": 0 + }, + { + "first": 115, + "second": 99, + "amount": 0 + }, + { + "first": 115, + "second": 100, + "amount": 0 + }, + { + "first": 115, + "second": 101, + "amount": 0 + }, + { + "first": 115, + "second": 102, + "amount": 0 + }, + { + "first": 115, + "second": 103, + "amount": 0 + }, + { + "first": 115, + "second": 111, + "amount": 0 + }, + { + "first": 115, + "second": 113, + "amount": 0 + }, + { + "first": 115, + "second": 116, + "amount": 0 + }, + { + "first": 115, + "second": 118, + "amount": 0 + }, + { + "first": 115, + "second": 119, + "amount": 0 + }, + { + "first": 115, + "second": 120, + "amount": 0 + }, + { + "first": 115, + "second": 121, + "amount": 0 + }, + { + "first": 115, + "second": 122, + "amount": 0 + }, + { + "first": 115, + "second": 169, + "amount": 0 + }, + { + "first": 115, + "second": 174, + "amount": 0 + }, + { + "first": 115, + "second": 180, + "amount": 0 + }, + { + "first": 115, + "second": 192, + "amount": 0 + }, + { + "first": 115, + "second": 193, + "amount": 0 + }, + { + "first": 115, + "second": 194, + "amount": 0 + }, + { + "first": 115, + "second": 195, + "amount": 0 + }, + { + "first": 115, + "second": 196, + "amount": 0 + }, + { + "first": 115, + "second": 197, + "amount": 0 + }, + { + "first": 115, + "second": 199, + "amount": 0 + }, + { + "first": 115, + "second": 210, + "amount": 0 + }, + { + "first": 115, + "second": 211, + "amount": 0 + }, + { + "first": 115, + "second": 212, + "amount": 0 + }, + { + "first": 115, + "second": 213, + "amount": 0 + }, + { + "first": 115, + "second": 214, + "amount": 0 + }, + { + "first": 115, + "second": 216, + "amount": 0 + }, + { + "first": 115, + "second": 217, + "amount": 0 + }, + { + "first": 115, + "second": 218, + "amount": 0 + }, + { + "first": 115, + "second": 219, + "amount": 0 + }, + { + "first": 115, + "second": 220, + "amount": 0 + }, + { + "first": 115, + "second": 221, + "amount": -4 + }, + { + "first": 115, + "second": 224, + "amount": 0 + }, + { + "first": 115, + "second": 225, + "amount": 0 + }, + { + "first": 115, + "second": 226, + "amount": 0 + }, + { + "first": 115, + "second": 227, + "amount": 0 + }, + { + "first": 115, + "second": 228, + "amount": 0 + }, + { + "first": 115, + "second": 229, + "amount": 0 + }, + { + "first": 115, + "second": 230, + "amount": 0 + }, + { + "first": 115, + "second": 231, + "amount": 0 + }, + { + "first": 115, + "second": 248, + "amount": 0 + }, + { + "first": 115, + "second": 338, + "amount": 0 + }, + { + "first": 115, + "second": 339, + "amount": 0 + }, + { + "first": 115, + "second": 376, + "amount": -4 + }, + { + "first": 116, + "second": 45, + "amount": 0 + }, + { + "first": 116, + "second": 87, + "amount": 0 + }, + { + "first": 116, + "second": 88, + "amount": 0 + }, + { + "first": 116, + "second": 97, + "amount": 0 + }, + { + "first": 116, + "second": 99, + "amount": 0 + }, + { + "first": 116, + "second": 100, + "amount": 0 + }, + { + "first": 116, + "second": 101, + "amount": 0 + }, + { + "first": 116, + "second": 102, + "amount": 0 + }, + { + "first": 116, + "second": 103, + "amount": 0 + }, + { + "first": 116, + "second": 111, + "amount": 0 + }, + { + "first": 116, + "second": 113, + "amount": 0 + }, + { + "first": 116, + "second": 115, + "amount": 0 + }, + { + "first": 116, + "second": 116, + "amount": -1 + }, + { + "first": 116, + "second": 117, + "amount": -1 + }, + { + "first": 116, + "second": 118, + "amount": 0 + }, + { + "first": 116, + "second": 119, + "amount": 0 + }, + { + "first": 116, + "second": 120, + "amount": -1 + }, + { + "first": 116, + "second": 121, + "amount": 0 + }, + { + "first": 116, + "second": 122, + "amount": 0 + }, + { + "first": 116, + "second": 171, + "amount": 0 + }, + { + "first": 116, + "second": 180, + "amount": 0 + }, + { + "first": 116, + "second": 224, + "amount": 0 + }, + { + "first": 116, + "second": 225, + "amount": 0 + }, + { + "first": 116, + "second": 226, + "amount": 0 + }, + { + "first": 116, + "second": 227, + "amount": 0 + }, + { + "first": 116, + "second": 228, + "amount": 0 + }, + { + "first": 116, + "second": 229, + "amount": 0 + }, + { + "first": 116, + "second": 230, + "amount": 0 + }, + { + "first": 116, + "second": 231, + "amount": 0 + }, + { + "first": 116, + "second": 232, + "amount": 0 + }, + { + "first": 116, + "second": 233, + "amount": 0 + }, + { + "first": 116, + "second": 234, + "amount": 0 + }, + { + "first": 116, + "second": 235, + "amount": 0 + }, + { + "first": 116, + "second": 240, + "amount": 0 + }, + { + "first": 116, + "second": 241, + "amount": -1 + }, + { + "first": 116, + "second": 242, + "amount": 0 + }, + { + "first": 116, + "second": 243, + "amount": 0 + }, + { + "first": 116, + "second": 244, + "amount": 0 + }, + { + "first": 116, + "second": 245, + "amount": 0 + }, + { + "first": 116, + "second": 246, + "amount": 0 + }, + { + "first": 116, + "second": 248, + "amount": 0 + }, + { + "first": 116, + "second": 250, + "amount": -1 + }, + { + "first": 116, + "second": 339, + "amount": 0 + }, + { + "first": 117, + "second": 55, + "amount": -2 + }, + { + "first": 117, + "second": 84, + "amount": -2 + }, + { + "first": 117, + "second": 86, + "amount": -1 + }, + { + "first": 117, + "second": 87, + "amount": -1 + }, + { + "first": 117, + "second": 88, + "amount": 0 + }, + { + "first": 117, + "second": 89, + "amount": -1 + }, + { + "first": 117, + "second": 106, + "amount": 2 + }, + { + "first": 117, + "second": 221, + "amount": -1 + }, + { + "first": 117, + "second": 376, + "amount": -1 + }, + { + "first": 118, + "second": 64, + "amount": -1 + }, + { + "first": 118, + "second": 65, + "amount": -2 + }, + { + "first": 118, + "second": 67, + "amount": -1 + }, + { + "first": 118, + "second": 71, + "amount": -1 + }, + { + "first": 118, + "second": 74, + "amount": -1 + }, + { + "first": 118, + "second": 79, + "amount": -1 + }, + { + "first": 118, + "second": 81, + "amount": -1 + }, + { + "first": 118, + "second": 83, + "amount": 0 + }, + { + "first": 118, + "second": 84, + "amount": -2 + }, + { + "first": 118, + "second": 86, + "amount": -2 + }, + { + "first": 118, + "second": 87, + "amount": 0 + }, + { + "first": 118, + "second": 88, + "amount": -1 + }, + { + "first": 118, + "second": 89, + "amount": -2 + }, + { + "first": 118, + "second": 90, + "amount": 0 + }, + { + "first": 118, + "second": 97, + "amount": 0 + }, + { + "first": 118, + "second": 99, + "amount": 0 + }, + { + "first": 118, + "second": 100, + "amount": 0 + }, + { + "first": 118, + "second": 101, + "amount": 0 + }, + { + "first": 118, + "second": 103, + "amount": 0 + }, + { + "first": 118, + "second": 111, + "amount": 0 + }, + { + "first": 118, + "second": 113, + "amount": 0 + }, + { + "first": 118, + "second": 115, + "amount": 0 + }, + { + "first": 118, + "second": 122, + "amount": 0 + }, + { + "first": 118, + "second": 169, + "amount": -1 + }, + { + "first": 118, + "second": 174, + "amount": -1 + }, + { + "first": 118, + "second": 180, + "amount": 0 + }, + { + "first": 118, + "second": 192, + "amount": -2 + }, + { + "first": 118, + "second": 193, + "amount": -2 + }, + { + "first": 118, + "second": 194, + "amount": -2 + }, + { + "first": 118, + "second": 195, + "amount": -2 + }, + { + "first": 118, + "second": 196, + "amount": -2 + }, + { + "first": 118, + "second": 197, + "amount": -2 + }, + { + "first": 118, + "second": 199, + "amount": -1 + }, + { + "first": 118, + "second": 210, + "amount": -1 + }, + { + "first": 118, + "second": 211, + "amount": -1 + }, + { + "first": 118, + "second": 212, + "amount": -1 + }, + { + "first": 118, + "second": 213, + "amount": -1 + }, + { + "first": 118, + "second": 214, + "amount": -1 + }, + { + "first": 118, + "second": 216, + "amount": -1 + }, + { + "first": 118, + "second": 221, + "amount": -2 + }, + { + "first": 118, + "second": 224, + "amount": 0 + }, + { + "first": 118, + "second": 225, + "amount": 0 + }, + { + "first": 118, + "second": 226, + "amount": 0 + }, + { + "first": 118, + "second": 227, + "amount": 0 + }, + { + "first": 118, + "second": 228, + "amount": 0 + }, + { + "first": 118, + "second": 229, + "amount": 0 + }, + { + "first": 118, + "second": 230, + "amount": 0 + }, + { + "first": 118, + "second": 231, + "amount": 0 + }, + { + "first": 118, + "second": 248, + "amount": 0 + }, + { + "first": 118, + "second": 338, + "amount": -1 + }, + { + "first": 118, + "second": 339, + "amount": 0 + }, + { + "first": 118, + "second": 376, + "amount": -2 + }, + { + "first": 119, + "second": 38, + "amount": 0 + }, + { + "first": 119, + "second": 44, + "amount": -1 + }, + { + "first": 119, + "second": 46, + "amount": -1 + }, + { + "first": 119, + "second": 48, + "amount": 0 + }, + { + "first": 119, + "second": 50, + "amount": 0 + }, + { + "first": 119, + "second": 51, + "amount": 0 + }, + { + "first": 119, + "second": 52, + "amount": 0 + }, + { + "first": 119, + "second": 53, + "amount": 0 + }, + { + "first": 119, + "second": 54, + "amount": 0 + }, + { + "first": 119, + "second": 55, + "amount": -1 + }, + { + "first": 119, + "second": 56, + "amount": 0 + }, + { + "first": 119, + "second": 64, + "amount": -1 + }, + { + "first": 119, + "second": 65, + "amount": -2 + }, + { + "first": 119, + "second": 67, + "amount": -1 + }, + { + "first": 119, + "second": 71, + "amount": -1 + }, + { + "first": 119, + "second": 74, + "amount": -1 + }, + { + "first": 119, + "second": 79, + "amount": -1 + }, + { + "first": 119, + "second": 81, + "amount": -1 + }, + { + "first": 119, + "second": 83, + "amount": 0 + }, + { + "first": 119, + "second": 84, + "amount": -2 + }, + { + "first": 119, + "second": 86, + "amount": -1 + }, + { + "first": 119, + "second": 87, + "amount": -1 + }, + { + "first": 119, + "second": 88, + "amount": -2 + }, + { + "first": 119, + "second": 89, + "amount": -1 + }, + { + "first": 119, + "second": 90, + "amount": -1 + }, + { + "first": 119, + "second": 97, + "amount": 0 + }, + { + "first": 119, + "second": 99, + "amount": 0 + }, + { + "first": 119, + "second": 100, + "amount": 0 + }, + { + "first": 119, + "second": 101, + "amount": 0 + }, + { + "first": 119, + "second": 103, + "amount": 0 + }, + { + "first": 119, + "second": 111, + "amount": 0 + }, + { + "first": 119, + "second": 113, + "amount": 0 + }, + { + "first": 119, + "second": 115, + "amount": 0 + }, + { + "first": 119, + "second": 120, + "amount": 0 + }, + { + "first": 119, + "second": 122, + "amount": 0 + }, + { + "first": 119, + "second": 169, + "amount": -1 + }, + { + "first": 119, + "second": 174, + "amount": -1 + }, + { + "first": 119, + "second": 180, + "amount": 0 + }, + { + "first": 119, + "second": 192, + "amount": -2 + }, + { + "first": 119, + "second": 193, + "amount": -2 + }, + { + "first": 119, + "second": 194, + "amount": -2 + }, + { + "first": 119, + "second": 195, + "amount": -2 + }, + { + "first": 119, + "second": 196, + "amount": -2 + }, + { + "first": 119, + "second": 197, + "amount": -2 + }, + { + "first": 119, + "second": 199, + "amount": -1 + }, + { + "first": 119, + "second": 210, + "amount": -1 + }, + { + "first": 119, + "second": 211, + "amount": -1 + }, + { + "first": 119, + "second": 212, + "amount": -1 + }, + { + "first": 119, + "second": 213, + "amount": -1 + }, + { + "first": 119, + "second": 214, + "amount": -1 + }, + { + "first": 119, + "second": 216, + "amount": -1 + }, + { + "first": 119, + "second": 221, + "amount": -1 + }, + { + "first": 119, + "second": 224, + "amount": 0 + }, + { + "first": 119, + "second": 225, + "amount": 0 + }, + { + "first": 119, + "second": 226, + "amount": 0 + }, + { + "first": 119, + "second": 227, + "amount": 0 + }, + { + "first": 119, + "second": 228, + "amount": 0 + }, + { + "first": 119, + "second": 229, + "amount": 0 + }, + { + "first": 119, + "second": 230, + "amount": 0 + }, + { + "first": 119, + "second": 231, + "amount": 0 + }, + { + "first": 119, + "second": 232, + "amount": 0 + }, + { + "first": 119, + "second": 233, + "amount": 0 + }, + { + "first": 119, + "second": 234, + "amount": 0 + }, + { + "first": 119, + "second": 235, + "amount": 0 + }, + { + "first": 119, + "second": 240, + "amount": 0 + }, + { + "first": 119, + "second": 242, + "amount": 0 + }, + { + "first": 119, + "second": 243, + "amount": 0 + }, + { + "first": 119, + "second": 244, + "amount": 0 + }, + { + "first": 119, + "second": 245, + "amount": 0 + }, + { + "first": 119, + "second": 246, + "amount": 0 + }, + { + "first": 119, + "second": 248, + "amount": 0 + }, + { + "first": 119, + "second": 338, + "amount": -1 + }, + { + "first": 119, + "second": 339, + "amount": 0 + }, + { + "first": 119, + "second": 376, + "amount": -1 + }, + { + "first": 120, + "second": 38, + "amount": -1 + }, + { + "first": 120, + "second": 42, + "amount": -1 + }, + { + "first": 120, + "second": 45, + "amount": -1 + }, + { + "first": 120, + "second": 48, + "amount": 0 + }, + { + "first": 120, + "second": 51, + "amount": 0 + }, + { + "first": 120, + "second": 53, + "amount": 0 + }, + { + "first": 120, + "second": 54, + "amount": 0 + }, + { + "first": 120, + "second": 55, + "amount": -1 + }, + { + "first": 120, + "second": 56, + "amount": 0 + }, + { + "first": 120, + "second": 57, + "amount": 0 + }, + { + "first": 120, + "second": 64, + "amount": -1 + }, + { + "first": 120, + "second": 67, + "amount": -1 + }, + { + "first": 120, + "second": 71, + "amount": -1 + }, + { + "first": 120, + "second": 74, + "amount": 0 + }, + { + "first": 120, + "second": 79, + "amount": -1 + }, + { + "first": 120, + "second": 81, + "amount": -1 + }, + { + "first": 120, + "second": 83, + "amount": -1 + }, + { + "first": 120, + "second": 84, + "amount": -2 + }, + { + "first": 120, + "second": 85, + "amount": 0 + }, + { + "first": 120, + "second": 86, + "amount": -3 + }, + { + "first": 120, + "second": 87, + "amount": -1 + }, + { + "first": 120, + "second": 88, + "amount": 0 + }, + { + "first": 120, + "second": 89, + "amount": -3 + }, + { + "first": 120, + "second": 90, + "amount": 0 + }, + { + "first": 120, + "second": 92, + "amount": -1 + }, + { + "first": 120, + "second": 97, + "amount": -1 + }, + { + "first": 120, + "second": 99, + "amount": 0 + }, + { + "first": 120, + "second": 100, + "amount": 0 + }, + { + "first": 120, + "second": 101, + "amount": 0 + }, + { + "first": 120, + "second": 102, + "amount": 0 + }, + { + "first": 120, + "second": 103, + "amount": 0 + }, + { + "first": 120, + "second": 111, + "amount": 0 + }, + { + "first": 120, + "second": 113, + "amount": 0 + }, + { + "first": 120, + "second": 115, + "amount": 0 + }, + { + "first": 120, + "second": 116, + "amount": 0 + }, + { + "first": 120, + "second": 117, + "amount": -1 + }, + { + "first": 120, + "second": 118, + "amount": 0 + }, + { + "first": 120, + "second": 119, + "amount": -1 + }, + { + "first": 120, + "second": 121, + "amount": 0 + }, + { + "first": 120, + "second": 169, + "amount": -1 + }, + { + "first": 120, + "second": 171, + "amount": -1 + }, + { + "first": 120, + "second": 174, + "amount": -1 + }, + { + "first": 120, + "second": 180, + "amount": -1 + }, + { + "first": 120, + "second": 199, + "amount": -1 + }, + { + "first": 120, + "second": 210, + "amount": -1 + }, + { + "first": 120, + "second": 211, + "amount": -1 + }, + { + "first": 120, + "second": 212, + "amount": -1 + }, + { + "first": 120, + "second": 213, + "amount": -1 + }, + { + "first": 120, + "second": 214, + "amount": -1 + }, + { + "first": 120, + "second": 216, + "amount": -1 + }, + { + "first": 120, + "second": 217, + "amount": 0 + }, + { + "first": 120, + "second": 218, + "amount": 0 + }, + { + "first": 120, + "second": 219, + "amount": 0 + }, + { + "first": 120, + "second": 220, + "amount": 0 + }, + { + "first": 120, + "second": 221, + "amount": -3 + }, + { + "first": 120, + "second": 224, + "amount": -1 + }, + { + "first": 120, + "second": 225, + "amount": -1 + }, + { + "first": 120, + "second": 226, + "amount": -1 + }, + { + "first": 120, + "second": 227, + "amount": -1 + }, + { + "first": 120, + "second": 228, + "amount": -1 + }, + { + "first": 120, + "second": 229, + "amount": -1 + }, + { + "first": 120, + "second": 230, + "amount": -1 + }, + { + "first": 120, + "second": 231, + "amount": 0 + }, + { + "first": 120, + "second": 248, + "amount": 0 + }, + { + "first": 120, + "second": 249, + "amount": 0 + }, + { + "first": 120, + "second": 250, + "amount": -1 + }, + { + "first": 120, + "second": 251, + "amount": 0 + }, + { + "first": 120, + "second": 252, + "amount": 0 + }, + { + "first": 120, + "second": 338, + "amount": -1 + }, + { + "first": 120, + "second": 339, + "amount": 0 + }, + { + "first": 120, + "second": 376, + "amount": -3 + }, + { + "first": 121, + "second": 64, + "amount": -1 + }, + { + "first": 121, + "second": 65, + "amount": -2 + }, + { + "first": 121, + "second": 67, + "amount": -1 + }, + { + "first": 121, + "second": 71, + "amount": -1 + }, + { + "first": 121, + "second": 74, + "amount": -1 + }, + { + "first": 121, + "second": 79, + "amount": -1 + }, + { + "first": 121, + "second": 81, + "amount": -1 + }, + { + "first": 121, + "second": 83, + "amount": 0 + }, + { + "first": 121, + "second": 84, + "amount": -2 + }, + { + "first": 121, + "second": 86, + "amount": -2 + }, + { + "first": 121, + "second": 87, + "amount": 0 + }, + { + "first": 121, + "second": 88, + "amount": -1 + }, + { + "first": 121, + "second": 89, + "amount": -2 + }, + { + "first": 121, + "second": 90, + "amount": 0 + }, + { + "first": 121, + "second": 97, + "amount": 0 + }, + { + "first": 121, + "second": 99, + "amount": 0 + }, + { + "first": 121, + "second": 100, + "amount": 0 + }, + { + "first": 121, + "second": 101, + "amount": 0 + }, + { + "first": 121, + "second": 103, + "amount": 0 + }, + { + "first": 121, + "second": 111, + "amount": 0 + }, + { + "first": 121, + "second": 113, + "amount": 0 + }, + { + "first": 121, + "second": 115, + "amount": 0 + }, + { + "first": 121, + "second": 122, + "amount": 0 + }, + { + "first": 121, + "second": 169, + "amount": -1 + }, + { + "first": 121, + "second": 174, + "amount": -1 + }, + { + "first": 121, + "second": 180, + "amount": 0 + }, + { + "first": 121, + "second": 192, + "amount": -2 + }, + { + "first": 121, + "second": 193, + "amount": -2 + }, + { + "first": 121, + "second": 194, + "amount": -2 + }, + { + "first": 121, + "second": 195, + "amount": -2 + }, + { + "first": 121, + "second": 196, + "amount": -2 + }, + { + "first": 121, + "second": 197, + "amount": -2 + }, + { + "first": 121, + "second": 199, + "amount": -1 + }, + { + "first": 121, + "second": 210, + "amount": -1 + }, + { + "first": 121, + "second": 211, + "amount": -1 + }, + { + "first": 121, + "second": 212, + "amount": -1 + }, + { + "first": 121, + "second": 213, + "amount": -1 + }, + { + "first": 121, + "second": 214, + "amount": -1 + }, + { + "first": 121, + "second": 216, + "amount": -1 + }, + { + "first": 121, + "second": 221, + "amount": -2 + }, + { + "first": 121, + "second": 224, + "amount": 0 + }, + { + "first": 121, + "second": 225, + "amount": 0 + }, + { + "first": 121, + "second": 226, + "amount": 0 + }, + { + "first": 121, + "second": 227, + "amount": 0 + }, + { + "first": 121, + "second": 228, + "amount": 0 + }, + { + "first": 121, + "second": 229, + "amount": 0 + }, + { + "first": 121, + "second": 230, + "amount": 0 + }, + { + "first": 121, + "second": 231, + "amount": 0 + }, + { + "first": 121, + "second": 248, + "amount": 0 + }, + { + "first": 121, + "second": 338, + "amount": -1 + }, + { + "first": 121, + "second": 339, + "amount": 0 + }, + { + "first": 121, + "second": 376, + "amount": -2 + }, + { + "first": 122, + "second": 64, + "amount": 0 + }, + { + "first": 122, + "second": 67, + "amount": 0 + }, + { + "first": 122, + "second": 71, + "amount": 0 + }, + { + "first": 122, + "second": 79, + "amount": 0 + }, + { + "first": 122, + "second": 81, + "amount": 0 + }, + { + "first": 122, + "second": 84, + "amount": -2 + }, + { + "first": 122, + "second": 86, + "amount": -2 + }, + { + "first": 122, + "second": 87, + "amount": 0 + }, + { + "first": 122, + "second": 88, + "amount": 0 + }, + { + "first": 122, + "second": 89, + "amount": -2 + }, + { + "first": 122, + "second": 99, + "amount": 0 + }, + { + "first": 122, + "second": 100, + "amount": 0 + }, + { + "first": 122, + "second": 101, + "amount": 0 + }, + { + "first": 122, + "second": 103, + "amount": 0 + }, + { + "first": 122, + "second": 111, + "amount": 0 + }, + { + "first": 122, + "second": 113, + "amount": 0 + }, + { + "first": 122, + "second": 115, + "amount": 0 + }, + { + "first": 122, + "second": 118, + "amount": 0 + }, + { + "first": 122, + "second": 119, + "amount": 0 + }, + { + "first": 122, + "second": 121, + "amount": 0 + }, + { + "first": 122, + "second": 169, + "amount": 0 + }, + { + "first": 122, + "second": 174, + "amount": 0 + }, + { + "first": 122, + "second": 199, + "amount": 0 + }, + { + "first": 122, + "second": 210, + "amount": 0 + }, + { + "first": 122, + "second": 211, + "amount": 0 + }, + { + "first": 122, + "second": 212, + "amount": 0 + }, + { + "first": 122, + "second": 213, + "amount": 0 + }, + { + "first": 122, + "second": 214, + "amount": 0 + }, + { + "first": 122, + "second": 216, + "amount": 0 + }, + { + "first": 122, + "second": 221, + "amount": -2 + }, + { + "first": 122, + "second": 231, + "amount": 0 + }, + { + "first": 122, + "second": 248, + "amount": 0 + }, + { + "first": 122, + "second": 338, + "amount": 0 + }, + { + "first": 122, + "second": 339, + "amount": 0 + }, + { + "first": 122, + "second": 376, + "amount": -2 + }, + { + "first": 123, + "second": 74, + "amount": -1 + }, + { + "first": 123, + "second": 106, + "amount": 4 + }, + { + "first": 161, + "second": 106, + "amount": 2 + }, + { + "first": 169, + "second": 44, + "amount": -1 + }, + { + "first": 169, + "second": 46, + "amount": -1 + }, + { + "first": 169, + "second": 47, + "amount": -1 + }, + { + "first": 169, + "second": 65, + "amount": -2 + }, + { + "first": 169, + "second": 74, + "amount": 0 + }, + { + "first": 169, + "second": 84, + "amount": -2 + }, + { + "first": 169, + "second": 86, + "amount": -3 + }, + { + "first": 169, + "second": 87, + "amount": -1 + }, + { + "first": 169, + "second": 88, + "amount": -1 + }, + { + "first": 169, + "second": 89, + "amount": -3 + }, + { + "first": 169, + "second": 90, + "amount": -1 + }, + { + "first": 169, + "second": 97, + "amount": 0 + }, + { + "first": 169, + "second": 99, + "amount": 0 + }, + { + "first": 169, + "second": 100, + "amount": 0 + }, + { + "first": 169, + "second": 101, + "amount": 0 + }, + { + "first": 169, + "second": 103, + "amount": 0 + }, + { + "first": 169, + "second": 111, + "amount": 0 + }, + { + "first": 169, + "second": 113, + "amount": 0 + }, + { + "first": 169, + "second": 115, + "amount": 0 + }, + { + "first": 169, + "second": 118, + "amount": 0 + }, + { + "first": 169, + "second": 119, + "amount": 0 + }, + { + "first": 169, + "second": 120, + "amount": 0 + }, + { + "first": 169, + "second": 121, + "amount": 0 + }, + { + "first": 169, + "second": 122, + "amount": 0 + }, + { + "first": 169, + "second": 192, + "amount": -2 + }, + { + "first": 169, + "second": 193, + "amount": -2 + }, + { + "first": 169, + "second": 194, + "amount": -2 + }, + { + "first": 169, + "second": 195, + "amount": -2 + }, + { + "first": 169, + "second": 196, + "amount": -2 + }, + { + "first": 169, + "second": 197, + "amount": -2 + }, + { + "first": 169, + "second": 221, + "amount": -3 + }, + { + "first": 169, + "second": 230, + "amount": 0 + }, + { + "first": 169, + "second": 231, + "amount": 0 + }, + { + "first": 169, + "second": 248, + "amount": 0 + }, + { + "first": 169, + "second": 339, + "amount": 0 + }, + { + "first": 169, + "second": 376, + "amount": -3 + }, + { + "first": 171, + "second": 84, + "amount": -2 + }, + { + "first": 171, + "second": 86, + "amount": -1 + }, + { + "first": 171, + "second": 89, + "amount": -1 + }, + { + "first": 171, + "second": 221, + "amount": -1 + }, + { + "first": 171, + "second": 376, + "amount": -1 + }, + { + "first": 174, + "second": 44, + "amount": -1 + }, + { + "first": 174, + "second": 46, + "amount": -1 + }, + { + "first": 174, + "second": 47, + "amount": -1 + }, + { + "first": 174, + "second": 65, + "amount": -2 + }, + { + "first": 174, + "second": 74, + "amount": 0 + }, + { + "first": 174, + "second": 84, + "amount": -2 + }, + { + "first": 174, + "second": 86, + "amount": -3 + }, + { + "first": 174, + "second": 87, + "amount": -1 + }, + { + "first": 174, + "second": 88, + "amount": -1 + }, + { + "first": 174, + "second": 89, + "amount": -3 + }, + { + "first": 174, + "second": 90, + "amount": -1 + }, + { + "first": 174, + "second": 97, + "amount": 0 + }, + { + "first": 174, + "second": 99, + "amount": 0 + }, + { + "first": 174, + "second": 100, + "amount": 0 + }, + { + "first": 174, + "second": 101, + "amount": 0 + }, + { + "first": 174, + "second": 103, + "amount": 0 + }, + { + "first": 174, + "second": 111, + "amount": 0 + }, + { + "first": 174, + "second": 113, + "amount": 0 + }, + { + "first": 174, + "second": 115, + "amount": 0 + }, + { + "first": 174, + "second": 118, + "amount": 0 + }, + { + "first": 174, + "second": 119, + "amount": 0 + }, + { + "first": 174, + "second": 120, + "amount": 0 + }, + { + "first": 174, + "second": 121, + "amount": 0 + }, + { + "first": 174, + "second": 122, + "amount": 0 + }, + { + "first": 174, + "second": 192, + "amount": -2 + }, + { + "first": 174, + "second": 193, + "amount": -2 + }, + { + "first": 174, + "second": 194, + "amount": -2 + }, + { + "first": 174, + "second": 195, + "amount": -2 + }, + { + "first": 174, + "second": 196, + "amount": -2 + }, + { + "first": 174, + "second": 197, + "amount": -2 + }, + { + "first": 174, + "second": 221, + "amount": -3 + }, + { + "first": 174, + "second": 230, + "amount": 0 + }, + { + "first": 174, + "second": 231, + "amount": 0 + }, + { + "first": 174, + "second": 248, + "amount": 0 + }, + { + "first": 174, + "second": 339, + "amount": 0 + }, + { + "first": 174, + "second": 376, + "amount": -3 + }, + { + "first": 180, + "second": 84, + "amount": -2 + }, + { + "first": 180, + "second": 86, + "amount": -3 + }, + { + "first": 180, + "second": 89, + "amount": -3 + }, + { + "first": 180, + "second": 118, + "amount": 0 + }, + { + "first": 180, + "second": 119, + "amount": 0 + }, + { + "first": 180, + "second": 121, + "amount": 0 + }, + { + "first": 180, + "second": 221, + "amount": -3 + }, + { + "first": 180, + "second": 376, + "amount": -3 + }, + { + "first": 181, + "second": 55, + "amount": -2 + }, + { + "first": 181, + "second": 84, + "amount": -2 + }, + { + "first": 181, + "second": 86, + "amount": -1 + }, + { + "first": 181, + "second": 87, + "amount": -1 + }, + { + "first": 181, + "second": 88, + "amount": 0 + }, + { + "first": 181, + "second": 89, + "amount": -1 + }, + { + "first": 181, + "second": 106, + "amount": 2 + }, + { + "first": 181, + "second": 221, + "amount": -1 + }, + { + "first": 181, + "second": 376, + "amount": -1 + }, + { + "first": 187, + "second": 65, + "amount": 0 + }, + { + "first": 187, + "second": 84, + "amount": -3 + }, + { + "first": 187, + "second": 86, + "amount": -4 + }, + { + "first": 187, + "second": 87, + "amount": -1 + }, + { + "first": 187, + "second": 88, + "amount": -1 + }, + { + "first": 187, + "second": 89, + "amount": -4 + }, + { + "first": 187, + "second": 116, + "amount": 0 + }, + { + "first": 187, + "second": 120, + "amount": -1 + }, + { + "first": 187, + "second": 192, + "amount": 0 + }, + { + "first": 187, + "second": 193, + "amount": 0 + }, + { + "first": 187, + "second": 194, + "amount": 0 + }, + { + "first": 187, + "second": 195, + "amount": 0 + }, + { + "first": 187, + "second": 196, + "amount": 0 + }, + { + "first": 187, + "second": 197, + "amount": 0 + }, + { + "first": 187, + "second": 221, + "amount": -4 + }, + { + "first": 187, + "second": 376, + "amount": -4 + }, + { + "first": 191, + "second": 84, + "amount": -2 + }, + { + "first": 191, + "second": 85, + "amount": -1 + }, + { + "first": 191, + "second": 86, + "amount": -3 + }, + { + "first": 191, + "second": 87, + "amount": -1 + }, + { + "first": 191, + "second": 89, + "amount": -3 + }, + { + "first": 191, + "second": 106, + "amount": 2 + }, + { + "first": 191, + "second": 119, + "amount": -1 + }, + { + "first": 191, + "second": 217, + "amount": -1 + }, + { + "first": 191, + "second": 218, + "amount": -1 + }, + { + "first": 191, + "second": 219, + "amount": -1 + }, + { + "first": 191, + "second": 220, + "amount": -1 + }, + { + "first": 191, + "second": 221, + "amount": -3 + }, + { + "first": 191, + "second": 376, + "amount": -3 + }, + { + "first": 192, + "second": 32, + "amount": -2 + }, + { + "first": 192, + "second": 38, + "amount": -1 + }, + { + "first": 192, + "second": 41, + "amount": -1 + }, + { + "first": 192, + "second": 42, + "amount": -4 + }, + { + "first": 192, + "second": 48, + "amount": -1 + }, + { + "first": 192, + "second": 49, + "amount": 0 + }, + { + "first": 192, + "second": 53, + "amount": 0 + }, + { + "first": 192, + "second": 54, + "amount": -1 + }, + { + "first": 192, + "second": 55, + "amount": -3 + }, + { + "first": 192, + "second": 56, + "amount": -1 + }, + { + "first": 192, + "second": 57, + "amount": -2 + }, + { + "first": 192, + "second": 64, + "amount": -2 + }, + { + "first": 192, + "second": 66, + "amount": -1 + }, + { + "first": 192, + "second": 67, + "amount": -2 + }, + { + "first": 192, + "second": 68, + "amount": -1 + }, + { + "first": 192, + "second": 69, + "amount": -1 + }, + { + "first": 192, + "second": 70, + "amount": -1 + }, + { + "first": 192, + "second": 71, + "amount": -2 + }, + { + "first": 192, + "second": 72, + "amount": -1 + }, + { + "first": 192, + "second": 73, + "amount": -1 + }, + { + "first": 192, + "second": 74, + "amount": 0 + }, + { + "first": 192, + "second": 75, + "amount": -1 + }, + { + "first": 192, + "second": 76, + "amount": -1 + }, + { + "first": 192, + "second": 77, + "amount": -1 + }, + { + "first": 192, + "second": 78, + "amount": -1 + }, + { + "first": 192, + "second": 79, + "amount": -2 + }, + { + "first": 192, + "second": 80, + "amount": -1 + }, + { + "first": 192, + "second": 81, + "amount": -2 + }, + { + "first": 192, + "second": 82, + "amount": -1 + }, + { + "first": 192, + "second": 83, + "amount": -1 + }, + { + "first": 192, + "second": 84, + "amount": -5 + }, + { + "first": 192, + "second": 85, + "amount": -1 + }, + { + "first": 192, + "second": 86, + "amount": -5 + }, + { + "first": 192, + "second": 87, + "amount": -2 + }, + { + "first": 192, + "second": 88, + "amount": -1 + }, + { + "first": 192, + "second": 89, + "amount": -5 + }, + { + "first": 192, + "second": 92, + "amount": -4 + }, + { + "first": 192, + "second": 97, + "amount": 0 + }, + { + "first": 192, + "second": 99, + "amount": 0 + }, + { + "first": 192, + "second": 100, + "amount": 0 + }, + { + "first": 192, + "second": 101, + "amount": 0 + }, + { + "first": 192, + "second": 102, + "amount": 0 + }, + { + "first": 192, + "second": 103, + "amount": 0 + }, + { + "first": 192, + "second": 106, + "amount": 3 + }, + { + "first": 192, + "second": 111, + "amount": 0 + }, + { + "first": 192, + "second": 113, + "amount": 0 + }, + { + "first": 192, + "second": 115, + "amount": 0 + }, + { + "first": 192, + "second": 116, + "amount": -1 + }, + { + "first": 192, + "second": 117, + "amount": 0 + }, + { + "first": 192, + "second": 118, + "amount": -2 + }, + { + "first": 192, + "second": 119, + "amount": -2 + }, + { + "first": 192, + "second": 121, + "amount": -2 + }, + { + "first": 192, + "second": 169, + "amount": -2 + }, + { + "first": 192, + "second": 171, + "amount": 0 + }, + { + "first": 192, + "second": 174, + "amount": -2 + }, + { + "first": 192, + "second": 199, + "amount": -2 + }, + { + "first": 192, + "second": 200, + "amount": -1 + }, + { + "first": 192, + "second": 201, + "amount": -1 + }, + { + "first": 192, + "second": 202, + "amount": -1 + }, + { + "first": 192, + "second": 203, + "amount": -1 + }, + { + "first": 192, + "second": 204, + "amount": -1 + }, + { + "first": 192, + "second": 205, + "amount": -1 + }, + { + "first": 192, + "second": 206, + "amount": -1 + }, + { + "first": 192, + "second": 207, + "amount": -1 + }, + { + "first": 192, + "second": 209, + "amount": -1 + }, + { + "first": 192, + "second": 210, + "amount": -2 + }, + { + "first": 192, + "second": 211, + "amount": -2 + }, + { + "first": 192, + "second": 212, + "amount": -2 + }, + { + "first": 192, + "second": 213, + "amount": -2 + }, + { + "first": 192, + "second": 214, + "amount": -2 + }, + { + "first": 192, + "second": 216, + "amount": -2 + }, + { + "first": 192, + "second": 217, + "amount": -1 + }, + { + "first": 192, + "second": 218, + "amount": -1 + }, + { + "first": 192, + "second": 219, + "amount": -1 + }, + { + "first": 192, + "second": 220, + "amount": -1 + }, + { + "first": 192, + "second": 221, + "amount": -5 + }, + { + "first": 192, + "second": 222, + "amount": -1 + }, + { + "first": 192, + "second": 230, + "amount": 0 + }, + { + "first": 192, + "second": 231, + "amount": 0 + }, + { + "first": 192, + "second": 232, + "amount": 0 + }, + { + "first": 192, + "second": 233, + "amount": 0 + }, + { + "first": 192, + "second": 234, + "amount": 0 + }, + { + "first": 192, + "second": 235, + "amount": 0 + }, + { + "first": 192, + "second": 240, + "amount": 0 + }, + { + "first": 192, + "second": 242, + "amount": 0 + }, + { + "first": 192, + "second": 243, + "amount": 0 + }, + { + "first": 192, + "second": 244, + "amount": 0 + }, + { + "first": 192, + "second": 245, + "amount": 0 + }, + { + "first": 192, + "second": 246, + "amount": 0 + }, + { + "first": 192, + "second": 248, + "amount": 0 + }, + { + "first": 192, + "second": 250, + "amount": 0 + }, + { + "first": 192, + "second": 338, + "amount": -2 + }, + { + "first": 192, + "second": 339, + "amount": 0 + }, + { + "first": 192, + "second": 376, + "amount": -5 + }, + { + "first": 193, + "second": 32, + "amount": -2 + }, + { + "first": 193, + "second": 38, + "amount": -1 + }, + { + "first": 193, + "second": 41, + "amount": -1 + }, + { + "first": 193, + "second": 42, + "amount": -4 + }, + { + "first": 193, + "second": 48, + "amount": -1 + }, + { + "first": 193, + "second": 49, + "amount": 0 + }, + { + "first": 193, + "second": 53, + "amount": 0 + }, + { + "first": 193, + "second": 54, + "amount": -1 + }, + { + "first": 193, + "second": 55, + "amount": -3 + }, + { + "first": 193, + "second": 56, + "amount": -1 + }, + { + "first": 193, + "second": 57, + "amount": -2 + }, + { + "first": 193, + "second": 64, + "amount": -2 + }, + { + "first": 193, + "second": 66, + "amount": -1 + }, + { + "first": 193, + "second": 67, + "amount": -2 + }, + { + "first": 193, + "second": 68, + "amount": -1 + }, + { + "first": 193, + "second": 69, + "amount": -1 + }, + { + "first": 193, + "second": 70, + "amount": -1 + }, + { + "first": 193, + "second": 71, + "amount": -2 + }, + { + "first": 193, + "second": 72, + "amount": -1 + }, + { + "first": 193, + "second": 73, + "amount": -1 + }, + { + "first": 193, + "second": 74, + "amount": 0 + }, + { + "first": 193, + "second": 75, + "amount": -1 + }, + { + "first": 193, + "second": 76, + "amount": -1 + }, + { + "first": 193, + "second": 77, + "amount": -1 + }, + { + "first": 193, + "second": 78, + "amount": -1 + }, + { + "first": 193, + "second": 79, + "amount": -2 + }, + { + "first": 193, + "second": 80, + "amount": -1 + }, + { + "first": 193, + "second": 81, + "amount": -2 + }, + { + "first": 193, + "second": 82, + "amount": -1 + }, + { + "first": 193, + "second": 83, + "amount": -1 + }, + { + "first": 193, + "second": 84, + "amount": -5 + }, + { + "first": 193, + "second": 85, + "amount": -1 + }, + { + "first": 193, + "second": 86, + "amount": -5 + }, + { + "first": 193, + "second": 87, + "amount": -2 + }, + { + "first": 193, + "second": 88, + "amount": -1 + }, + { + "first": 193, + "second": 89, + "amount": -5 + }, + { + "first": 193, + "second": 92, + "amount": -4 + }, + { + "first": 193, + "second": 97, + "amount": 0 + }, + { + "first": 193, + "second": 99, + "amount": 0 + }, + { + "first": 193, + "second": 100, + "amount": 0 + }, + { + "first": 193, + "second": 101, + "amount": 0 + }, + { + "first": 193, + "second": 102, + "amount": 0 + }, + { + "first": 193, + "second": 103, + "amount": 0 + }, + { + "first": 193, + "second": 106, + "amount": 3 + }, + { + "first": 193, + "second": 111, + "amount": 0 + }, + { + "first": 193, + "second": 113, + "amount": 0 + }, + { + "first": 193, + "second": 115, + "amount": 0 + }, + { + "first": 193, + "second": 116, + "amount": -1 + }, + { + "first": 193, + "second": 117, + "amount": 0 + }, + { + "first": 193, + "second": 118, + "amount": -2 + }, + { + "first": 193, + "second": 119, + "amount": -2 + }, + { + "first": 193, + "second": 121, + "amount": -2 + }, + { + "first": 193, + "second": 169, + "amount": -2 + }, + { + "first": 193, + "second": 171, + "amount": 0 + }, + { + "first": 193, + "second": 174, + "amount": -2 + }, + { + "first": 193, + "second": 199, + "amount": -2 + }, + { + "first": 193, + "second": 200, + "amount": -1 + }, + { + "first": 193, + "second": 201, + "amount": -1 + }, + { + "first": 193, + "second": 202, + "amount": -1 + }, + { + "first": 193, + "second": 203, + "amount": -1 + }, + { + "first": 193, + "second": 204, + "amount": -1 + }, + { + "first": 193, + "second": 205, + "amount": -1 + }, + { + "first": 193, + "second": 206, + "amount": -1 + }, + { + "first": 193, + "second": 207, + "amount": -1 + }, + { + "first": 193, + "second": 209, + "amount": -1 + }, + { + "first": 193, + "second": 210, + "amount": -2 + }, + { + "first": 193, + "second": 211, + "amount": -2 + }, + { + "first": 193, + "second": 212, + "amount": -2 + }, + { + "first": 193, + "second": 213, + "amount": -2 + }, + { + "first": 193, + "second": 214, + "amount": -2 + }, + { + "first": 193, + "second": 216, + "amount": -2 + }, + { + "first": 193, + "second": 217, + "amount": -1 + }, + { + "first": 193, + "second": 218, + "amount": -1 + }, + { + "first": 193, + "second": 219, + "amount": -1 + }, + { + "first": 193, + "second": 220, + "amount": -1 + }, + { + "first": 193, + "second": 221, + "amount": -5 + }, + { + "first": 193, + "second": 222, + "amount": -1 + }, + { + "first": 193, + "second": 230, + "amount": 0 + }, + { + "first": 193, + "second": 231, + "amount": 0 + }, + { + "first": 193, + "second": 232, + "amount": 0 + }, + { + "first": 193, + "second": 233, + "amount": 0 + }, + { + "first": 193, + "second": 234, + "amount": 0 + }, + { + "first": 193, + "second": 235, + "amount": 0 + }, + { + "first": 193, + "second": 240, + "amount": 0 + }, + { + "first": 193, + "second": 242, + "amount": 0 + }, + { + "first": 193, + "second": 243, + "amount": 0 + }, + { + "first": 193, + "second": 244, + "amount": 0 + }, + { + "first": 193, + "second": 245, + "amount": 0 + }, + { + "first": 193, + "second": 246, + "amount": 0 + }, + { + "first": 193, + "second": 248, + "amount": 0 + }, + { + "first": 193, + "second": 250, + "amount": 0 + }, + { + "first": 193, + "second": 338, + "amount": -2 + }, + { + "first": 193, + "second": 339, + "amount": 0 + }, + { + "first": 193, + "second": 376, + "amount": -5 + }, + { + "first": 194, + "second": 32, + "amount": -2 + }, + { + "first": 194, + "second": 38, + "amount": -1 + }, + { + "first": 194, + "second": 41, + "amount": -1 + }, + { + "first": 194, + "second": 42, + "amount": -4 + }, + { + "first": 194, + "second": 48, + "amount": -1 + }, + { + "first": 194, + "second": 49, + "amount": 0 + }, + { + "first": 194, + "second": 53, + "amount": 0 + }, + { + "first": 194, + "second": 54, + "amount": -1 + }, + { + "first": 194, + "second": 55, + "amount": -3 + }, + { + "first": 194, + "second": 56, + "amount": -1 + }, + { + "first": 194, + "second": 57, + "amount": -2 + }, + { + "first": 194, + "second": 64, + "amount": -2 + }, + { + "first": 194, + "second": 66, + "amount": -1 + }, + { + "first": 194, + "second": 67, + "amount": -2 + }, + { + "first": 194, + "second": 68, + "amount": -1 + }, + { + "first": 194, + "second": 69, + "amount": -1 + }, + { + "first": 194, + "second": 70, + "amount": -1 + }, + { + "first": 194, + "second": 71, + "amount": -2 + }, + { + "first": 194, + "second": 72, + "amount": -1 + }, + { + "first": 194, + "second": 73, + "amount": -1 + }, + { + "first": 194, + "second": 74, + "amount": 0 + }, + { + "first": 194, + "second": 75, + "amount": -1 + }, + { + "first": 194, + "second": 76, + "amount": -1 + }, + { + "first": 194, + "second": 77, + "amount": -1 + }, + { + "first": 194, + "second": 78, + "amount": -1 + }, + { + "first": 194, + "second": 79, + "amount": -2 + }, + { + "first": 194, + "second": 80, + "amount": -1 + }, + { + "first": 194, + "second": 81, + "amount": -2 + }, + { + "first": 194, + "second": 82, + "amount": -1 + }, + { + "first": 194, + "second": 83, + "amount": -1 + }, + { + "first": 194, + "second": 84, + "amount": -5 + }, + { + "first": 194, + "second": 85, + "amount": -1 + }, + { + "first": 194, + "second": 86, + "amount": -5 + }, + { + "first": 194, + "second": 87, + "amount": -2 + }, + { + "first": 194, + "second": 88, + "amount": -1 + }, + { + "first": 194, + "second": 89, + "amount": -5 + }, + { + "first": 194, + "second": 92, + "amount": -4 + }, + { + "first": 194, + "second": 97, + "amount": 0 + }, + { + "first": 194, + "second": 99, + "amount": 0 + }, + { + "first": 194, + "second": 100, + "amount": 0 + }, + { + "first": 194, + "second": 101, + "amount": 0 + }, + { + "first": 194, + "second": 102, + "amount": 0 + }, + { + "first": 194, + "second": 103, + "amount": 0 + }, + { + "first": 194, + "second": 106, + "amount": 3 + }, + { + "first": 194, + "second": 111, + "amount": 0 + }, + { + "first": 194, + "second": 113, + "amount": 0 + }, + { + "first": 194, + "second": 115, + "amount": 0 + }, + { + "first": 194, + "second": 116, + "amount": -1 + }, + { + "first": 194, + "second": 117, + "amount": 0 + }, + { + "first": 194, + "second": 118, + "amount": -2 + }, + { + "first": 194, + "second": 119, + "amount": -2 + }, + { + "first": 194, + "second": 121, + "amount": -2 + }, + { + "first": 194, + "second": 169, + "amount": -2 + }, + { + "first": 194, + "second": 171, + "amount": 0 + }, + { + "first": 194, + "second": 174, + "amount": -2 + }, + { + "first": 194, + "second": 199, + "amount": -2 + }, + { + "first": 194, + "second": 200, + "amount": -1 + }, + { + "first": 194, + "second": 201, + "amount": -1 + }, + { + "first": 194, + "second": 202, + "amount": -1 + }, + { + "first": 194, + "second": 203, + "amount": -1 + }, + { + "first": 194, + "second": 204, + "amount": -1 + }, + { + "first": 194, + "second": 205, + "amount": -1 + }, + { + "first": 194, + "second": 206, + "amount": -1 + }, + { + "first": 194, + "second": 207, + "amount": -1 + }, + { + "first": 194, + "second": 209, + "amount": -1 + }, + { + "first": 194, + "second": 210, + "amount": -2 + }, + { + "first": 194, + "second": 211, + "amount": -2 + }, + { + "first": 194, + "second": 212, + "amount": -2 + }, + { + "first": 194, + "second": 213, + "amount": -2 + }, + { + "first": 194, + "second": 214, + "amount": -2 + }, + { + "first": 194, + "second": 216, + "amount": -2 + }, + { + "first": 194, + "second": 217, + "amount": -1 + }, + { + "first": 194, + "second": 218, + "amount": -1 + }, + { + "first": 194, + "second": 219, + "amount": -1 + }, + { + "first": 194, + "second": 220, + "amount": -1 + }, + { + "first": 194, + "second": 221, + "amount": -5 + }, + { + "first": 194, + "second": 222, + "amount": -1 + }, + { + "first": 194, + "second": 230, + "amount": 0 + }, + { + "first": 194, + "second": 231, + "amount": 0 + }, + { + "first": 194, + "second": 232, + "amount": 0 + }, + { + "first": 194, + "second": 233, + "amount": 0 + }, + { + "first": 194, + "second": 234, + "amount": 0 + }, + { + "first": 194, + "second": 235, + "amount": 0 + }, + { + "first": 194, + "second": 240, + "amount": 0 + }, + { + "first": 194, + "second": 242, + "amount": 0 + }, + { + "first": 194, + "second": 243, + "amount": 0 + }, + { + "first": 194, + "second": 244, + "amount": 0 + }, + { + "first": 194, + "second": 245, + "amount": 0 + }, + { + "first": 194, + "second": 246, + "amount": 0 + }, + { + "first": 194, + "second": 248, + "amount": 0 + }, + { + "first": 194, + "second": 250, + "amount": 0 + }, + { + "first": 194, + "second": 338, + "amount": -2 + }, + { + "first": 194, + "second": 339, + "amount": 0 + }, + { + "first": 194, + "second": 376, + "amount": -5 + }, + { + "first": 195, + "second": 32, + "amount": -2 + }, + { + "first": 195, + "second": 38, + "amount": -1 + }, + { + "first": 195, + "second": 41, + "amount": -1 + }, + { + "first": 195, + "second": 42, + "amount": -4 + }, + { + "first": 195, + "second": 48, + "amount": -1 + }, + { + "first": 195, + "second": 49, + "amount": 0 + }, + { + "first": 195, + "second": 53, + "amount": 0 + }, + { + "first": 195, + "second": 54, + "amount": -1 + }, + { + "first": 195, + "second": 55, + "amount": -3 + }, + { + "first": 195, + "second": 56, + "amount": -1 + }, + { + "first": 195, + "second": 57, + "amount": -2 + }, + { + "first": 195, + "second": 64, + "amount": -2 + }, + { + "first": 195, + "second": 66, + "amount": -1 + }, + { + "first": 195, + "second": 67, + "amount": -2 + }, + { + "first": 195, + "second": 68, + "amount": -1 + }, + { + "first": 195, + "second": 69, + "amount": -1 + }, + { + "first": 195, + "second": 70, + "amount": -1 + }, + { + "first": 195, + "second": 71, + "amount": -2 + }, + { + "first": 195, + "second": 72, + "amount": -1 + }, + { + "first": 195, + "second": 73, + "amount": -1 + }, + { + "first": 195, + "second": 74, + "amount": 0 + }, + { + "first": 195, + "second": 75, + "amount": -1 + }, + { + "first": 195, + "second": 76, + "amount": -1 + }, + { + "first": 195, + "second": 77, + "amount": -1 + }, + { + "first": 195, + "second": 78, + "amount": -1 + }, + { + "first": 195, + "second": 79, + "amount": -2 + }, + { + "first": 195, + "second": 80, + "amount": -1 + }, + { + "first": 195, + "second": 81, + "amount": -2 + }, + { + "first": 195, + "second": 82, + "amount": -1 + }, + { + "first": 195, + "second": 83, + "amount": -1 + }, + { + "first": 195, + "second": 84, + "amount": -5 + }, + { + "first": 195, + "second": 85, + "amount": -1 + }, + { + "first": 195, + "second": 86, + "amount": -5 + }, + { + "first": 195, + "second": 87, + "amount": -2 + }, + { + "first": 195, + "second": 88, + "amount": -1 + }, + { + "first": 195, + "second": 89, + "amount": -5 + }, + { + "first": 195, + "second": 92, + "amount": -4 + }, + { + "first": 195, + "second": 97, + "amount": 0 + }, + { + "first": 195, + "second": 99, + "amount": 0 + }, + { + "first": 195, + "second": 100, + "amount": 0 + }, + { + "first": 195, + "second": 101, + "amount": 0 + }, + { + "first": 195, + "second": 102, + "amount": 0 + }, + { + "first": 195, + "second": 103, + "amount": 0 + }, + { + "first": 195, + "second": 106, + "amount": 3 + }, + { + "first": 195, + "second": 111, + "amount": 0 + }, + { + "first": 195, + "second": 113, + "amount": 0 + }, + { + "first": 195, + "second": 115, + "amount": 0 + }, + { + "first": 195, + "second": 116, + "amount": -1 + }, + { + "first": 195, + "second": 117, + "amount": 0 + }, + { + "first": 195, + "second": 118, + "amount": -2 + }, + { + "first": 195, + "second": 119, + "amount": -2 + }, + { + "first": 195, + "second": 121, + "amount": -2 + }, + { + "first": 195, + "second": 169, + "amount": -2 + }, + { + "first": 195, + "second": 171, + "amount": 0 + }, + { + "first": 195, + "second": 174, + "amount": -2 + }, + { + "first": 195, + "second": 199, + "amount": -2 + }, + { + "first": 195, + "second": 200, + "amount": -1 + }, + { + "first": 195, + "second": 201, + "amount": -1 + }, + { + "first": 195, + "second": 202, + "amount": -1 + }, + { + "first": 195, + "second": 203, + "amount": -1 + }, + { + "first": 195, + "second": 204, + "amount": -1 + }, + { + "first": 195, + "second": 205, + "amount": -1 + }, + { + "first": 195, + "second": 206, + "amount": -1 + }, + { + "first": 195, + "second": 207, + "amount": -1 + }, + { + "first": 195, + "second": 209, + "amount": -1 + }, + { + "first": 195, + "second": 210, + "amount": -2 + }, + { + "first": 195, + "second": 211, + "amount": -2 + }, + { + "first": 195, + "second": 212, + "amount": -2 + }, + { + "first": 195, + "second": 213, + "amount": -2 + }, + { + "first": 195, + "second": 214, + "amount": -2 + }, + { + "first": 195, + "second": 216, + "amount": -2 + }, + { + "first": 195, + "second": 217, + "amount": -1 + }, + { + "first": 195, + "second": 218, + "amount": -1 + }, + { + "first": 195, + "second": 219, + "amount": -1 + }, + { + "first": 195, + "second": 220, + "amount": -1 + }, + { + "first": 195, + "second": 221, + "amount": -5 + }, + { + "first": 195, + "second": 222, + "amount": -1 + }, + { + "first": 195, + "second": 230, + "amount": 0 + }, + { + "first": 195, + "second": 231, + "amount": 0 + }, + { + "first": 195, + "second": 232, + "amount": 0 + }, + { + "first": 195, + "second": 233, + "amount": 0 + }, + { + "first": 195, + "second": 234, + "amount": 0 + }, + { + "first": 195, + "second": 235, + "amount": 0 + }, + { + "first": 195, + "second": 240, + "amount": 0 + }, + { + "first": 195, + "second": 242, + "amount": 0 + }, + { + "first": 195, + "second": 243, + "amount": 0 + }, + { + "first": 195, + "second": 244, + "amount": 0 + }, + { + "first": 195, + "second": 245, + "amount": 0 + }, + { + "first": 195, + "second": 246, + "amount": 0 + }, + { + "first": 195, + "second": 248, + "amount": 0 + }, + { + "first": 195, + "second": 250, + "amount": 0 + }, + { + "first": 195, + "second": 338, + "amount": -2 + }, + { + "first": 195, + "second": 339, + "amount": 0 + }, + { + "first": 195, + "second": 376, + "amount": -5 + }, + { + "first": 196, + "second": 32, + "amount": -2 + }, + { + "first": 196, + "second": 38, + "amount": -1 + }, + { + "first": 196, + "second": 41, + "amount": -1 + }, + { + "first": 196, + "second": 42, + "amount": -4 + }, + { + "first": 196, + "second": 48, + "amount": -1 + }, + { + "first": 196, + "second": 49, + "amount": 0 + }, + { + "first": 196, + "second": 53, + "amount": 0 + }, + { + "first": 196, + "second": 54, + "amount": -1 + }, + { + "first": 196, + "second": 55, + "amount": -3 + }, + { + "first": 196, + "second": 56, + "amount": -1 + }, + { + "first": 196, + "second": 57, + "amount": -2 + }, + { + "first": 196, + "second": 64, + "amount": -2 + }, + { + "first": 196, + "second": 66, + "amount": -1 + }, + { + "first": 196, + "second": 67, + "amount": -2 + }, + { + "first": 196, + "second": 68, + "amount": -1 + }, + { + "first": 196, + "second": 69, + "amount": -1 + }, + { + "first": 196, + "second": 70, + "amount": -1 + }, + { + "first": 196, + "second": 71, + "amount": -2 + }, + { + "first": 196, + "second": 72, + "amount": -1 + }, + { + "first": 196, + "second": 73, + "amount": -1 + }, + { + "first": 196, + "second": 74, + "amount": 0 + }, + { + "first": 196, + "second": 75, + "amount": -1 + }, + { + "first": 196, + "second": 76, + "amount": -1 + }, + { + "first": 196, + "second": 77, + "amount": -1 + }, + { + "first": 196, + "second": 78, + "amount": -1 + }, + { + "first": 196, + "second": 79, + "amount": -2 + }, + { + "first": 196, + "second": 80, + "amount": -1 + }, + { + "first": 196, + "second": 81, + "amount": -2 + }, + { + "first": 196, + "second": 82, + "amount": -1 + }, + { + "first": 196, + "second": 83, + "amount": -1 + }, + { + "first": 196, + "second": 84, + "amount": -5 + }, + { + "first": 196, + "second": 85, + "amount": -1 + }, + { + "first": 196, + "second": 86, + "amount": -5 + }, + { + "first": 196, + "second": 87, + "amount": -2 + }, + { + "first": 196, + "second": 88, + "amount": -1 + }, + { + "first": 196, + "second": 89, + "amount": -5 + }, + { + "first": 196, + "second": 92, + "amount": -4 + }, + { + "first": 196, + "second": 97, + "amount": 0 + }, + { + "first": 196, + "second": 99, + "amount": 0 + }, + { + "first": 196, + "second": 100, + "amount": 0 + }, + { + "first": 196, + "second": 101, + "amount": 0 + }, + { + "first": 196, + "second": 102, + "amount": 0 + }, + { + "first": 196, + "second": 103, + "amount": 0 + }, + { + "first": 196, + "second": 106, + "amount": 3 + }, + { + "first": 196, + "second": 111, + "amount": 0 + }, + { + "first": 196, + "second": 113, + "amount": 0 + }, + { + "first": 196, + "second": 115, + "amount": 0 + }, + { + "first": 196, + "second": 116, + "amount": -1 + }, + { + "first": 196, + "second": 117, + "amount": 0 + }, + { + "first": 196, + "second": 118, + "amount": -2 + }, + { + "first": 196, + "second": 119, + "amount": -2 + }, + { + "first": 196, + "second": 121, + "amount": -2 + }, + { + "first": 196, + "second": 169, + "amount": -2 + }, + { + "first": 196, + "second": 171, + "amount": 0 + }, + { + "first": 196, + "second": 174, + "amount": -2 + }, + { + "first": 196, + "second": 199, + "amount": -2 + }, + { + "first": 196, + "second": 200, + "amount": -1 + }, + { + "first": 196, + "second": 201, + "amount": -1 + }, + { + "first": 196, + "second": 202, + "amount": -1 + }, + { + "first": 196, + "second": 203, + "amount": -1 + }, + { + "first": 196, + "second": 204, + "amount": -1 + }, + { + "first": 196, + "second": 205, + "amount": -1 + }, + { + "first": 196, + "second": 206, + "amount": -1 + }, + { + "first": 196, + "second": 207, + "amount": -1 + }, + { + "first": 196, + "second": 209, + "amount": -1 + }, + { + "first": 196, + "second": 210, + "amount": -2 + }, + { + "first": 196, + "second": 211, + "amount": -2 + }, + { + "first": 196, + "second": 212, + "amount": -2 + }, + { + "first": 196, + "second": 213, + "amount": -2 + }, + { + "first": 196, + "second": 214, + "amount": -2 + }, + { + "first": 196, + "second": 216, + "amount": -2 + }, + { + "first": 196, + "second": 217, + "amount": -1 + }, + { + "first": 196, + "second": 218, + "amount": -1 + }, + { + "first": 196, + "second": 219, + "amount": -1 + }, + { + "first": 196, + "second": 220, + "amount": -1 + }, + { + "first": 196, + "second": 221, + "amount": -5 + }, + { + "first": 196, + "second": 222, + "amount": -1 + }, + { + "first": 196, + "second": 230, + "amount": 0 + }, + { + "first": 196, + "second": 231, + "amount": 0 + }, + { + "first": 196, + "second": 232, + "amount": 0 + }, + { + "first": 196, + "second": 233, + "amount": 0 + }, + { + "first": 196, + "second": 234, + "amount": 0 + }, + { + "first": 196, + "second": 235, + "amount": 0 + }, + { + "first": 196, + "second": 240, + "amount": 0 + }, + { + "first": 196, + "second": 242, + "amount": 0 + }, + { + "first": 196, + "second": 243, + "amount": 0 + }, + { + "first": 196, + "second": 244, + "amount": 0 + }, + { + "first": 196, + "second": 245, + "amount": 0 + }, + { + "first": 196, + "second": 246, + "amount": 0 + }, + { + "first": 196, + "second": 248, + "amount": 0 + }, + { + "first": 196, + "second": 250, + "amount": 0 + }, + { + "first": 196, + "second": 338, + "amount": -2 + }, + { + "first": 196, + "second": 339, + "amount": 0 + }, + { + "first": 196, + "second": 376, + "amount": -5 + }, + { + "first": 197, + "second": 32, + "amount": -2 + }, + { + "first": 197, + "second": 38, + "amount": -1 + }, + { + "first": 197, + "second": 41, + "amount": -1 + }, + { + "first": 197, + "second": 42, + "amount": -4 + }, + { + "first": 197, + "second": 48, + "amount": -1 + }, + { + "first": 197, + "second": 49, + "amount": 0 + }, + { + "first": 197, + "second": 53, + "amount": 0 + }, + { + "first": 197, + "second": 54, + "amount": -1 + }, + { + "first": 197, + "second": 55, + "amount": -3 + }, + { + "first": 197, + "second": 56, + "amount": -1 + }, + { + "first": 197, + "second": 57, + "amount": -2 + }, + { + "first": 197, + "second": 64, + "amount": -2 + }, + { + "first": 197, + "second": 66, + "amount": -1 + }, + { + "first": 197, + "second": 67, + "amount": -2 + }, + { + "first": 197, + "second": 68, + "amount": -1 + }, + { + "first": 197, + "second": 69, + "amount": -1 + }, + { + "first": 197, + "second": 70, + "amount": -1 + }, + { + "first": 197, + "second": 71, + "amount": -2 + }, + { + "first": 197, + "second": 72, + "amount": -1 + }, + { + "first": 197, + "second": 73, + "amount": -1 + }, + { + "first": 197, + "second": 74, + "amount": 0 + }, + { + "first": 197, + "second": 75, + "amount": -1 + }, + { + "first": 197, + "second": 76, + "amount": -1 + }, + { + "first": 197, + "second": 77, + "amount": -1 + }, + { + "first": 197, + "second": 78, + "amount": -1 + }, + { + "first": 197, + "second": 79, + "amount": -2 + }, + { + "first": 197, + "second": 80, + "amount": -1 + }, + { + "first": 197, + "second": 81, + "amount": -2 + }, + { + "first": 197, + "second": 82, + "amount": -1 + }, + { + "first": 197, + "second": 83, + "amount": -1 + }, + { + "first": 197, + "second": 84, + "amount": -5 + }, + { + "first": 197, + "second": 85, + "amount": -1 + }, + { + "first": 197, + "second": 86, + "amount": -5 + }, + { + "first": 197, + "second": 87, + "amount": -2 + }, + { + "first": 197, + "second": 88, + "amount": -1 + }, + { + "first": 197, + "second": 89, + "amount": -5 + }, + { + "first": 197, + "second": 92, + "amount": -4 + }, + { + "first": 197, + "second": 97, + "amount": 0 + }, + { + "first": 197, + "second": 99, + "amount": 0 + }, + { + "first": 197, + "second": 100, + "amount": 0 + }, + { + "first": 197, + "second": 101, + "amount": 0 + }, + { + "first": 197, + "second": 102, + "amount": 0 + }, + { + "first": 197, + "second": 103, + "amount": 0 + }, + { + "first": 197, + "second": 106, + "amount": 3 + }, + { + "first": 197, + "second": 111, + "amount": 0 + }, + { + "first": 197, + "second": 113, + "amount": 0 + }, + { + "first": 197, + "second": 115, + "amount": 0 + }, + { + "first": 197, + "second": 116, + "amount": -1 + }, + { + "first": 197, + "second": 117, + "amount": 0 + }, + { + "first": 197, + "second": 118, + "amount": -2 + }, + { + "first": 197, + "second": 119, + "amount": -2 + }, + { + "first": 197, + "second": 121, + "amount": -2 + }, + { + "first": 197, + "second": 169, + "amount": -2 + }, + { + "first": 197, + "second": 171, + "amount": 0 + }, + { + "first": 197, + "second": 174, + "amount": -2 + }, + { + "first": 197, + "second": 199, + "amount": -2 + }, + { + "first": 197, + "second": 200, + "amount": -1 + }, + { + "first": 197, + "second": 201, + "amount": -1 + }, + { + "first": 197, + "second": 202, + "amount": -1 + }, + { + "first": 197, + "second": 203, + "amount": -1 + }, + { + "first": 197, + "second": 204, + "amount": -1 + }, + { + "first": 197, + "second": 205, + "amount": -1 + }, + { + "first": 197, + "second": 206, + "amount": -1 + }, + { + "first": 197, + "second": 207, + "amount": -1 + }, + { + "first": 197, + "second": 209, + "amount": -1 + }, + { + "first": 197, + "second": 210, + "amount": -2 + }, + { + "first": 197, + "second": 211, + "amount": -2 + }, + { + "first": 197, + "second": 212, + "amount": -2 + }, + { + "first": 197, + "second": 213, + "amount": -2 + }, + { + "first": 197, + "second": 214, + "amount": -2 + }, + { + "first": 197, + "second": 216, + "amount": -2 + }, + { + "first": 197, + "second": 217, + "amount": -1 + }, + { + "first": 197, + "second": 218, + "amount": -1 + }, + { + "first": 197, + "second": 219, + "amount": -1 + }, + { + "first": 197, + "second": 220, + "amount": -1 + }, + { + "first": 197, + "second": 221, + "amount": -5 + }, + { + "first": 197, + "second": 222, + "amount": -1 + }, + { + "first": 197, + "second": 230, + "amount": 0 + }, + { + "first": 197, + "second": 231, + "amount": 0 + }, + { + "first": 197, + "second": 232, + "amount": 0 + }, + { + "first": 197, + "second": 233, + "amount": 0 + }, + { + "first": 197, + "second": 234, + "amount": 0 + }, + { + "first": 197, + "second": 235, + "amount": 0 + }, + { + "first": 197, + "second": 240, + "amount": 0 + }, + { + "first": 197, + "second": 242, + "amount": 0 + }, + { + "first": 197, + "second": 243, + "amount": 0 + }, + { + "first": 197, + "second": 244, + "amount": 0 + }, + { + "first": 197, + "second": 245, + "amount": 0 + }, + { + "first": 197, + "second": 246, + "amount": 0 + }, + { + "first": 197, + "second": 248, + "amount": 0 + }, + { + "first": 197, + "second": 250, + "amount": 0 + }, + { + "first": 197, + "second": 338, + "amount": -2 + }, + { + "first": 197, + "second": 339, + "amount": 0 + }, + { + "first": 197, + "second": 376, + "amount": -5 + }, + { + "first": 198, + "second": 38, + "amount": 0 + }, + { + "first": 198, + "second": 64, + "amount": -1 + }, + { + "first": 198, + "second": 67, + "amount": -1 + }, + { + "first": 198, + "second": 71, + "amount": -1 + }, + { + "first": 198, + "second": 74, + "amount": 0 + }, + { + "first": 198, + "second": 79, + "amount": -1 + }, + { + "first": 198, + "second": 81, + "amount": -1 + }, + { + "first": 198, + "second": 87, + "amount": 0 + }, + { + "first": 198, + "second": 97, + "amount": 0 + }, + { + "first": 198, + "second": 99, + "amount": 0 + }, + { + "first": 198, + "second": 100, + "amount": 0 + }, + { + "first": 198, + "second": 101, + "amount": 0 + }, + { + "first": 198, + "second": 102, + "amount": -1 + }, + { + "first": 198, + "second": 103, + "amount": 0 + }, + { + "first": 198, + "second": 106, + "amount": 1 + }, + { + "first": 198, + "second": 111, + "amount": 0 + }, + { + "first": 198, + "second": 113, + "amount": 0 + }, + { + "first": 198, + "second": 115, + "amount": 0 + }, + { + "first": 198, + "second": 116, + "amount": 0 + }, + { + "first": 198, + "second": 118, + "amount": -1 + }, + { + "first": 198, + "second": 119, + "amount": -1 + }, + { + "first": 198, + "second": 121, + "amount": -1 + }, + { + "first": 198, + "second": 169, + "amount": -1 + }, + { + "first": 198, + "second": 171, + "amount": -1 + }, + { + "first": 198, + "second": 174, + "amount": -1 + }, + { + "first": 198, + "second": 180, + "amount": 0 + }, + { + "first": 198, + "second": 199, + "amount": -1 + }, + { + "first": 198, + "second": 210, + "amount": -1 + }, + { + "first": 198, + "second": 211, + "amount": -1 + }, + { + "first": 198, + "second": 212, + "amount": -1 + }, + { + "first": 198, + "second": 213, + "amount": -1 + }, + { + "first": 198, + "second": 214, + "amount": -1 + }, + { + "first": 198, + "second": 216, + "amount": -1 + }, + { + "first": 198, + "second": 224, + "amount": 0 + }, + { + "first": 198, + "second": 225, + "amount": 0 + }, + { + "first": 198, + "second": 226, + "amount": 0 + }, + { + "first": 198, + "second": 227, + "amount": 0 + }, + { + "first": 198, + "second": 228, + "amount": 0 + }, + { + "first": 198, + "second": 229, + "amount": 0 + }, + { + "first": 198, + "second": 230, + "amount": 0 + }, + { + "first": 198, + "second": 231, + "amount": 0 + }, + { + "first": 198, + "second": 248, + "amount": 0 + }, + { + "first": 198, + "second": 338, + "amount": -1 + }, + { + "first": 198, + "second": 339, + "amount": 0 + }, + { + "first": 199, + "second": 47, + "amount": -1 + }, + { + "first": 199, + "second": 64, + "amount": 0 + }, + { + "first": 199, + "second": 65, + "amount": -1 + }, + { + "first": 199, + "second": 67, + "amount": 0 + }, + { + "first": 199, + "second": 71, + "amount": 0 + }, + { + "first": 199, + "second": 74, + "amount": -1 + }, + { + "first": 199, + "second": 79, + "amount": 0 + }, + { + "first": 199, + "second": 81, + "amount": 0 + }, + { + "first": 199, + "second": 84, + "amount": 0 + }, + { + "first": 199, + "second": 86, + "amount": 0 + }, + { + "first": 199, + "second": 87, + "amount": 0 + }, + { + "first": 199, + "second": 88, + "amount": -1 + }, + { + "first": 199, + "second": 89, + "amount": 0 + }, + { + "first": 199, + "second": 90, + "amount": -1 + }, + { + "first": 199, + "second": 99, + "amount": 0 + }, + { + "first": 199, + "second": 100, + "amount": 0 + }, + { + "first": 199, + "second": 101, + "amount": 0 + }, + { + "first": 199, + "second": 103, + "amount": 0 + }, + { + "first": 199, + "second": 111, + "amount": 0 + }, + { + "first": 199, + "second": 113, + "amount": 0 + }, + { + "first": 199, + "second": 115, + "amount": 0 + }, + { + "first": 199, + "second": 118, + "amount": 0 + }, + { + "first": 199, + "second": 119, + "amount": 0 + }, + { + "first": 199, + "second": 120, + "amount": -1 + }, + { + "first": 199, + "second": 121, + "amount": 0 + }, + { + "first": 199, + "second": 122, + "amount": 0 + }, + { + "first": 199, + "second": 169, + "amount": 0 + }, + { + "first": 199, + "second": 171, + "amount": 0 + }, + { + "first": 199, + "second": 174, + "amount": 0 + }, + { + "first": 199, + "second": 187, + "amount": 0 + }, + { + "first": 199, + "second": 192, + "amount": -1 + }, + { + "first": 199, + "second": 193, + "amount": -1 + }, + { + "first": 199, + "second": 194, + "amount": -1 + }, + { + "first": 199, + "second": 195, + "amount": -1 + }, + { + "first": 199, + "second": 196, + "amount": -1 + }, + { + "first": 199, + "second": 197, + "amount": -1 + }, + { + "first": 199, + "second": 199, + "amount": 0 + }, + { + "first": 199, + "second": 210, + "amount": 0 + }, + { + "first": 199, + "second": 211, + "amount": 0 + }, + { + "first": 199, + "second": 212, + "amount": 0 + }, + { + "first": 199, + "second": 213, + "amount": 0 + }, + { + "first": 199, + "second": 214, + "amount": 0 + }, + { + "first": 199, + "second": 216, + "amount": 0 + }, + { + "first": 199, + "second": 221, + "amount": 0 + }, + { + "first": 199, + "second": 231, + "amount": 0 + }, + { + "first": 199, + "second": 248, + "amount": 0 + }, + { + "first": 199, + "second": 338, + "amount": 0 + }, + { + "first": 199, + "second": 339, + "amount": 0 + }, + { + "first": 199, + "second": 376, + "amount": 0 + }, + { + "first": 200, + "second": 38, + "amount": 0 + }, + { + "first": 200, + "second": 64, + "amount": -1 + }, + { + "first": 200, + "second": 67, + "amount": -1 + }, + { + "first": 200, + "second": 71, + "amount": -1 + }, + { + "first": 200, + "second": 74, + "amount": 0 + }, + { + "first": 200, + "second": 79, + "amount": -1 + }, + { + "first": 200, + "second": 81, + "amount": -1 + }, + { + "first": 200, + "second": 87, + "amount": 0 + }, + { + "first": 200, + "second": 97, + "amount": 0 + }, + { + "first": 200, + "second": 99, + "amount": 0 + }, + { + "first": 200, + "second": 100, + "amount": 0 + }, + { + "first": 200, + "second": 101, + "amount": 0 + }, + { + "first": 200, + "second": 102, + "amount": -1 + }, + { + "first": 200, + "second": 103, + "amount": 0 + }, + { + "first": 200, + "second": 106, + "amount": 1 + }, + { + "first": 200, + "second": 111, + "amount": 0 + }, + { + "first": 200, + "second": 113, + "amount": 0 + }, + { + "first": 200, + "second": 115, + "amount": 0 + }, + { + "first": 200, + "second": 116, + "amount": 0 + }, + { + "first": 200, + "second": 118, + "amount": -1 + }, + { + "first": 200, + "second": 119, + "amount": -1 + }, + { + "first": 200, + "second": 121, + "amount": -1 + }, + { + "first": 200, + "second": 169, + "amount": -1 + }, + { + "first": 200, + "second": 171, + "amount": -1 + }, + { + "first": 200, + "second": 174, + "amount": -1 + }, + { + "first": 200, + "second": 180, + "amount": 0 + }, + { + "first": 200, + "second": 199, + "amount": -1 + }, + { + "first": 200, + "second": 210, + "amount": -1 + }, + { + "first": 200, + "second": 211, + "amount": -1 + }, + { + "first": 200, + "second": 212, + "amount": -1 + }, + { + "first": 200, + "second": 213, + "amount": -1 + }, + { + "first": 200, + "second": 214, + "amount": -1 + }, + { + "first": 200, + "second": 216, + "amount": -1 + }, + { + "first": 200, + "second": 224, + "amount": 0 + }, + { + "first": 200, + "second": 225, + "amount": 0 + }, + { + "first": 200, + "second": 226, + "amount": 0 + }, + { + "first": 200, + "second": 227, + "amount": 0 + }, + { + "first": 200, + "second": 228, + "amount": 0 + }, + { + "first": 200, + "second": 229, + "amount": 0 + }, + { + "first": 200, + "second": 230, + "amount": 0 + }, + { + "first": 200, + "second": 231, + "amount": 0 + }, + { + "first": 200, + "second": 248, + "amount": 0 + }, + { + "first": 200, + "second": 338, + "amount": -1 + }, + { + "first": 200, + "second": 339, + "amount": 0 + }, + { + "first": 201, + "second": 38, + "amount": 0 + }, + { + "first": 201, + "second": 64, + "amount": -1 + }, + { + "first": 201, + "second": 67, + "amount": -1 + }, + { + "first": 201, + "second": 71, + "amount": -1 + }, + { + "first": 201, + "second": 74, + "amount": 0 + }, + { + "first": 201, + "second": 79, + "amount": -1 + }, + { + "first": 201, + "second": 81, + "amount": -1 + }, + { + "first": 201, + "second": 87, + "amount": 0 + }, + { + "first": 201, + "second": 97, + "amount": 0 + }, + { + "first": 201, + "second": 99, + "amount": 0 + }, + { + "first": 201, + "second": 100, + "amount": 0 + }, + { + "first": 201, + "second": 101, + "amount": 0 + }, + { + "first": 201, + "second": 102, + "amount": -1 + }, + { + "first": 201, + "second": 103, + "amount": 0 + }, + { + "first": 201, + "second": 106, + "amount": 1 + }, + { + "first": 201, + "second": 111, + "amount": 0 + }, + { + "first": 201, + "second": 113, + "amount": 0 + }, + { + "first": 201, + "second": 115, + "amount": 0 + }, + { + "first": 201, + "second": 116, + "amount": 0 + }, + { + "first": 201, + "second": 118, + "amount": -1 + }, + { + "first": 201, + "second": 119, + "amount": -1 + }, + { + "first": 201, + "second": 121, + "amount": -1 + }, + { + "first": 201, + "second": 169, + "amount": -1 + }, + { + "first": 201, + "second": 171, + "amount": -1 + }, + { + "first": 201, + "second": 174, + "amount": -1 + }, + { + "first": 201, + "second": 180, + "amount": 0 + }, + { + "first": 201, + "second": 199, + "amount": -1 + }, + { + "first": 201, + "second": 210, + "amount": -1 + }, + { + "first": 201, + "second": 211, + "amount": -1 + }, + { + "first": 201, + "second": 212, + "amount": -1 + }, + { + "first": 201, + "second": 213, + "amount": -1 + }, + { + "first": 201, + "second": 214, + "amount": -1 + }, + { + "first": 201, + "second": 216, + "amount": -1 + }, + { + "first": 201, + "second": 224, + "amount": 0 + }, + { + "first": 201, + "second": 225, + "amount": 0 + }, + { + "first": 201, + "second": 226, + "amount": 0 + }, + { + "first": 201, + "second": 227, + "amount": 0 + }, + { + "first": 201, + "second": 228, + "amount": 0 + }, + { + "first": 201, + "second": 229, + "amount": 0 + }, + { + "first": 201, + "second": 230, + "amount": 0 + }, + { + "first": 201, + "second": 231, + "amount": 0 + }, + { + "first": 201, + "second": 248, + "amount": 0 + }, + { + "first": 201, + "second": 338, + "amount": -1 + }, + { + "first": 201, + "second": 339, + "amount": 0 + }, + { + "first": 202, + "second": 38, + "amount": 0 + }, + { + "first": 202, + "second": 64, + "amount": -1 + }, + { + "first": 202, + "second": 67, + "amount": -1 + }, + { + "first": 202, + "second": 71, + "amount": -1 + }, + { + "first": 202, + "second": 74, + "amount": 0 + }, + { + "first": 202, + "second": 79, + "amount": -1 + }, + { + "first": 202, + "second": 81, + "amount": -1 + }, + { + "first": 202, + "second": 87, + "amount": 0 + }, + { + "first": 202, + "second": 97, + "amount": 0 + }, + { + "first": 202, + "second": 99, + "amount": 0 + }, + { + "first": 202, + "second": 100, + "amount": 0 + }, + { + "first": 202, + "second": 101, + "amount": 0 + }, + { + "first": 202, + "second": 102, + "amount": -1 + }, + { + "first": 202, + "second": 103, + "amount": 0 + }, + { + "first": 202, + "second": 106, + "amount": 1 + }, + { + "first": 202, + "second": 111, + "amount": 0 + }, + { + "first": 202, + "second": 113, + "amount": 0 + }, + { + "first": 202, + "second": 115, + "amount": 0 + }, + { + "first": 202, + "second": 116, + "amount": 0 + }, + { + "first": 202, + "second": 118, + "amount": -1 + }, + { + "first": 202, + "second": 119, + "amount": -1 + }, + { + "first": 202, + "second": 121, + "amount": -1 + }, + { + "first": 202, + "second": 169, + "amount": -1 + }, + { + "first": 202, + "second": 171, + "amount": -1 + }, + { + "first": 202, + "second": 174, + "amount": -1 + }, + { + "first": 202, + "second": 180, + "amount": 0 + }, + { + "first": 202, + "second": 199, + "amount": -1 + }, + { + "first": 202, + "second": 210, + "amount": -1 + }, + { + "first": 202, + "second": 211, + "amount": -1 + }, + { + "first": 202, + "second": 212, + "amount": -1 + }, + { + "first": 202, + "second": 213, + "amount": -1 + }, + { + "first": 202, + "second": 214, + "amount": -1 + }, + { + "first": 202, + "second": 216, + "amount": -1 + }, + { + "first": 202, + "second": 224, + "amount": 0 + }, + { + "first": 202, + "second": 225, + "amount": 0 + }, + { + "first": 202, + "second": 226, + "amount": 0 + }, + { + "first": 202, + "second": 227, + "amount": 0 + }, + { + "first": 202, + "second": 228, + "amount": 0 + }, + { + "first": 202, + "second": 229, + "amount": 0 + }, + { + "first": 202, + "second": 230, + "amount": 0 + }, + { + "first": 202, + "second": 231, + "amount": 0 + }, + { + "first": 202, + "second": 248, + "amount": 0 + }, + { + "first": 202, + "second": 338, + "amount": -1 + }, + { + "first": 202, + "second": 339, + "amount": 0 + }, + { + "first": 203, + "second": 38, + "amount": 0 + }, + { + "first": 203, + "second": 64, + "amount": -1 + }, + { + "first": 203, + "second": 67, + "amount": -1 + }, + { + "first": 203, + "second": 71, + "amount": -1 + }, + { + "first": 203, + "second": 74, + "amount": 0 + }, + { + "first": 203, + "second": 79, + "amount": -1 + }, + { + "first": 203, + "second": 81, + "amount": -1 + }, + { + "first": 203, + "second": 87, + "amount": 0 + }, + { + "first": 203, + "second": 97, + "amount": 0 + }, + { + "first": 203, + "second": 99, + "amount": 0 + }, + { + "first": 203, + "second": 100, + "amount": 0 + }, + { + "first": 203, + "second": 101, + "amount": 0 + }, + { + "first": 203, + "second": 102, + "amount": -1 + }, + { + "first": 203, + "second": 103, + "amount": 0 + }, + { + "first": 203, + "second": 106, + "amount": 1 + }, + { + "first": 203, + "second": 111, + "amount": 0 + }, + { + "first": 203, + "second": 113, + "amount": 0 + }, + { + "first": 203, + "second": 115, + "amount": 0 + }, + { + "first": 203, + "second": 116, + "amount": 0 + }, + { + "first": 203, + "second": 118, + "amount": -1 + }, + { + "first": 203, + "second": 119, + "amount": -1 + }, + { + "first": 203, + "second": 121, + "amount": -1 + }, + { + "first": 203, + "second": 169, + "amount": -1 + }, + { + "first": 203, + "second": 171, + "amount": -1 + }, + { + "first": 203, + "second": 174, + "amount": -1 + }, + { + "first": 203, + "second": 180, + "amount": 0 + }, + { + "first": 203, + "second": 199, + "amount": -1 + }, + { + "first": 203, + "second": 210, + "amount": -1 + }, + { + "first": 203, + "second": 211, + "amount": -1 + }, + { + "first": 203, + "second": 212, + "amount": -1 + }, + { + "first": 203, + "second": 213, + "amount": -1 + }, + { + "first": 203, + "second": 214, + "amount": -1 + }, + { + "first": 203, + "second": 216, + "amount": -1 + }, + { + "first": 203, + "second": 224, + "amount": 0 + }, + { + "first": 203, + "second": 225, + "amount": 0 + }, + { + "first": 203, + "second": 226, + "amount": 0 + }, + { + "first": 203, + "second": 227, + "amount": 0 + }, + { + "first": 203, + "second": 228, + "amount": 0 + }, + { + "first": 203, + "second": 229, + "amount": 0 + }, + { + "first": 203, + "second": 230, + "amount": 0 + }, + { + "first": 203, + "second": 231, + "amount": 0 + }, + { + "first": 203, + "second": 248, + "amount": 0 + }, + { + "first": 203, + "second": 338, + "amount": -1 + }, + { + "first": 203, + "second": 339, + "amount": 0 + }, + { + "first": 204, + "second": 65, + "amount": -1 + }, + { + "first": 204, + "second": 74, + "amount": 0 + }, + { + "first": 204, + "second": 86, + "amount": -1 + }, + { + "first": 204, + "second": 87, + "amount": -1 + }, + { + "first": 204, + "second": 89, + "amount": -1 + }, + { + "first": 204, + "second": 192, + "amount": -1 + }, + { + "first": 204, + "second": 193, + "amount": -1 + }, + { + "first": 204, + "second": 194, + "amount": -1 + }, + { + "first": 204, + "second": 195, + "amount": -1 + }, + { + "first": 204, + "second": 196, + "amount": -1 + }, + { + "first": 204, + "second": 197, + "amount": -1 + }, + { + "first": 204, + "second": 221, + "amount": -1 + }, + { + "first": 204, + "second": 376, + "amount": -1 + }, + { + "first": 205, + "second": 65, + "amount": -1 + }, + { + "first": 205, + "second": 74, + "amount": 0 + }, + { + "first": 205, + "second": 86, + "amount": -1 + }, + { + "first": 205, + "second": 87, + "amount": -1 + }, + { + "first": 205, + "second": 89, + "amount": -1 + }, + { + "first": 205, + "second": 192, + "amount": -1 + }, + { + "first": 205, + "second": 193, + "amount": -1 + }, + { + "first": 205, + "second": 194, + "amount": -1 + }, + { + "first": 205, + "second": 195, + "amount": -1 + }, + { + "first": 205, + "second": 196, + "amount": -1 + }, + { + "first": 205, + "second": 197, + "amount": -1 + }, + { + "first": 205, + "second": 221, + "amount": -1 + }, + { + "first": 205, + "second": 376, + "amount": -1 + }, + { + "first": 206, + "second": 65, + "amount": -1 + }, + { + "first": 206, + "second": 74, + "amount": 0 + }, + { + "first": 206, + "second": 86, + "amount": -1 + }, + { + "first": 206, + "second": 87, + "amount": -1 + }, + { + "first": 206, + "second": 89, + "amount": -1 + }, + { + "first": 206, + "second": 192, + "amount": -1 + }, + { + "first": 206, + "second": 193, + "amount": -1 + }, + { + "first": 206, + "second": 194, + "amount": -1 + }, + { + "first": 206, + "second": 195, + "amount": -1 + }, + { + "first": 206, + "second": 196, + "amount": -1 + }, + { + "first": 206, + "second": 197, + "amount": -1 + }, + { + "first": 206, + "second": 221, + "amount": -1 + }, + { + "first": 206, + "second": 376, + "amount": -1 + }, + { + "first": 207, + "second": 65, + "amount": -1 + }, + { + "first": 207, + "second": 74, + "amount": 0 + }, + { + "first": 207, + "second": 86, + "amount": -1 + }, + { + "first": 207, + "second": 87, + "amount": -1 + }, + { + "first": 207, + "second": 89, + "amount": -1 + }, + { + "first": 207, + "second": 192, + "amount": -1 + }, + { + "first": 207, + "second": 193, + "amount": -1 + }, + { + "first": 207, + "second": 194, + "amount": -1 + }, + { + "first": 207, + "second": 195, + "amount": -1 + }, + { + "first": 207, + "second": 196, + "amount": -1 + }, + { + "first": 207, + "second": 197, + "amount": -1 + }, + { + "first": 207, + "second": 221, + "amount": -1 + }, + { + "first": 207, + "second": 376, + "amount": -1 + }, + { + "first": 208, + "second": 44, + "amount": -1 + }, + { + "first": 208, + "second": 46, + "amount": -1 + }, + { + "first": 208, + "second": 47, + "amount": -1 + }, + { + "first": 208, + "second": 65, + "amount": -2 + }, + { + "first": 208, + "second": 74, + "amount": 0 + }, + { + "first": 208, + "second": 84, + "amount": -2 + }, + { + "first": 208, + "second": 86, + "amount": -3 + }, + { + "first": 208, + "second": 87, + "amount": -1 + }, + { + "first": 208, + "second": 88, + "amount": -1 + }, + { + "first": 208, + "second": 89, + "amount": -3 + }, + { + "first": 208, + "second": 90, + "amount": -1 + }, + { + "first": 208, + "second": 97, + "amount": 0 + }, + { + "first": 208, + "second": 99, + "amount": 0 + }, + { + "first": 208, + "second": 100, + "amount": 0 + }, + { + "first": 208, + "second": 101, + "amount": 0 + }, + { + "first": 208, + "second": 103, + "amount": 0 + }, + { + "first": 208, + "second": 111, + "amount": 0 + }, + { + "first": 208, + "second": 113, + "amount": 0 + }, + { + "first": 208, + "second": 115, + "amount": 0 + }, + { + "first": 208, + "second": 118, + "amount": 0 + }, + { + "first": 208, + "second": 119, + "amount": 0 + }, + { + "first": 208, + "second": 120, + "amount": 0 + }, + { + "first": 208, + "second": 121, + "amount": 0 + }, + { + "first": 208, + "second": 122, + "amount": 0 + }, + { + "first": 208, + "second": 192, + "amount": -2 + }, + { + "first": 208, + "second": 193, + "amount": -2 + }, + { + "first": 208, + "second": 194, + "amount": -2 + }, + { + "first": 208, + "second": 195, + "amount": -2 + }, + { + "first": 208, + "second": 196, + "amount": -2 + }, + { + "first": 208, + "second": 197, + "amount": -2 + }, + { + "first": 208, + "second": 221, + "amount": -3 + }, + { + "first": 208, + "second": 230, + "amount": 0 + }, + { + "first": 208, + "second": 231, + "amount": 0 + }, + { + "first": 208, + "second": 248, + "amount": 0 + }, + { + "first": 208, + "second": 339, + "amount": 0 + }, + { + "first": 208, + "second": 376, + "amount": -3 + }, + { + "first": 209, + "second": 65, + "amount": -1 + }, + { + "first": 209, + "second": 74, + "amount": 0 + }, + { + "first": 209, + "second": 86, + "amount": -1 + }, + { + "first": 209, + "second": 87, + "amount": -1 + }, + { + "first": 209, + "second": 89, + "amount": -1 + }, + { + "first": 209, + "second": 192, + "amount": -1 + }, + { + "first": 209, + "second": 193, + "amount": -1 + }, + { + "first": 209, + "second": 194, + "amount": -1 + }, + { + "first": 209, + "second": 195, + "amount": -1 + }, + { + "first": 209, + "second": 196, + "amount": -1 + }, + { + "first": 209, + "second": 197, + "amount": -1 + }, + { + "first": 209, + "second": 221, + "amount": -1 + }, + { + "first": 209, + "second": 376, + "amount": -1 + }, + { + "first": 210, + "second": 44, + "amount": -1 + }, + { + "first": 210, + "second": 46, + "amount": -1 + }, + { + "first": 210, + "second": 47, + "amount": -1 + }, + { + "first": 210, + "second": 65, + "amount": -2 + }, + { + "first": 210, + "second": 74, + "amount": 0 + }, + { + "first": 210, + "second": 84, + "amount": -2 + }, + { + "first": 210, + "second": 86, + "amount": -3 + }, + { + "first": 210, + "second": 87, + "amount": -1 + }, + { + "first": 210, + "second": 88, + "amount": -1 + }, + { + "first": 210, + "second": 89, + "amount": -3 + }, + { + "first": 210, + "second": 90, + "amount": -1 + }, + { + "first": 210, + "second": 97, + "amount": 0 + }, + { + "first": 210, + "second": 99, + "amount": 0 + }, + { + "first": 210, + "second": 100, + "amount": 0 + }, + { + "first": 210, + "second": 101, + "amount": 0 + }, + { + "first": 210, + "second": 103, + "amount": 0 + }, + { + "first": 210, + "second": 111, + "amount": 0 + }, + { + "first": 210, + "second": 113, + "amount": 0 + }, + { + "first": 210, + "second": 115, + "amount": 0 + }, + { + "first": 210, + "second": 118, + "amount": 0 + }, + { + "first": 210, + "second": 119, + "amount": 0 + }, + { + "first": 210, + "second": 120, + "amount": 0 + }, + { + "first": 210, + "second": 121, + "amount": 0 + }, + { + "first": 210, + "second": 122, + "amount": 0 + }, + { + "first": 210, + "second": 192, + "amount": -2 + }, + { + "first": 210, + "second": 193, + "amount": -2 + }, + { + "first": 210, + "second": 194, + "amount": -2 + }, + { + "first": 210, + "second": 195, + "amount": -2 + }, + { + "first": 210, + "second": 196, + "amount": -2 + }, + { + "first": 210, + "second": 197, + "amount": -2 + }, + { + "first": 210, + "second": 221, + "amount": -3 + }, + { + "first": 210, + "second": 230, + "amount": 0 + }, + { + "first": 210, + "second": 231, + "amount": 0 + }, + { + "first": 210, + "second": 248, + "amount": 0 + }, + { + "first": 210, + "second": 339, + "amount": 0 + }, + { + "first": 210, + "second": 376, + "amount": -3 + }, + { + "first": 211, + "second": 44, + "amount": -1 + }, + { + "first": 211, + "second": 46, + "amount": -1 + }, + { + "first": 211, + "second": 47, + "amount": -1 + }, + { + "first": 211, + "second": 65, + "amount": -2 + }, + { + "first": 211, + "second": 74, + "amount": 0 + }, + { + "first": 211, + "second": 84, + "amount": -2 + }, + { + "first": 211, + "second": 86, + "amount": -3 + }, + { + "first": 211, + "second": 87, + "amount": -1 + }, + { + "first": 211, + "second": 88, + "amount": -1 + }, + { + "first": 211, + "second": 89, + "amount": -3 + }, + { + "first": 211, + "second": 90, + "amount": -1 + }, + { + "first": 211, + "second": 97, + "amount": 0 + }, + { + "first": 211, + "second": 99, + "amount": 0 + }, + { + "first": 211, + "second": 100, + "amount": 0 + }, + { + "first": 211, + "second": 101, + "amount": 0 + }, + { + "first": 211, + "second": 103, + "amount": 0 + }, + { + "first": 211, + "second": 111, + "amount": 0 + }, + { + "first": 211, + "second": 113, + "amount": 0 + }, + { + "first": 211, + "second": 115, + "amount": 0 + }, + { + "first": 211, + "second": 118, + "amount": 0 + }, + { + "first": 211, + "second": 119, + "amount": 0 + }, + { + "first": 211, + "second": 120, + "amount": 0 + }, + { + "first": 211, + "second": 121, + "amount": 0 + }, + { + "first": 211, + "second": 122, + "amount": 0 + }, + { + "first": 211, + "second": 192, + "amount": -2 + }, + { + "first": 211, + "second": 193, + "amount": -2 + }, + { + "first": 211, + "second": 194, + "amount": -2 + }, + { + "first": 211, + "second": 195, + "amount": -2 + }, + { + "first": 211, + "second": 196, + "amount": -2 + }, + { + "first": 211, + "second": 197, + "amount": -2 + }, + { + "first": 211, + "second": 221, + "amount": -3 + }, + { + "first": 211, + "second": 230, + "amount": 0 + }, + { + "first": 211, + "second": 231, + "amount": 0 + }, + { + "first": 211, + "second": 248, + "amount": 0 + }, + { + "first": 211, + "second": 339, + "amount": 0 + }, + { + "first": 211, + "second": 376, + "amount": -3 + }, + { + "first": 212, + "second": 44, + "amount": -1 + }, + { + "first": 212, + "second": 46, + "amount": -1 + }, + { + "first": 212, + "second": 47, + "amount": -1 + }, + { + "first": 212, + "second": 65, + "amount": -2 + }, + { + "first": 212, + "second": 74, + "amount": 0 + }, + { + "first": 212, + "second": 84, + "amount": -2 + }, + { + "first": 212, + "second": 86, + "amount": -3 + }, + { + "first": 212, + "second": 87, + "amount": -1 + }, + { + "first": 212, + "second": 88, + "amount": -1 + }, + { + "first": 212, + "second": 89, + "amount": -3 + }, + { + "first": 212, + "second": 90, + "amount": -1 + }, + { + "first": 212, + "second": 97, + "amount": 0 + }, + { + "first": 212, + "second": 99, + "amount": 0 + }, + { + "first": 212, + "second": 100, + "amount": 0 + }, + { + "first": 212, + "second": 101, + "amount": 0 + }, + { + "first": 212, + "second": 103, + "amount": 0 + }, + { + "first": 212, + "second": 111, + "amount": 0 + }, + { + "first": 212, + "second": 113, + "amount": 0 + }, + { + "first": 212, + "second": 115, + "amount": 0 + }, + { + "first": 212, + "second": 118, + "amount": 0 + }, + { + "first": 212, + "second": 119, + "amount": 0 + }, + { + "first": 212, + "second": 120, + "amount": 0 + }, + { + "first": 212, + "second": 121, + "amount": 0 + }, + { + "first": 212, + "second": 122, + "amount": 0 + }, + { + "first": 212, + "second": 192, + "amount": -2 + }, + { + "first": 212, + "second": 193, + "amount": -2 + }, + { + "first": 212, + "second": 194, + "amount": -2 + }, + { + "first": 212, + "second": 195, + "amount": -2 + }, + { + "first": 212, + "second": 196, + "amount": -2 + }, + { + "first": 212, + "second": 197, + "amount": -2 + }, + { + "first": 212, + "second": 221, + "amount": -3 + }, + { + "first": 212, + "second": 230, + "amount": 0 + }, + { + "first": 212, + "second": 231, + "amount": 0 + }, + { + "first": 212, + "second": 248, + "amount": 0 + }, + { + "first": 212, + "second": 339, + "amount": 0 + }, + { + "first": 212, + "second": 376, + "amount": -3 + }, + { + "first": 213, + "second": 44, + "amount": -1 + }, + { + "first": 213, + "second": 46, + "amount": -1 + }, + { + "first": 213, + "second": 47, + "amount": -1 + }, + { + "first": 213, + "second": 65, + "amount": -2 + }, + { + "first": 213, + "second": 74, + "amount": 0 + }, + { + "first": 213, + "second": 84, + "amount": -2 + }, + { + "first": 213, + "second": 86, + "amount": -3 + }, + { + "first": 213, + "second": 87, + "amount": -1 + }, + { + "first": 213, + "second": 88, + "amount": -1 + }, + { + "first": 213, + "second": 89, + "amount": -3 + }, + { + "first": 213, + "second": 90, + "amount": -1 + }, + { + "first": 213, + "second": 97, + "amount": 0 + }, + { + "first": 213, + "second": 99, + "amount": 0 + }, + { + "first": 213, + "second": 100, + "amount": 0 + }, + { + "first": 213, + "second": 101, + "amount": 0 + }, + { + "first": 213, + "second": 103, + "amount": 0 + }, + { + "first": 213, + "second": 111, + "amount": 0 + }, + { + "first": 213, + "second": 113, + "amount": 0 + }, + { + "first": 213, + "second": 115, + "amount": 0 + }, + { + "first": 213, + "second": 118, + "amount": 0 + }, + { + "first": 213, + "second": 119, + "amount": 0 + }, + { + "first": 213, + "second": 120, + "amount": 0 + }, + { + "first": 213, + "second": 121, + "amount": 0 + }, + { + "first": 213, + "second": 122, + "amount": 0 + }, + { + "first": 213, + "second": 192, + "amount": -2 + }, + { + "first": 213, + "second": 193, + "amount": -2 + }, + { + "first": 213, + "second": 194, + "amount": -2 + }, + { + "first": 213, + "second": 195, + "amount": -2 + }, + { + "first": 213, + "second": 196, + "amount": -2 + }, + { + "first": 213, + "second": 197, + "amount": -2 + }, + { + "first": 213, + "second": 221, + "amount": -3 + }, + { + "first": 213, + "second": 230, + "amount": 0 + }, + { + "first": 213, + "second": 231, + "amount": 0 + }, + { + "first": 213, + "second": 248, + "amount": 0 + }, + { + "first": 213, + "second": 339, + "amount": 0 + }, + { + "first": 213, + "second": 376, + "amount": -3 + }, + { + "first": 214, + "second": 44, + "amount": -1 + }, + { + "first": 214, + "second": 46, + "amount": -1 + }, + { + "first": 214, + "second": 47, + "amount": -1 + }, + { + "first": 214, + "second": 65, + "amount": -2 + }, + { + "first": 214, + "second": 74, + "amount": 0 + }, + { + "first": 214, + "second": 84, + "amount": -2 + }, + { + "first": 214, + "second": 86, + "amount": -3 + }, + { + "first": 214, + "second": 87, + "amount": -1 + }, + { + "first": 214, + "second": 88, + "amount": -1 + }, + { + "first": 214, + "second": 89, + "amount": -3 + }, + { + "first": 214, + "second": 90, + "amount": -1 + }, + { + "first": 214, + "second": 97, + "amount": 0 + }, + { + "first": 214, + "second": 99, + "amount": 0 + }, + { + "first": 214, + "second": 100, + "amount": 0 + }, + { + "first": 214, + "second": 101, + "amount": 0 + }, + { + "first": 214, + "second": 103, + "amount": 0 + }, + { + "first": 214, + "second": 111, + "amount": 0 + }, + { + "first": 214, + "second": 113, + "amount": 0 + }, + { + "first": 214, + "second": 115, + "amount": 0 + }, + { + "first": 214, + "second": 118, + "amount": 0 + }, + { + "first": 214, + "second": 119, + "amount": 0 + }, + { + "first": 214, + "second": 120, + "amount": 0 + }, + { + "first": 214, + "second": 121, + "amount": 0 + }, + { + "first": 214, + "second": 122, + "amount": 0 + }, + { + "first": 214, + "second": 192, + "amount": -2 + }, + { + "first": 214, + "second": 193, + "amount": -2 + }, + { + "first": 214, + "second": 194, + "amount": -2 + }, + { + "first": 214, + "second": 195, + "amount": -2 + }, + { + "first": 214, + "second": 196, + "amount": -2 + }, + { + "first": 214, + "second": 197, + "amount": -2 + }, + { + "first": 214, + "second": 221, + "amount": -3 + }, + { + "first": 214, + "second": 230, + "amount": 0 + }, + { + "first": 214, + "second": 231, + "amount": 0 + }, + { + "first": 214, + "second": 248, + "amount": 0 + }, + { + "first": 214, + "second": 339, + "amount": 0 + }, + { + "first": 214, + "second": 376, + "amount": -3 + }, + { + "first": 216, + "second": 44, + "amount": -1 + }, + { + "first": 216, + "second": 46, + "amount": -1 + }, + { + "first": 216, + "second": 47, + "amount": -1 + }, + { + "first": 216, + "second": 65, + "amount": -2 + }, + { + "first": 216, + "second": 74, + "amount": 0 + }, + { + "first": 216, + "second": 84, + "amount": -2 + }, + { + "first": 216, + "second": 86, + "amount": -3 + }, + { + "first": 216, + "second": 87, + "amount": -1 + }, + { + "first": 216, + "second": 88, + "amount": -1 + }, + { + "first": 216, + "second": 89, + "amount": -3 + }, + { + "first": 216, + "second": 90, + "amount": -1 + }, + { + "first": 216, + "second": 97, + "amount": 0 + }, + { + "first": 216, + "second": 99, + "amount": 0 + }, + { + "first": 216, + "second": 100, + "amount": 0 + }, + { + "first": 216, + "second": 101, + "amount": 0 + }, + { + "first": 216, + "second": 103, + "amount": 0 + }, + { + "first": 216, + "second": 111, + "amount": 0 + }, + { + "first": 216, + "second": 113, + "amount": 0 + }, + { + "first": 216, + "second": 115, + "amount": 0 + }, + { + "first": 216, + "second": 118, + "amount": 0 + }, + { + "first": 216, + "second": 119, + "amount": 0 + }, + { + "first": 216, + "second": 120, + "amount": 0 + }, + { + "first": 216, + "second": 121, + "amount": 0 + }, + { + "first": 216, + "second": 122, + "amount": 0 + }, + { + "first": 216, + "second": 192, + "amount": -2 + }, + { + "first": 216, + "second": 193, + "amount": -2 + }, + { + "first": 216, + "second": 194, + "amount": -2 + }, + { + "first": 216, + "second": 195, + "amount": -2 + }, + { + "first": 216, + "second": 196, + "amount": -2 + }, + { + "first": 216, + "second": 197, + "amount": -2 + }, + { + "first": 216, + "second": 221, + "amount": -3 + }, + { + "first": 216, + "second": 230, + "amount": 0 + }, + { + "first": 216, + "second": 231, + "amount": 0 + }, + { + "first": 216, + "second": 248, + "amount": 0 + }, + { + "first": 216, + "second": 339, + "amount": 0 + }, + { + "first": 216, + "second": 376, + "amount": -3 + }, + { + "first": 217, + "second": 44, + "amount": -2 + }, + { + "first": 217, + "second": 46, + "amount": -2 + }, + { + "first": 217, + "second": 47, + "amount": -2 + }, + { + "first": 217, + "second": 65, + "amount": -1 + }, + { + "first": 217, + "second": 88, + "amount": 0 + }, + { + "first": 217, + "second": 115, + "amount": 0 + }, + { + "first": 217, + "second": 120, + "amount": 0 + }, + { + "first": 217, + "second": 192, + "amount": -1 + }, + { + "first": 217, + "second": 193, + "amount": -1 + }, + { + "first": 217, + "second": 194, + "amount": -1 + }, + { + "first": 217, + "second": 195, + "amount": -1 + }, + { + "first": 217, + "second": 196, + "amount": -1 + }, + { + "first": 217, + "second": 197, + "amount": -1 + }, + { + "first": 218, + "second": 44, + "amount": -2 + }, + { + "first": 218, + "second": 46, + "amount": -2 + }, + { + "first": 218, + "second": 47, + "amount": -2 + }, + { + "first": 218, + "second": 65, + "amount": -1 + }, + { + "first": 218, + "second": 88, + "amount": 0 + }, + { + "first": 218, + "second": 115, + "amount": 0 + }, + { + "first": 218, + "second": 120, + "amount": 0 + }, + { + "first": 218, + "second": 192, + "amount": -1 + }, + { + "first": 218, + "second": 193, + "amount": -1 + }, + { + "first": 218, + "second": 194, + "amount": -1 + }, + { + "first": 218, + "second": 195, + "amount": -1 + }, + { + "first": 218, + "second": 196, + "amount": -1 + }, + { + "first": 218, + "second": 197, + "amount": -1 + }, + { + "first": 219, + "second": 44, + "amount": -2 + }, + { + "first": 219, + "second": 46, + "amount": -2 + }, + { + "first": 219, + "second": 47, + "amount": -2 + }, + { + "first": 219, + "second": 65, + "amount": -1 + }, + { + "first": 219, + "second": 88, + "amount": 0 + }, + { + "first": 219, + "second": 115, + "amount": 0 + }, + { + "first": 219, + "second": 120, + "amount": 0 + }, + { + "first": 219, + "second": 192, + "amount": -1 + }, + { + "first": 219, + "second": 193, + "amount": -1 + }, + { + "first": 219, + "second": 194, + "amount": -1 + }, + { + "first": 219, + "second": 195, + "amount": -1 + }, + { + "first": 219, + "second": 196, + "amount": -1 + }, + { + "first": 219, + "second": 197, + "amount": -1 + }, + { + "first": 220, + "second": 44, + "amount": -2 + }, + { + "first": 220, + "second": 46, + "amount": -2 + }, + { + "first": 220, + "second": 47, + "amount": -2 + }, + { + "first": 220, + "second": 65, + "amount": -1 + }, + { + "first": 220, + "second": 88, + "amount": 0 + }, + { + "first": 220, + "second": 115, + "amount": 0 + }, + { + "first": 220, + "second": 120, + "amount": 0 + }, + { + "first": 220, + "second": 192, + "amount": -1 + }, + { + "first": 220, + "second": 193, + "amount": -1 + }, + { + "first": 220, + "second": 194, + "amount": -1 + }, + { + "first": 220, + "second": 195, + "amount": -1 + }, + { + "first": 220, + "second": 196, + "amount": -1 + }, + { + "first": 220, + "second": 197, + "amount": -1 + }, + { + "first": 221, + "second": 32, + "amount": -2 + }, + { + "first": 221, + "second": 38, + "amount": -3 + }, + { + "first": 221, + "second": 41, + "amount": -1 + }, + { + "first": 221, + "second": 44, + "amount": -5 + }, + { + "first": 221, + "second": 45, + "amount": -1 + }, + { + "first": 221, + "second": 46, + "amount": -5 + }, + { + "first": 221, + "second": 47, + "amount": -6 + }, + { + "first": 221, + "second": 48, + "amount": -2 + }, + { + "first": 221, + "second": 50, + "amount": -1 + }, + { + "first": 221, + "second": 51, + "amount": -1 + }, + { + "first": 221, + "second": 52, + "amount": -4 + }, + { + "first": 221, + "second": 53, + "amount": -1 + }, + { + "first": 221, + "second": 54, + "amount": -3 + }, + { + "first": 221, + "second": 55, + "amount": 0 + }, + { + "first": 221, + "second": 56, + "amount": -3 + }, + { + "first": 221, + "second": 57, + "amount": -2 + }, + { + "first": 221, + "second": 58, + "amount": -4 + }, + { + "first": 221, + "second": 59, + "amount": -4 + }, + { + "first": 221, + "second": 64, + "amount": -3 + }, + { + "first": 221, + "second": 65, + "amount": -5 + }, + { + "first": 221, + "second": 66, + "amount": -1 + }, + { + "first": 221, + "second": 67, + "amount": -3 + }, + { + "first": 221, + "second": 68, + "amount": -1 + }, + { + "first": 221, + "second": 69, + "amount": -1 + }, + { + "first": 221, + "second": 70, + "amount": -1 + }, + { + "first": 221, + "second": 71, + "amount": -3 + }, + { + "first": 221, + "second": 72, + "amount": -1 + }, + { + "first": 221, + "second": 73, + "amount": -1 + }, + { + "first": 221, + "second": 74, + "amount": -6 + }, + { + "first": 221, + "second": 75, + "amount": -1 + }, + { + "first": 221, + "second": 76, + "amount": -1 + }, + { + "first": 221, + "second": 77, + "amount": -1 + }, + { + "first": 221, + "second": 78, + "amount": -1 + }, + { + "first": 221, + "second": 79, + "amount": -3 + }, + { + "first": 221, + "second": 80, + "amount": -1 + }, + { + "first": 221, + "second": 81, + "amount": -3 + }, + { + "first": 221, + "second": 82, + "amount": -1 + }, + { + "first": 221, + "second": 83, + "amount": -1 + }, + { + "first": 221, + "second": 88, + "amount": -1 + }, + { + "first": 221, + "second": 97, + "amount": -4 + }, + { + "first": 221, + "second": 99, + "amount": -4 + }, + { + "first": 221, + "second": 100, + "amount": -4 + }, + { + "first": 221, + "second": 101, + "amount": -4 + }, + { + "first": 221, + "second": 102, + "amount": -1 + }, + { + "first": 221, + "second": 103, + "amount": -4 + }, + { + "first": 221, + "second": 109, + "amount": -1 + }, + { + "first": 221, + "second": 110, + "amount": -1 + }, + { + "first": 221, + "second": 111, + "amount": -4 + }, + { + "first": 221, + "second": 112, + "amount": -1 + }, + { + "first": 221, + "second": 113, + "amount": -4 + }, + { + "first": 221, + "second": 114, + "amount": -1 + }, + { + "first": 221, + "second": 115, + "amount": -3 + }, + { + "first": 221, + "second": 117, + "amount": -1 + }, + { + "first": 221, + "second": 118, + "amount": -2 + }, + { + "first": 221, + "second": 119, + "amount": -1 + }, + { + "first": 221, + "second": 120, + "amount": -3 + }, + { + "first": 221, + "second": 121, + "amount": -2 + }, + { + "first": 221, + "second": 122, + "amount": -2 + }, + { + "first": 221, + "second": 169, + "amount": -3 + }, + { + "first": 221, + "second": 171, + "amount": -4 + }, + { + "first": 221, + "second": 174, + "amount": -3 + }, + { + "first": 221, + "second": 180, + "amount": -3 + }, + { + "first": 221, + "second": 181, + "amount": -1 + }, + { + "first": 221, + "second": 187, + "amount": -1 + }, + { + "first": 221, + "second": 192, + "amount": -5 + }, + { + "first": 221, + "second": 193, + "amount": -5 + }, + { + "first": 221, + "second": 194, + "amount": -5 + }, + { + "first": 221, + "second": 195, + "amount": -5 + }, + { + "first": 221, + "second": 196, + "amount": -5 + }, + { + "first": 221, + "second": 197, + "amount": -5 + }, + { + "first": 221, + "second": 198, + "amount": -5 + }, + { + "first": 221, + "second": 199, + "amount": -3 + }, + { + "first": 221, + "second": 200, + "amount": -1 + }, + { + "first": 221, + "second": 201, + "amount": -1 + }, + { + "first": 221, + "second": 202, + "amount": -1 + }, + { + "first": 221, + "second": 203, + "amount": -1 + }, + { + "first": 221, + "second": 204, + "amount": -1 + }, + { + "first": 221, + "second": 205, + "amount": -1 + }, + { + "first": 221, + "second": 206, + "amount": -1 + }, + { + "first": 221, + "second": 207, + "amount": -1 + }, + { + "first": 221, + "second": 209, + "amount": -1 + }, + { + "first": 221, + "second": 210, + "amount": -3 + }, + { + "first": 221, + "second": 211, + "amount": -3 + }, + { + "first": 221, + "second": 212, + "amount": -3 + }, + { + "first": 221, + "second": 213, + "amount": -3 + }, + { + "first": 221, + "second": 214, + "amount": -3 + }, + { + "first": 221, + "second": 216, + "amount": -3 + }, + { + "first": 221, + "second": 222, + "amount": -1 + }, + { + "first": 221, + "second": 224, + "amount": -3 + }, + { + "first": 221, + "second": 225, + "amount": -3 + }, + { + "first": 221, + "second": 226, + "amount": -3 + }, + { + "first": 221, + "second": 227, + "amount": -3 + }, + { + "first": 221, + "second": 228, + "amount": -3 + }, + { + "first": 221, + "second": 229, + "amount": -3 + }, + { + "first": 221, + "second": 230, + "amount": -4 + }, + { + "first": 221, + "second": 231, + "amount": -4 + }, + { + "first": 221, + "second": 232, + "amount": -3 + }, + { + "first": 221, + "second": 233, + "amount": -3 + }, + { + "first": 221, + "second": 234, + "amount": -3 + }, + { + "first": 221, + "second": 235, + "amount": -3 + }, + { + "first": 221, + "second": 236, + "amount": 3 + }, + { + "first": 221, + "second": 237, + "amount": 3 + }, + { + "first": 221, + "second": 238, + "amount": 3 + }, + { + "first": 221, + "second": 239, + "amount": 3 + }, + { + "first": 221, + "second": 240, + "amount": -3 + }, + { + "first": 221, + "second": 241, + "amount": -1 + }, + { + "first": 221, + "second": 242, + "amount": -3 + }, + { + "first": 221, + "second": 243, + "amount": -3 + }, + { + "first": 221, + "second": 244, + "amount": -3 + }, + { + "first": 221, + "second": 245, + "amount": -3 + }, + { + "first": 221, + "second": 246, + "amount": -3 + }, + { + "first": 221, + "second": 248, + "amount": -4 + }, + { + "first": 221, + "second": 249, + "amount": -1 + }, + { + "first": 221, + "second": 250, + "amount": -1 + }, + { + "first": 221, + "second": 251, + "amount": -1 + }, + { + "first": 221, + "second": 252, + "amount": -1 + }, + { + "first": 221, + "second": 253, + "amount": -2 + }, + { + "first": 221, + "second": 255, + "amount": -2 + }, + { + "first": 221, + "second": 338, + "amount": -3 + }, + { + "first": 221, + "second": 339, + "amount": -4 + }, + { + "first": 222, + "second": 65, + "amount": -2 + }, + { + "first": 222, + "second": 86, + "amount": -1 + }, + { + "first": 222, + "second": 89, + "amount": -1 + }, + { + "first": 222, + "second": 192, + "amount": -2 + }, + { + "first": 222, + "second": 193, + "amount": -2 + }, + { + "first": 222, + "second": 194, + "amount": -2 + }, + { + "first": 222, + "second": 195, + "amount": -2 + }, + { + "first": 222, + "second": 196, + "amount": -2 + }, + { + "first": 222, + "second": 197, + "amount": -2 + }, + { + "first": 222, + "second": 221, + "amount": -1 + }, + { + "first": 222, + "second": 376, + "amount": -1 + }, + { + "first": 223, + "second": 42, + "amount": -1 + }, + { + "first": 223, + "second": 48, + "amount": 0 + }, + { + "first": 223, + "second": 52, + "amount": 0 + }, + { + "first": 223, + "second": 55, + "amount": -2 + }, + { + "first": 223, + "second": 57, + "amount": -1 + }, + { + "first": 223, + "second": 64, + "amount": 0 + }, + { + "first": 223, + "second": 65, + "amount": 0 + }, + { + "first": 223, + "second": 67, + "amount": 0 + }, + { + "first": 223, + "second": 71, + "amount": 0 + }, + { + "first": 223, + "second": 79, + "amount": 0 + }, + { + "first": 223, + "second": 81, + "amount": 0 + }, + { + "first": 223, + "second": 84, + "amount": -3 + }, + { + "first": 223, + "second": 85, + "amount": 0 + }, + { + "first": 223, + "second": 86, + "amount": -4 + }, + { + "first": 223, + "second": 88, + "amount": -1 + }, + { + "first": 223, + "second": 89, + "amount": -4 + }, + { + "first": 223, + "second": 90, + "amount": 0 + }, + { + "first": 223, + "second": 97, + "amount": 0 + }, + { + "first": 223, + "second": 99, + "amount": 0 + }, + { + "first": 223, + "second": 100, + "amount": 0 + }, + { + "first": 223, + "second": 101, + "amount": 0 + }, + { + "first": 223, + "second": 102, + "amount": 0 + }, + { + "first": 223, + "second": 103, + "amount": 0 + }, + { + "first": 223, + "second": 111, + "amount": 0 + }, + { + "first": 223, + "second": 113, + "amount": 0 + }, + { + "first": 223, + "second": 116, + "amount": 0 + }, + { + "first": 223, + "second": 118, + "amount": 0 + }, + { + "first": 223, + "second": 119, + "amount": 0 + }, + { + "first": 223, + "second": 120, + "amount": 0 + }, + { + "first": 223, + "second": 121, + "amount": 0 + }, + { + "first": 223, + "second": 122, + "amount": 0 + }, + { + "first": 223, + "second": 169, + "amount": 0 + }, + { + "first": 223, + "second": 174, + "amount": 0 + }, + { + "first": 223, + "second": 180, + "amount": 0 + }, + { + "first": 223, + "second": 192, + "amount": 0 + }, + { + "first": 223, + "second": 193, + "amount": 0 + }, + { + "first": 223, + "second": 194, + "amount": 0 + }, + { + "first": 223, + "second": 195, + "amount": 0 + }, + { + "first": 223, + "second": 196, + "amount": 0 + }, + { + "first": 223, + "second": 197, + "amount": 0 + }, + { + "first": 223, + "second": 199, + "amount": 0 + }, + { + "first": 223, + "second": 210, + "amount": 0 + }, + { + "first": 223, + "second": 211, + "amount": 0 + }, + { + "first": 223, + "second": 212, + "amount": 0 + }, + { + "first": 223, + "second": 213, + "amount": 0 + }, + { + "first": 223, + "second": 214, + "amount": 0 + }, + { + "first": 223, + "second": 216, + "amount": 0 + }, + { + "first": 223, + "second": 217, + "amount": 0 + }, + { + "first": 223, + "second": 218, + "amount": 0 + }, + { + "first": 223, + "second": 219, + "amount": 0 + }, + { + "first": 223, + "second": 220, + "amount": 0 + }, + { + "first": 223, + "second": 221, + "amount": -4 + }, + { + "first": 223, + "second": 224, + "amount": 0 + }, + { + "first": 223, + "second": 225, + "amount": 0 + }, + { + "first": 223, + "second": 226, + "amount": 0 + }, + { + "first": 223, + "second": 227, + "amount": 0 + }, + { + "first": 223, + "second": 228, + "amount": 0 + }, + { + "first": 223, + "second": 229, + "amount": 0 + }, + { + "first": 223, + "second": 230, + "amount": 0 + }, + { + "first": 223, + "second": 231, + "amount": 0 + }, + { + "first": 223, + "second": 248, + "amount": 0 + }, + { + "first": 223, + "second": 338, + "amount": 0 + }, + { + "first": 223, + "second": 339, + "amount": 0 + }, + { + "first": 223, + "second": 376, + "amount": -4 + }, + { + "first": 224, + "second": 84, + "amount": -2 + }, + { + "first": 224, + "second": 86, + "amount": -3 + }, + { + "first": 224, + "second": 89, + "amount": -3 + }, + { + "first": 224, + "second": 118, + "amount": 0 + }, + { + "first": 224, + "second": 119, + "amount": 0 + }, + { + "first": 224, + "second": 121, + "amount": 0 + }, + { + "first": 224, + "second": 221, + "amount": -3 + }, + { + "first": 224, + "second": 376, + "amount": -3 + }, + { + "first": 225, + "second": 84, + "amount": -2 + }, + { + "first": 225, + "second": 86, + "amount": -3 + }, + { + "first": 225, + "second": 89, + "amount": -3 + }, + { + "first": 225, + "second": 118, + "amount": 0 + }, + { + "first": 225, + "second": 119, + "amount": 0 + }, + { + "first": 225, + "second": 121, + "amount": 0 + }, + { + "first": 225, + "second": 221, + "amount": -3 + }, + { + "first": 225, + "second": 376, + "amount": -3 + }, + { + "first": 226, + "second": 84, + "amount": -2 + }, + { + "first": 226, + "second": 86, + "amount": -3 + }, + { + "first": 226, + "second": 89, + "amount": -3 + }, + { + "first": 226, + "second": 118, + "amount": 0 + }, + { + "first": 226, + "second": 119, + "amount": 0 + }, + { + "first": 226, + "second": 121, + "amount": 0 + }, + { + "first": 226, + "second": 221, + "amount": -3 + }, + { + "first": 226, + "second": 376, + "amount": -3 + }, + { + "first": 227, + "second": 84, + "amount": -2 + }, + { + "first": 227, + "second": 86, + "amount": -3 + }, + { + "first": 227, + "second": 89, + "amount": -3 + }, + { + "first": 227, + "second": 118, + "amount": 0 + }, + { + "first": 227, + "second": 119, + "amount": 0 + }, + { + "first": 227, + "second": 121, + "amount": 0 + }, + { + "first": 227, + "second": 221, + "amount": -3 + }, + { + "first": 227, + "second": 376, + "amount": -3 + }, + { + "first": 228, + "second": 84, + "amount": -2 + }, + { + "first": 228, + "second": 86, + "amount": -3 + }, + { + "first": 228, + "second": 89, + "amount": -3 + }, + { + "first": 228, + "second": 118, + "amount": 0 + }, + { + "first": 228, + "second": 119, + "amount": 0 + }, + { + "first": 228, + "second": 121, + "amount": 0 + }, + { + "first": 228, + "second": 221, + "amount": -3 + }, + { + "first": 228, + "second": 376, + "amount": -3 + }, + { + "first": 229, + "second": 84, + "amount": -2 + }, + { + "first": 229, + "second": 86, + "amount": -3 + }, + { + "first": 229, + "second": 89, + "amount": -3 + }, + { + "first": 229, + "second": 118, + "amount": 0 + }, + { + "first": 229, + "second": 119, + "amount": 0 + }, + { + "first": 229, + "second": 121, + "amount": 0 + }, + { + "first": 229, + "second": 221, + "amount": -3 + }, + { + "first": 229, + "second": 376, + "amount": -3 + }, + { + "first": 230, + "second": 42, + "amount": -2 + }, + { + "first": 230, + "second": 47, + "amount": -2 + }, + { + "first": 230, + "second": 64, + "amount": 0 + }, + { + "first": 230, + "second": 67, + "amount": 0 + }, + { + "first": 230, + "second": 71, + "amount": 0 + }, + { + "first": 230, + "second": 79, + "amount": 0 + }, + { + "first": 230, + "second": 81, + "amount": 0 + }, + { + "first": 230, + "second": 84, + "amount": -4 + }, + { + "first": 230, + "second": 86, + "amount": -2 + }, + { + "first": 230, + "second": 87, + "amount": -1 + }, + { + "first": 230, + "second": 88, + "amount": -1 + }, + { + "first": 230, + "second": 89, + "amount": -2 + }, + { + "first": 230, + "second": 90, + "amount": 0 + }, + { + "first": 230, + "second": 92, + "amount": -1 + }, + { + "first": 230, + "second": 102, + "amount": 0 + }, + { + "first": 230, + "second": 115, + "amount": 0 + }, + { + "first": 230, + "second": 116, + "amount": 0 + }, + { + "first": 230, + "second": 118, + "amount": 0 + }, + { + "first": 230, + "second": 119, + "amount": 0 + }, + { + "first": 230, + "second": 120, + "amount": -1 + }, + { + "first": 230, + "second": 121, + "amount": 0 + }, + { + "first": 230, + "second": 122, + "amount": 0 + }, + { + "first": 230, + "second": 169, + "amount": 0 + }, + { + "first": 230, + "second": 174, + "amount": 0 + }, + { + "first": 230, + "second": 199, + "amount": 0 + }, + { + "first": 230, + "second": 210, + "amount": 0 + }, + { + "first": 230, + "second": 211, + "amount": 0 + }, + { + "first": 230, + "second": 212, + "amount": 0 + }, + { + "first": 230, + "second": 213, + "amount": 0 + }, + { + "first": 230, + "second": 214, + "amount": 0 + }, + { + "first": 230, + "second": 216, + "amount": 0 + }, + { + "first": 230, + "second": 221, + "amount": -2 + }, + { + "first": 230, + "second": 338, + "amount": 0 + }, + { + "first": 230, + "second": 376, + "amount": -2 + }, + { + "first": 231, + "second": 47, + "amount": -1 + }, + { + "first": 231, + "second": 64, + "amount": 0 + }, + { + "first": 231, + "second": 67, + "amount": 0 + }, + { + "first": 231, + "second": 71, + "amount": 0 + }, + { + "first": 231, + "second": 79, + "amount": 0 + }, + { + "first": 231, + "second": 81, + "amount": 0 + }, + { + "first": 231, + "second": 84, + "amount": -4 + }, + { + "first": 231, + "second": 85, + "amount": 0 + }, + { + "first": 231, + "second": 86, + "amount": -2 + }, + { + "first": 231, + "second": 87, + "amount": -1 + }, + { + "first": 231, + "second": 88, + "amount": -1 + }, + { + "first": 231, + "second": 89, + "amount": -2 + }, + { + "first": 231, + "second": 90, + "amount": 0 + }, + { + "first": 231, + "second": 92, + "amount": -1 + }, + { + "first": 231, + "second": 97, + "amount": 0 + }, + { + "first": 231, + "second": 99, + "amount": 0 + }, + { + "first": 231, + "second": 100, + "amount": 0 + }, + { + "first": 231, + "second": 101, + "amount": 0 + }, + { + "first": 231, + "second": 103, + "amount": 0 + }, + { + "first": 231, + "second": 111, + "amount": 0 + }, + { + "first": 231, + "second": 113, + "amount": 0 + }, + { + "first": 231, + "second": 115, + "amount": 0 + }, + { + "first": 231, + "second": 116, + "amount": 0 + }, + { + "first": 231, + "second": 117, + "amount": 0 + }, + { + "first": 231, + "second": 118, + "amount": 0 + }, + { + "first": 231, + "second": 119, + "amount": 0 + }, + { + "first": 231, + "second": 120, + "amount": -1 + }, + { + "first": 231, + "second": 121, + "amount": 0 + }, + { + "first": 231, + "second": 122, + "amount": 0 + }, + { + "first": 231, + "second": 169, + "amount": 0 + }, + { + "first": 231, + "second": 171, + "amount": 0 + }, + { + "first": 231, + "second": 174, + "amount": 0 + }, + { + "first": 231, + "second": 180, + "amount": 0 + }, + { + "first": 231, + "second": 187, + "amount": 0 + }, + { + "first": 231, + "second": 199, + "amount": 0 + }, + { + "first": 231, + "second": 210, + "amount": 0 + }, + { + "first": 231, + "second": 211, + "amount": 0 + }, + { + "first": 231, + "second": 212, + "amount": 0 + }, + { + "first": 231, + "second": 213, + "amount": 0 + }, + { + "first": 231, + "second": 214, + "amount": 0 + }, + { + "first": 231, + "second": 216, + "amount": 0 + }, + { + "first": 231, + "second": 217, + "amount": 0 + }, + { + "first": 231, + "second": 218, + "amount": 0 + }, + { + "first": 231, + "second": 219, + "amount": 0 + }, + { + "first": 231, + "second": 220, + "amount": 0 + }, + { + "first": 231, + "second": 221, + "amount": -2 + }, + { + "first": 231, + "second": 224, + "amount": 0 + }, + { + "first": 231, + "second": 225, + "amount": 0 + }, + { + "first": 231, + "second": 226, + "amount": 0 + }, + { + "first": 231, + "second": 227, + "amount": 0 + }, + { + "first": 231, + "second": 228, + "amount": 0 + }, + { + "first": 231, + "second": 229, + "amount": 0 + }, + { + "first": 231, + "second": 230, + "amount": 0 + }, + { + "first": 231, + "second": 231, + "amount": 0 + }, + { + "first": 231, + "second": 248, + "amount": 0 + }, + { + "first": 231, + "second": 249, + "amount": 0 + }, + { + "first": 231, + "second": 250, + "amount": 0 + }, + { + "first": 231, + "second": 251, + "amount": 0 + }, + { + "first": 231, + "second": 252, + "amount": 0 + }, + { + "first": 231, + "second": 338, + "amount": 0 + }, + { + "first": 231, + "second": 339, + "amount": 0 + }, + { + "first": 231, + "second": 376, + "amount": -2 + }, + { + "first": 232, + "second": 42, + "amount": -2 + }, + { + "first": 232, + "second": 47, + "amount": -2 + }, + { + "first": 232, + "second": 64, + "amount": 0 + }, + { + "first": 232, + "second": 67, + "amount": 0 + }, + { + "first": 232, + "second": 71, + "amount": 0 + }, + { + "first": 232, + "second": 79, + "amount": 0 + }, + { + "first": 232, + "second": 81, + "amount": 0 + }, + { + "first": 232, + "second": 84, + "amount": -4 + }, + { + "first": 232, + "second": 86, + "amount": -2 + }, + { + "first": 232, + "second": 87, + "amount": -1 + }, + { + "first": 232, + "second": 88, + "amount": -1 + }, + { + "first": 232, + "second": 89, + "amount": -2 + }, + { + "first": 232, + "second": 90, + "amount": 0 + }, + { + "first": 232, + "second": 92, + "amount": -1 + }, + { + "first": 232, + "second": 102, + "amount": 0 + }, + { + "first": 232, + "second": 115, + "amount": 0 + }, + { + "first": 232, + "second": 116, + "amount": 0 + }, + { + "first": 232, + "second": 118, + "amount": 0 + }, + { + "first": 232, + "second": 119, + "amount": 0 + }, + { + "first": 232, + "second": 120, + "amount": -1 + }, + { + "first": 232, + "second": 121, + "amount": 0 + }, + { + "first": 232, + "second": 122, + "amount": 0 + }, + { + "first": 232, + "second": 169, + "amount": 0 + }, + { + "first": 232, + "second": 174, + "amount": 0 + }, + { + "first": 232, + "second": 199, + "amount": 0 + }, + { + "first": 232, + "second": 210, + "amount": 0 + }, + { + "first": 232, + "second": 211, + "amount": 0 + }, + { + "first": 232, + "second": 212, + "amount": 0 + }, + { + "first": 232, + "second": 213, + "amount": 0 + }, + { + "first": 232, + "second": 214, + "amount": 0 + }, + { + "first": 232, + "second": 216, + "amount": 0 + }, + { + "first": 232, + "second": 221, + "amount": -2 + }, + { + "first": 232, + "second": 338, + "amount": 0 + }, + { + "first": 232, + "second": 376, + "amount": -2 + }, + { + "first": 233, + "second": 42, + "amount": -2 + }, + { + "first": 233, + "second": 47, + "amount": -2 + }, + { + "first": 233, + "second": 64, + "amount": 0 + }, + { + "first": 233, + "second": 67, + "amount": 0 + }, + { + "first": 233, + "second": 71, + "amount": 0 + }, + { + "first": 233, + "second": 79, + "amount": 0 + }, + { + "first": 233, + "second": 81, + "amount": 0 + }, + { + "first": 233, + "second": 84, + "amount": -4 + }, + { + "first": 233, + "second": 86, + "amount": -2 + }, + { + "first": 233, + "second": 87, + "amount": -1 + }, + { + "first": 233, + "second": 88, + "amount": -1 + }, + { + "first": 233, + "second": 89, + "amount": -2 + }, + { + "first": 233, + "second": 90, + "amount": 0 + }, + { + "first": 233, + "second": 92, + "amount": -1 + }, + { + "first": 233, + "second": 102, + "amount": 0 + }, + { + "first": 233, + "second": 115, + "amount": 0 + }, + { + "first": 233, + "second": 116, + "amount": 0 + }, + { + "first": 233, + "second": 118, + "amount": 0 + }, + { + "first": 233, + "second": 119, + "amount": 0 + }, + { + "first": 233, + "second": 120, + "amount": -1 + }, + { + "first": 233, + "second": 121, + "amount": 0 + }, + { + "first": 233, + "second": 122, + "amount": 0 + }, + { + "first": 233, + "second": 169, + "amount": 0 + }, + { + "first": 233, + "second": 174, + "amount": 0 + }, + { + "first": 233, + "second": 199, + "amount": 0 + }, + { + "first": 233, + "second": 210, + "amount": 0 + }, + { + "first": 233, + "second": 211, + "amount": 0 + }, + { + "first": 233, + "second": 212, + "amount": 0 + }, + { + "first": 233, + "second": 213, + "amount": 0 + }, + { + "first": 233, + "second": 214, + "amount": 0 + }, + { + "first": 233, + "second": 216, + "amount": 0 + }, + { + "first": 233, + "second": 221, + "amount": -2 + }, + { + "first": 233, + "second": 338, + "amount": 0 + }, + { + "first": 233, + "second": 376, + "amount": -2 + }, + { + "first": 234, + "second": 42, + "amount": -2 + }, + { + "first": 234, + "second": 47, + "amount": -2 + }, + { + "first": 234, + "second": 64, + "amount": 0 + }, + { + "first": 234, + "second": 67, + "amount": 0 + }, + { + "first": 234, + "second": 71, + "amount": 0 + }, + { + "first": 234, + "second": 79, + "amount": 0 + }, + { + "first": 234, + "second": 81, + "amount": 0 + }, + { + "first": 234, + "second": 84, + "amount": -4 + }, + { + "first": 234, + "second": 86, + "amount": -2 + }, + { + "first": 234, + "second": 87, + "amount": -1 + }, + { + "first": 234, + "second": 88, + "amount": -1 + }, + { + "first": 234, + "second": 89, + "amount": -2 + }, + { + "first": 234, + "second": 90, + "amount": 0 + }, + { + "first": 234, + "second": 92, + "amount": -1 + }, + { + "first": 234, + "second": 102, + "amount": 0 + }, + { + "first": 234, + "second": 115, + "amount": 0 + }, + { + "first": 234, + "second": 116, + "amount": 0 + }, + { + "first": 234, + "second": 118, + "amount": 0 + }, + { + "first": 234, + "second": 119, + "amount": 0 + }, + { + "first": 234, + "second": 120, + "amount": -1 + }, + { + "first": 234, + "second": 121, + "amount": 0 + }, + { + "first": 234, + "second": 122, + "amount": 0 + }, + { + "first": 234, + "second": 169, + "amount": 0 + }, + { + "first": 234, + "second": 174, + "amount": 0 + }, + { + "first": 234, + "second": 199, + "amount": 0 + }, + { + "first": 234, + "second": 210, + "amount": 0 + }, + { + "first": 234, + "second": 211, + "amount": 0 + }, + { + "first": 234, + "second": 212, + "amount": 0 + }, + { + "first": 234, + "second": 213, + "amount": 0 + }, + { + "first": 234, + "second": 214, + "amount": 0 + }, + { + "first": 234, + "second": 216, + "amount": 0 + }, + { + "first": 234, + "second": 221, + "amount": -2 + }, + { + "first": 234, + "second": 338, + "amount": 0 + }, + { + "first": 234, + "second": 376, + "amount": -2 + }, + { + "first": 235, + "second": 42, + "amount": -2 + }, + { + "first": 235, + "second": 47, + "amount": -2 + }, + { + "first": 235, + "second": 64, + "amount": 0 + }, + { + "first": 235, + "second": 67, + "amount": 0 + }, + { + "first": 235, + "second": 71, + "amount": 0 + }, + { + "first": 235, + "second": 79, + "amount": 0 + }, + { + "first": 235, + "second": 81, + "amount": 0 + }, + { + "first": 235, + "second": 84, + "amount": -4 + }, + { + "first": 235, + "second": 86, + "amount": -2 + }, + { + "first": 235, + "second": 87, + "amount": -1 + }, + { + "first": 235, + "second": 88, + "amount": -1 + }, + { + "first": 235, + "second": 89, + "amount": -2 + }, + { + "first": 235, + "second": 90, + "amount": 0 + }, + { + "first": 235, + "second": 92, + "amount": -1 + }, + { + "first": 235, + "second": 102, + "amount": 0 + }, + { + "first": 235, + "second": 115, + "amount": 0 + }, + { + "first": 235, + "second": 116, + "amount": 0 + }, + { + "first": 235, + "second": 118, + "amount": 0 + }, + { + "first": 235, + "second": 119, + "amount": 0 + }, + { + "first": 235, + "second": 120, + "amount": -1 + }, + { + "first": 235, + "second": 121, + "amount": 0 + }, + { + "first": 235, + "second": 122, + "amount": 0 + }, + { + "first": 235, + "second": 169, + "amount": 0 + }, + { + "first": 235, + "second": 174, + "amount": 0 + }, + { + "first": 235, + "second": 199, + "amount": 0 + }, + { + "first": 235, + "second": 210, + "amount": 0 + }, + { + "first": 235, + "second": 211, + "amount": 0 + }, + { + "first": 235, + "second": 212, + "amount": 0 + }, + { + "first": 235, + "second": 213, + "amount": 0 + }, + { + "first": 235, + "second": 214, + "amount": 0 + }, + { + "first": 235, + "second": 216, + "amount": 0 + }, + { + "first": 235, + "second": 221, + "amount": -2 + }, + { + "first": 235, + "second": 338, + "amount": 0 + }, + { + "first": 235, + "second": 376, + "amount": -2 + }, + { + "first": 236, + "second": 84, + "amount": 3 + }, + { + "first": 236, + "second": 86, + "amount": 3 + }, + { + "first": 236, + "second": 89, + "amount": 3 + }, + { + "first": 236, + "second": 221, + "amount": 3 + }, + { + "first": 236, + "second": 376, + "amount": 3 + }, + { + "first": 237, + "second": 84, + "amount": 3 + }, + { + "first": 237, + "second": 86, + "amount": 3 + }, + { + "first": 237, + "second": 89, + "amount": 3 + }, + { + "first": 237, + "second": 221, + "amount": 3 + }, + { + "first": 237, + "second": 376, + "amount": 3 + }, + { + "first": 238, + "second": 84, + "amount": 3 + }, + { + "first": 238, + "second": 86, + "amount": 3 + }, + { + "first": 238, + "second": 89, + "amount": 3 + }, + { + "first": 238, + "second": 221, + "amount": 3 + }, + { + "first": 238, + "second": 376, + "amount": 3 + }, + { + "first": 239, + "second": 84, + "amount": 3 + }, + { + "first": 239, + "second": 86, + "amount": 3 + }, + { + "first": 239, + "second": 89, + "amount": 3 + }, + { + "first": 239, + "second": 221, + "amount": 3 + }, + { + "first": 239, + "second": 376, + "amount": 3 + }, + { + "first": 240, + "second": 65, + "amount": 0 + }, + { + "first": 240, + "second": 84, + "amount": -3 + }, + { + "first": 240, + "second": 86, + "amount": -3 + }, + { + "first": 240, + "second": 87, + "amount": 0 + }, + { + "first": 240, + "second": 88, + "amount": -1 + }, + { + "first": 240, + "second": 89, + "amount": -3 + }, + { + "first": 240, + "second": 102, + "amount": 0 + }, + { + "first": 240, + "second": 116, + "amount": 0 + }, + { + "first": 240, + "second": 119, + "amount": 0 + }, + { + "first": 240, + "second": 120, + "amount": -1 + }, + { + "first": 240, + "second": 192, + "amount": 0 + }, + { + "first": 240, + "second": 193, + "amount": 0 + }, + { + "first": 240, + "second": 194, + "amount": 0 + }, + { + "first": 240, + "second": 195, + "amount": 0 + }, + { + "first": 240, + "second": 196, + "amount": 0 + }, + { + "first": 240, + "second": 197, + "amount": 0 + }, + { + "first": 240, + "second": 221, + "amount": -3 + }, + { + "first": 240, + "second": 376, + "amount": -3 + }, + { + "first": 241, + "second": 84, + "amount": -2 + }, + { + "first": 241, + "second": 86, + "amount": -3 + }, + { + "first": 241, + "second": 89, + "amount": -3 + }, + { + "first": 241, + "second": 118, + "amount": 0 + }, + { + "first": 241, + "second": 119, + "amount": 0 + }, + { + "first": 241, + "second": 121, + "amount": 0 + }, + { + "first": 241, + "second": 221, + "amount": -3 + }, + { + "first": 241, + "second": 376, + "amount": -3 + }, + { + "first": 242, + "second": 65, + "amount": 0 + }, + { + "first": 242, + "second": 84, + "amount": -3 + }, + { + "first": 242, + "second": 86, + "amount": -3 + }, + { + "first": 242, + "second": 87, + "amount": 0 + }, + { + "first": 242, + "second": 88, + "amount": -1 + }, + { + "first": 242, + "second": 89, + "amount": -3 + }, + { + "first": 242, + "second": 102, + "amount": 0 + }, + { + "first": 242, + "second": 116, + "amount": 0 + }, + { + "first": 242, + "second": 119, + "amount": 0 + }, + { + "first": 242, + "second": 120, + "amount": -1 + }, + { + "first": 242, + "second": 192, + "amount": 0 + }, + { + "first": 242, + "second": 193, + "amount": 0 + }, + { + "first": 242, + "second": 194, + "amount": 0 + }, + { + "first": 242, + "second": 195, + "amount": 0 + }, + { + "first": 242, + "second": 196, + "amount": 0 + }, + { + "first": 242, + "second": 197, + "amount": 0 + }, + { + "first": 242, + "second": 221, + "amount": -3 + }, + { + "first": 242, + "second": 376, + "amount": -3 + }, + { + "first": 243, + "second": 65, + "amount": 0 + }, + { + "first": 243, + "second": 84, + "amount": -3 + }, + { + "first": 243, + "second": 86, + "amount": -3 + }, + { + "first": 243, + "second": 87, + "amount": 0 + }, + { + "first": 243, + "second": 88, + "amount": -1 + }, + { + "first": 243, + "second": 89, + "amount": -3 + }, + { + "first": 243, + "second": 102, + "amount": 0 + }, + { + "first": 243, + "second": 116, + "amount": 0 + }, + { + "first": 243, + "second": 119, + "amount": 0 + }, + { + "first": 243, + "second": 120, + "amount": -1 + }, + { + "first": 243, + "second": 192, + "amount": 0 + }, + { + "first": 243, + "second": 193, + "amount": 0 + }, + { + "first": 243, + "second": 194, + "amount": 0 + }, + { + "first": 243, + "second": 195, + "amount": 0 + }, + { + "first": 243, + "second": 196, + "amount": 0 + }, + { + "first": 243, + "second": 197, + "amount": 0 + }, + { + "first": 243, + "second": 221, + "amount": -3 + }, + { + "first": 243, + "second": 376, + "amount": -3 + }, + { + "first": 244, + "second": 65, + "amount": 0 + }, + { + "first": 244, + "second": 84, + "amount": -3 + }, + { + "first": 244, + "second": 86, + "amount": -3 + }, + { + "first": 244, + "second": 87, + "amount": 0 + }, + { + "first": 244, + "second": 88, + "amount": -1 + }, + { + "first": 244, + "second": 89, + "amount": -3 + }, + { + "first": 244, + "second": 102, + "amount": 0 + }, + { + "first": 244, + "second": 116, + "amount": 0 + }, + { + "first": 244, + "second": 119, + "amount": 0 + }, + { + "first": 244, + "second": 120, + "amount": -1 + }, + { + "first": 244, + "second": 192, + "amount": 0 + }, + { + "first": 244, + "second": 193, + "amount": 0 + }, + { + "first": 244, + "second": 194, + "amount": 0 + }, + { + "first": 244, + "second": 195, + "amount": 0 + }, + { + "first": 244, + "second": 196, + "amount": 0 + }, + { + "first": 244, + "second": 197, + "amount": 0 + }, + { + "first": 244, + "second": 221, + "amount": -3 + }, + { + "first": 244, + "second": 376, + "amount": -3 + }, + { + "first": 245, + "second": 65, + "amount": 0 + }, + { + "first": 245, + "second": 84, + "amount": -3 + }, + { + "first": 245, + "second": 86, + "amount": -3 + }, + { + "first": 245, + "second": 87, + "amount": 0 + }, + { + "first": 245, + "second": 88, + "amount": -1 + }, + { + "first": 245, + "second": 89, + "amount": -3 + }, + { + "first": 245, + "second": 102, + "amount": 0 + }, + { + "first": 245, + "second": 116, + "amount": 0 + }, + { + "first": 245, + "second": 119, + "amount": 0 + }, + { + "first": 245, + "second": 120, + "amount": -1 + }, + { + "first": 245, + "second": 192, + "amount": 0 + }, + { + "first": 245, + "second": 193, + "amount": 0 + }, + { + "first": 245, + "second": 194, + "amount": 0 + }, + { + "first": 245, + "second": 195, + "amount": 0 + }, + { + "first": 245, + "second": 196, + "amount": 0 + }, + { + "first": 245, + "second": 197, + "amount": 0 + }, + { + "first": 245, + "second": 221, + "amount": -3 + }, + { + "first": 245, + "second": 376, + "amount": -3 + }, + { + "first": 246, + "second": 65, + "amount": 0 + }, + { + "first": 246, + "second": 84, + "amount": -3 + }, + { + "first": 246, + "second": 86, + "amount": -3 + }, + { + "first": 246, + "second": 87, + "amount": 0 + }, + { + "first": 246, + "second": 88, + "amount": -1 + }, + { + "first": 246, + "second": 89, + "amount": -3 + }, + { + "first": 246, + "second": 102, + "amount": 0 + }, + { + "first": 246, + "second": 116, + "amount": 0 + }, + { + "first": 246, + "second": 119, + "amount": 0 + }, + { + "first": 246, + "second": 120, + "amount": -1 + }, + { + "first": 246, + "second": 192, + "amount": 0 + }, + { + "first": 246, + "second": 193, + "amount": 0 + }, + { + "first": 246, + "second": 194, + "amount": 0 + }, + { + "first": 246, + "second": 195, + "amount": 0 + }, + { + "first": 246, + "second": 196, + "amount": 0 + }, + { + "first": 246, + "second": 197, + "amount": 0 + }, + { + "first": 246, + "second": 221, + "amount": -3 + }, + { + "first": 246, + "second": 376, + "amount": -3 + }, + { + "first": 248, + "second": 42, + "amount": -2 + }, + { + "first": 248, + "second": 44, + "amount": -1 + }, + { + "first": 248, + "second": 46, + "amount": -1 + }, + { + "first": 248, + "second": 55, + "amount": -2 + }, + { + "first": 248, + "second": 64, + "amount": 0 + }, + { + "first": 248, + "second": 65, + "amount": 0 + }, + { + "first": 248, + "second": 67, + "amount": 0 + }, + { + "first": 248, + "second": 71, + "amount": 0 + }, + { + "first": 248, + "second": 79, + "amount": 0 + }, + { + "first": 248, + "second": 81, + "amount": 0 + }, + { + "first": 248, + "second": 83, + "amount": 0 + }, + { + "first": 248, + "second": 84, + "amount": -4 + }, + { + "first": 248, + "second": 86, + "amount": -4 + }, + { + "first": 248, + "second": 87, + "amount": -1 + }, + { + "first": 248, + "second": 88, + "amount": -2 + }, + { + "first": 248, + "second": 89, + "amount": -4 + }, + { + "first": 248, + "second": 90, + "amount": 0 + }, + { + "first": 248, + "second": 102, + "amount": 0 + }, + { + "first": 248, + "second": 115, + "amount": 0 + }, + { + "first": 248, + "second": 116, + "amount": 0 + }, + { + "first": 248, + "second": 118, + "amount": 0 + }, + { + "first": 248, + "second": 119, + "amount": 0 + }, + { + "first": 248, + "second": 120, + "amount": -1 + }, + { + "first": 248, + "second": 121, + "amount": 0 + }, + { + "first": 248, + "second": 122, + "amount": 0 + }, + { + "first": 248, + "second": 169, + "amount": 0 + }, + { + "first": 248, + "second": 174, + "amount": 0 + }, + { + "first": 248, + "second": 192, + "amount": 0 + }, + { + "first": 248, + "second": 193, + "amount": 0 + }, + { + "first": 248, + "second": 194, + "amount": 0 + }, + { + "first": 248, + "second": 195, + "amount": 0 + }, + { + "first": 248, + "second": 196, + "amount": 0 + }, + { + "first": 248, + "second": 197, + "amount": 0 + }, + { + "first": 248, + "second": 199, + "amount": 0 + }, + { + "first": 248, + "second": 210, + "amount": 0 + }, + { + "first": 248, + "second": 211, + "amount": 0 + }, + { + "first": 248, + "second": 212, + "amount": 0 + }, + { + "first": 248, + "second": 213, + "amount": 0 + }, + { + "first": 248, + "second": 214, + "amount": 0 + }, + { + "first": 248, + "second": 216, + "amount": 0 + }, + { + "first": 248, + "second": 221, + "amount": -4 + }, + { + "first": 248, + "second": 338, + "amount": 0 + }, + { + "first": 248, + "second": 376, + "amount": -4 + }, + { + "first": 249, + "second": 86, + "amount": -1 + }, + { + "first": 249, + "second": 89, + "amount": -1 + }, + { + "first": 249, + "second": 221, + "amount": -1 + }, + { + "first": 249, + "second": 376, + "amount": -1 + }, + { + "first": 250, + "second": 86, + "amount": -1 + }, + { + "first": 250, + "second": 89, + "amount": -1 + }, + { + "first": 250, + "second": 221, + "amount": -1 + }, + { + "first": 250, + "second": 376, + "amount": -1 + }, + { + "first": 251, + "second": 86, + "amount": -1 + }, + { + "first": 251, + "second": 89, + "amount": -1 + }, + { + "first": 251, + "second": 221, + "amount": -1 + }, + { + "first": 251, + "second": 376, + "amount": -1 + }, + { + "first": 252, + "second": 86, + "amount": -1 + }, + { + "first": 252, + "second": 89, + "amount": -1 + }, + { + "first": 252, + "second": 221, + "amount": -1 + }, + { + "first": 252, + "second": 376, + "amount": -1 + }, + { + "first": 253, + "second": 84, + "amount": -1 + }, + { + "first": 253, + "second": 86, + "amount": -2 + }, + { + "first": 253, + "second": 89, + "amount": -2 + }, + { + "first": 253, + "second": 97, + "amount": 0 + }, + { + "first": 253, + "second": 221, + "amount": -2 + }, + { + "first": 253, + "second": 230, + "amount": 0 + }, + { + "first": 253, + "second": 376, + "amount": -2 + }, + { + "first": 254, + "second": 42, + "amount": -2 + }, + { + "first": 254, + "second": 44, + "amount": -1 + }, + { + "first": 254, + "second": 46, + "amount": -1 + }, + { + "first": 254, + "second": 55, + "amount": -2 + }, + { + "first": 254, + "second": 64, + "amount": 0 + }, + { + "first": 254, + "second": 65, + "amount": 0 + }, + { + "first": 254, + "second": 67, + "amount": 0 + }, + { + "first": 254, + "second": 71, + "amount": 0 + }, + { + "first": 254, + "second": 79, + "amount": 0 + }, + { + "first": 254, + "second": 81, + "amount": 0 + }, + { + "first": 254, + "second": 83, + "amount": 0 + }, + { + "first": 254, + "second": 84, + "amount": -4 + }, + { + "first": 254, + "second": 86, + "amount": -4 + }, + { + "first": 254, + "second": 87, + "amount": -1 + }, + { + "first": 254, + "second": 88, + "amount": -2 + }, + { + "first": 254, + "second": 89, + "amount": -4 + }, + { + "first": 254, + "second": 90, + "amount": 0 + }, + { + "first": 254, + "second": 102, + "amount": 0 + }, + { + "first": 254, + "second": 115, + "amount": 0 + }, + { + "first": 254, + "second": 116, + "amount": 0 + }, + { + "first": 254, + "second": 118, + "amount": 0 + }, + { + "first": 254, + "second": 119, + "amount": 0 + }, + { + "first": 254, + "second": 120, + "amount": -1 + }, + { + "first": 254, + "second": 121, + "amount": 0 + }, + { + "first": 254, + "second": 122, + "amount": 0 + }, + { + "first": 254, + "second": 169, + "amount": 0 + }, + { + "first": 254, + "second": 174, + "amount": 0 + }, + { + "first": 254, + "second": 192, + "amount": 0 + }, + { + "first": 254, + "second": 193, + "amount": 0 + }, + { + "first": 254, + "second": 194, + "amount": 0 + }, + { + "first": 254, + "second": 195, + "amount": 0 + }, + { + "first": 254, + "second": 196, + "amount": 0 + }, + { + "first": 254, + "second": 197, + "amount": 0 + }, + { + "first": 254, + "second": 199, + "amount": 0 + }, + { + "first": 254, + "second": 210, + "amount": 0 + }, + { + "first": 254, + "second": 211, + "amount": 0 + }, + { + "first": 254, + "second": 212, + "amount": 0 + }, + { + "first": 254, + "second": 213, + "amount": 0 + }, + { + "first": 254, + "second": 214, + "amount": 0 + }, + { + "first": 254, + "second": 216, + "amount": 0 + }, + { + "first": 254, + "second": 221, + "amount": -4 + }, + { + "first": 254, + "second": 338, + "amount": 0 + }, + { + "first": 254, + "second": 376, + "amount": -4 + }, + { + "first": 255, + "second": 84, + "amount": -1 + }, + { + "first": 255, + "second": 86, + "amount": -2 + }, + { + "first": 255, + "second": 89, + "amount": -2 + }, + { + "first": 255, + "second": 97, + "amount": 0 + }, + { + "first": 255, + "second": 221, + "amount": -2 + }, + { + "first": 255, + "second": 230, + "amount": 0 + }, + { + "first": 255, + "second": 376, + "amount": -2 + }, + { + "first": 338, + "second": 38, + "amount": 0 + }, + { + "first": 338, + "second": 64, + "amount": -1 + }, + { + "first": 338, + "second": 67, + "amount": -1 + }, + { + "first": 338, + "second": 71, + "amount": -1 + }, + { + "first": 338, + "second": 74, + "amount": 0 + }, + { + "first": 338, + "second": 79, + "amount": -1 + }, + { + "first": 338, + "second": 81, + "amount": -1 + }, + { + "first": 338, + "second": 87, + "amount": 0 + }, + { + "first": 338, + "second": 97, + "amount": 0 + }, + { + "first": 338, + "second": 99, + "amount": 0 + }, + { + "first": 338, + "second": 100, + "amount": 0 + }, + { + "first": 338, + "second": 101, + "amount": 0 + }, + { + "first": 338, + "second": 102, + "amount": -1 + }, + { + "first": 338, + "second": 103, + "amount": 0 + }, + { + "first": 338, + "second": 106, + "amount": 1 + }, + { + "first": 338, + "second": 111, + "amount": 0 + }, + { + "first": 338, + "second": 113, + "amount": 0 + }, + { + "first": 338, + "second": 115, + "amount": 0 + }, + { + "first": 338, + "second": 116, + "amount": 0 + }, + { + "first": 338, + "second": 118, + "amount": -1 + }, + { + "first": 338, + "second": 119, + "amount": -1 + }, + { + "first": 338, + "second": 121, + "amount": -1 + }, + { + "first": 338, + "second": 169, + "amount": -1 + }, + { + "first": 338, + "second": 171, + "amount": -1 + }, + { + "first": 338, + "second": 174, + "amount": -1 + }, + { + "first": 338, + "second": 180, + "amount": 0 + }, + { + "first": 338, + "second": 199, + "amount": -1 + }, + { + "first": 338, + "second": 210, + "amount": -1 + }, + { + "first": 338, + "second": 211, + "amount": -1 + }, + { + "first": 338, + "second": 212, + "amount": -1 + }, + { + "first": 338, + "second": 213, + "amount": -1 + }, + { + "first": 338, + "second": 214, + "amount": -1 + }, + { + "first": 338, + "second": 216, + "amount": -1 + }, + { + "first": 338, + "second": 224, + "amount": 0 + }, + { + "first": 338, + "second": 225, + "amount": 0 + }, + { + "first": 338, + "second": 226, + "amount": 0 + }, + { + "first": 338, + "second": 227, + "amount": 0 + }, + { + "first": 338, + "second": 228, + "amount": 0 + }, + { + "first": 338, + "second": 229, + "amount": 0 + }, + { + "first": 338, + "second": 230, + "amount": 0 + }, + { + "first": 338, + "second": 231, + "amount": 0 + }, + { + "first": 338, + "second": 248, + "amount": 0 + }, + { + "first": 338, + "second": 338, + "amount": -1 + }, + { + "first": 338, + "second": 339, + "amount": 0 + }, + { + "first": 339, + "second": 42, + "amount": -2 + }, + { + "first": 339, + "second": 47, + "amount": -2 + }, + { + "first": 339, + "second": 64, + "amount": 0 + }, + { + "first": 339, + "second": 67, + "amount": 0 + }, + { + "first": 339, + "second": 71, + "amount": 0 + }, + { + "first": 339, + "second": 79, + "amount": 0 + }, + { + "first": 339, + "second": 81, + "amount": 0 + }, + { + "first": 339, + "second": 84, + "amount": -4 + }, + { + "first": 339, + "second": 86, + "amount": -2 + }, + { + "first": 339, + "second": 87, + "amount": -1 + }, + { + "first": 339, + "second": 88, + "amount": -1 + }, + { + "first": 339, + "second": 89, + "amount": -2 + }, + { + "first": 339, + "second": 90, + "amount": 0 + }, + { + "first": 339, + "second": 92, + "amount": -1 + }, + { + "first": 339, + "second": 102, + "amount": 0 + }, + { + "first": 339, + "second": 115, + "amount": 0 + }, + { + "first": 339, + "second": 116, + "amount": 0 + }, + { + "first": 339, + "second": 118, + "amount": 0 + }, + { + "first": 339, + "second": 119, + "amount": 0 + }, + { + "first": 339, + "second": 120, + "amount": -1 + }, + { + "first": 339, + "second": 121, + "amount": 0 + }, + { + "first": 339, + "second": 122, + "amount": 0 + }, + { + "first": 339, + "second": 169, + "amount": 0 + }, + { + "first": 339, + "second": 174, + "amount": 0 + }, + { + "first": 339, + "second": 199, + "amount": 0 + }, + { + "first": 339, + "second": 210, + "amount": 0 + }, + { + "first": 339, + "second": 211, + "amount": 0 + }, + { + "first": 339, + "second": 212, + "amount": 0 + }, + { + "first": 339, + "second": 213, + "amount": 0 + }, + { + "first": 339, + "second": 214, + "amount": 0 + }, + { + "first": 339, + "second": 216, + "amount": 0 + }, + { + "first": 339, + "second": 221, + "amount": -2 + }, + { + "first": 339, + "second": 338, + "amount": 0 + }, + { + "first": 339, + "second": 376, + "amount": -2 + }, + { + "first": 376, + "second": 32, + "amount": -2 + }, + { + "first": 376, + "second": 38, + "amount": -3 + }, + { + "first": 376, + "second": 41, + "amount": -1 + }, + { + "first": 376, + "second": 44, + "amount": -5 + }, + { + "first": 376, + "second": 45, + "amount": -1 + }, + { + "first": 376, + "second": 46, + "amount": -5 + }, + { + "first": 376, + "second": 47, + "amount": -6 + }, + { + "first": 376, + "second": 48, + "amount": -2 + }, + { + "first": 376, + "second": 50, + "amount": -1 + }, + { + "first": 376, + "second": 51, + "amount": -1 + }, + { + "first": 376, + "second": 52, + "amount": -4 + }, + { + "first": 376, + "second": 53, + "amount": -1 + }, + { + "first": 376, + "second": 54, + "amount": -3 + }, + { + "first": 376, + "second": 55, + "amount": 0 + }, + { + "first": 376, + "second": 56, + "amount": -3 + }, + { + "first": 376, + "second": 57, + "amount": -2 + }, + { + "first": 376, + "second": 58, + "amount": -4 + }, + { + "first": 376, + "second": 59, + "amount": -4 + }, + { + "first": 376, + "second": 64, + "amount": -3 + }, + { + "first": 376, + "second": 65, + "amount": -5 + }, + { + "first": 376, + "second": 66, + "amount": -1 + }, + { + "first": 376, + "second": 67, + "amount": -3 + }, + { + "first": 376, + "second": 68, + "amount": -1 + }, + { + "first": 376, + "second": 69, + "amount": -1 + }, + { + "first": 376, + "second": 70, + "amount": -1 + }, + { + "first": 376, + "second": 71, + "amount": -3 + }, + { + "first": 376, + "second": 72, + "amount": -1 + }, + { + "first": 376, + "second": 73, + "amount": -1 + }, + { + "first": 376, + "second": 74, + "amount": -6 + }, + { + "first": 376, + "second": 75, + "amount": -1 + }, + { + "first": 376, + "second": 76, + "amount": -1 + }, + { + "first": 376, + "second": 77, + "amount": -1 + }, + { + "first": 376, + "second": 78, + "amount": -1 + }, + { + "first": 376, + "second": 79, + "amount": -3 + }, + { + "first": 376, + "second": 80, + "amount": -1 + }, + { + "first": 376, + "second": 81, + "amount": -3 + }, + { + "first": 376, + "second": 82, + "amount": -1 + }, + { + "first": 376, + "second": 83, + "amount": -1 + }, + { + "first": 376, + "second": 88, + "amount": -1 + }, + { + "first": 376, + "second": 97, + "amount": -4 + }, + { + "first": 376, + "second": 99, + "amount": -4 + }, + { + "first": 376, + "second": 100, + "amount": -4 + }, + { + "first": 376, + "second": 101, + "amount": -4 + }, + { + "first": 376, + "second": 102, + "amount": -1 + }, + { + "first": 376, + "second": 103, + "amount": -4 + }, + { + "first": 376, + "second": 109, + "amount": -1 + }, + { + "first": 376, + "second": 110, + "amount": -1 + }, + { + "first": 376, + "second": 111, + "amount": -4 + }, + { + "first": 376, + "second": 112, + "amount": -1 + }, + { + "first": 376, + "second": 113, + "amount": -4 + }, + { + "first": 376, + "second": 114, + "amount": -1 + }, + { + "first": 376, + "second": 115, + "amount": -3 + }, + { + "first": 376, + "second": 117, + "amount": -1 + }, + { + "first": 376, + "second": 118, + "amount": -2 + }, + { + "first": 376, + "second": 119, + "amount": -1 + }, + { + "first": 376, + "second": 120, + "amount": -3 + }, + { + "first": 376, + "second": 121, + "amount": -2 + }, + { + "first": 376, + "second": 122, + "amount": -2 + }, + { + "first": 376, + "second": 169, + "amount": -3 + }, + { + "first": 376, + "second": 171, + "amount": -4 + }, + { + "first": 376, + "second": 174, + "amount": -3 + }, + { + "first": 376, + "second": 180, + "amount": -3 + }, + { + "first": 376, + "second": 181, + "amount": -1 + }, + { + "first": 376, + "second": 187, + "amount": -1 + }, + { + "first": 376, + "second": 192, + "amount": -5 + }, + { + "first": 376, + "second": 193, + "amount": -5 + }, + { + "first": 376, + "second": 194, + "amount": -5 + }, + { + "first": 376, + "second": 195, + "amount": -5 + }, + { + "first": 376, + "second": 196, + "amount": -5 + }, + { + "first": 376, + "second": 197, + "amount": -5 + }, + { + "first": 376, + "second": 198, + "amount": -5 + }, + { + "first": 376, + "second": 199, + "amount": -3 + }, + { + "first": 376, + "second": 200, + "amount": -1 + }, + { + "first": 376, + "second": 201, + "amount": -1 + }, + { + "first": 376, + "second": 202, + "amount": -1 + }, + { + "first": 376, + "second": 203, + "amount": -1 + }, + { + "first": 376, + "second": 204, + "amount": -1 + }, + { + "first": 376, + "second": 205, + "amount": -1 + }, + { + "first": 376, + "second": 206, + "amount": -1 + }, + { + "first": 376, + "second": 207, + "amount": -1 + }, + { + "first": 376, + "second": 209, + "amount": -1 + }, + { + "first": 376, + "second": 210, + "amount": -3 + }, + { + "first": 376, + "second": 211, + "amount": -3 + }, + { + "first": 376, + "second": 212, + "amount": -3 + }, + { + "first": 376, + "second": 213, + "amount": -3 + }, + { + "first": 376, + "second": 214, + "amount": -3 + }, + { + "first": 376, + "second": 216, + "amount": -3 + }, + { + "first": 376, + "second": 222, + "amount": -1 + }, + { + "first": 376, + "second": 224, + "amount": -3 + }, + { + "first": 376, + "second": 225, + "amount": -3 + }, + { + "first": 376, + "second": 226, + "amount": -3 + }, + { + "first": 376, + "second": 227, + "amount": -3 + }, + { + "first": 376, + "second": 228, + "amount": -3 + }, + { + "first": 376, + "second": 229, + "amount": -3 + }, + { + "first": 376, + "second": 230, + "amount": -4 + }, + { + "first": 376, + "second": 231, + "amount": -4 + }, + { + "first": 376, + "second": 232, + "amount": -3 + }, + { + "first": 376, + "second": 233, + "amount": -3 + }, + { + "first": 376, + "second": 234, + "amount": -3 + }, + { + "first": 376, + "second": 235, + "amount": -3 + }, + { + "first": 376, + "second": 236, + "amount": 3 + }, + { + "first": 376, + "second": 237, + "amount": 3 + }, + { + "first": 376, + "second": 238, + "amount": 3 + }, + { + "first": 376, + "second": 239, + "amount": 3 + }, + { + "first": 376, + "second": 240, + "amount": -3 + }, + { + "first": 376, + "second": 241, + "amount": -1 + }, + { + "first": 376, + "second": 242, + "amount": -3 + }, + { + "first": 376, + "second": 243, + "amount": -3 + }, + { + "first": 376, + "second": 244, + "amount": -3 + }, + { + "first": 376, + "second": 245, + "amount": -3 + }, + { + "first": 376, + "second": 246, + "amount": -3 + }, + { + "first": 376, + "second": 248, + "amount": -4 + }, + { + "first": 376, + "second": 249, + "amount": -1 + }, + { + "first": 376, + "second": 250, + "amount": -1 + }, + { + "first": 376, + "second": 251, + "amount": -1 + }, + { + "first": 376, + "second": 252, + "amount": -1 + }, + { + "first": 376, + "second": 253, + "amount": -2 + }, + { + "first": 376, + "second": 255, + "amount": -2 + }, + { + "first": 376, + "second": 338, + "amount": -3 + }, + { + "first": 376, + "second": 339, + "amount": -4 + } + ] +} diff --git a/src/client/render/gl/assets/msdf-atlas.png b/src/client/render/gl/assets/msdf-atlas.png new file mode 100644 index 000000000..d4987e31b Binary files /dev/null and b/src/client/render/gl/assets/msdf-atlas.png differ diff --git a/src/client/render/gl/assets/status-atlas-meta.json b/src/client/render/gl/assets/status-atlas-meta.json new file mode 100644 index 000000000..32cebd1cc --- /dev/null +++ b/src/client/render/gl/assets/status-atlas-meta.json @@ -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 + } +} diff --git a/src/client/render/gl/assets/status-atlas.png b/src/client/render/gl/assets/status-atlas.png new file mode 100644 index 000000000..944dbf204 Binary files /dev/null and b/src/client/render/gl/assets/status-atlas.png differ diff --git a/src/client/render/gl/assets/unit-atlas.png b/src/client/render/gl/assets/unit-atlas.png new file mode 100644 index 000000000..c4ddefb24 Binary files /dev/null and b/src/client/render/gl/assets/unit-atlas.png differ diff --git a/src/client/render/gl/camera.ts b/src/client/render/gl/camera.ts new file mode 100644 index 000000000..1bd4cbf8d --- /dev/null +++ b/src/client/render/gl/camera.ts @@ -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), + ); + } +} diff --git a/src/client/render/gl/debug/config-prop.ts b/src/client/render/gl/debug/config-prop.ts new file mode 100644 index 000000000..a6b9b585f --- /dev/null +++ b/src/client/render/gl/debug/config-prop.ts @@ -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; +} diff --git a/src/client/render/gl/debug/folder.ts b/src/client/render/gl/debug/folder.ts new file mode 100644 index 000000000..97e9e2077 --- /dev/null +++ b/src/client/render/gl/debug/folder.ts @@ -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 }; +} diff --git a/src/client/render/gl/debug/index.ts b/src/client/render/gl/debug/index.ts new file mode 100644 index 000000000..10077477d --- /dev/null +++ b/src/client/render/gl/debug/index.ts @@ -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; +} diff --git a/src/client/render/gl/debug/layout.ts b/src/client/render/gl/debug/layout.ts new file mode 100644 index 000000000..9bfd49a81 --- /dev/null +++ b/src/client/render/gl/debug/layout.ts @@ -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), + ]), + ), + ), + ]; +} diff --git a/src/client/render/gl/debug/props/color.ts b/src/client/render/gl/debug/props/color.ts new file mode 100644 index 000000000..095072a22 --- /dev/null +++ b/src/client/render/gl/debug/props/color.ts @@ -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>( + 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)[rKey] = v.r; + (target as Record)[gKey] = v.g; + (target as Record)[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)[rKey] = defaultR; + (target as Record)[gKey] = defaultG; + (target as Record)[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(); + }, + }; +} diff --git a/src/client/render/gl/debug/props/select.ts b/src/client/render/gl/debug/props/select.ts new file mode 100644 index 000000000..34ac0a893 --- /dev/null +++ b/src/client/render/gl/debug/props/select.ts @@ -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>( + 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)[key] = defaultVal; + ctrl?.updateDisplay(); + }, + updateDisplay() { + ctrl?.updateDisplay(); + }, + }; +} diff --git a/src/client/render/gl/debug/props/slider.ts b/src/client/render/gl/debug/props/slider.ts new file mode 100644 index 000000000..016591dc4 --- /dev/null +++ b/src/client/render/gl/debug/props/slider.ts @@ -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>( + 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)[key] = defaultVal; + ctrl?.updateDisplay(); + }, + updateDisplay() { + ctrl?.updateDisplay(); + }, + }; +} diff --git a/src/client/render/gl/debug/props/toggle.ts b/src/client/render/gl/debug/props/toggle.ts new file mode 100644 index 000000000..0429fbefe --- /dev/null +++ b/src/client/render/gl/debug/props/toggle.ts @@ -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>( + 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)[key] = defaultVal; + ctrl?.updateDisplay(); + }, + updateDisplay() { + ctrl?.updateDisplay(); + }, + }; +} diff --git a/src/client/render/gl/debug/tree.ts b/src/client/render/gl/debug/tree.ts new file mode 100644 index 000000000..e7caa6995 --- /dev/null +++ b/src/client/render/gl/debug/tree.ts @@ -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"; +} diff --git a/src/client/render/gl/debug/wiring.ts b/src/client/render/gl/debug/wiring.ts new file mode 100644 index 000000000..1af7ef0ae --- /dev/null +++ b/src/client/render/gl/debug/wiring.ts @@ -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)); + } +} diff --git a/src/client/render/gl/dynamic-buffer.ts b/src/client/render/gl/dynamic-buffer.ts new file mode 100644 index 000000000..86ce6f89f --- /dev/null +++ b/src/client/render/gl/dynamic-buffer.ts @@ -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); + } + } +} diff --git a/src/client/render/gl/events.ts b/src/client/render/gl/events.ts new file mode 100644 index 000000000..a4a691105 --- /dev/null +++ b/src/client/render/gl/events.ts @@ -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; diff --git a/src/client/render/gl/game-view.ts b/src/client/render/gl/game-view.ts new file mode 100644 index 000000000..05fc29767 --- /dev/null +++ b/src/client/render/gl/game-view.ts @@ -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 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( + 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( + event: K, + handler: (e: GameViewEventMap[K]) => void, + ): void { + this.listeners.get(event)?.delete(handler as (e: unknown) => void); + } + + private emit( + 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, gameTick: number): void { + this.renderer.updateUnits(units, gameTick); + } + updateNames( + names: Map, + players: Map, + snap: boolean, + statusData?: Map, + ): void { + this.renderer.updateNames(names, players, snap, statusData); + } + updateRelations(data: Uint8Array, size: number): void { + this.renderer.updateRelations(data, size); + } + updateStructures(units: Map): 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): void { + this.renderer.setSAMPerspective(playerID, allies); + } + setSAMColorMode(mode: "perspective" | "owner"): void { + this.renderer.setSAMColorMode(mode); + } + setSAMAllianceClusters(clusters: Map): 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(); + } +} diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts new file mode 100644 index 000000000..93e4ee05a --- /dev/null +++ b/src/client/render/gl/index.ts @@ -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"; diff --git a/src/client/render/gl/keyboard-pan.ts b/src/client/render/gl/keyboard-pan.ts new file mode 100644 index 000000000..797e9e0f5 --- /dev/null +++ b/src/client/render/gl/keyboard-pan.ts @@ -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(); + 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; +} diff --git a/src/client/render/gl/map-interaction.ts b/src/client/render/gl/map-interaction.ts new file mode 100644 index 000000000..4e353206e --- /dev/null +++ b/src/client/render/gl/map-interaction.ts @@ -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: ( + 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(); + } +} diff --git a/src/client/render/gl/passes/bar-pass.ts b/src/client/render/gl/passes/bar-pass.ts new file mode 100644 index 000000000..8f14c8466 --- /dev/null +++ b/src/client/render/gl/passes/bar-pass.ts @@ -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, + structures: Map, + 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; + } +} diff --git a/src/client/render/gl/passes/border-compute-pass.ts b/src/client/render/gl/passes/border-compute-pass.ts new file mode 100644 index 000000000..625aefd00 --- /dev/null +++ b/src/client/render/gl/passes/border-compute-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/border-stamp-pass.ts b/src/client/render/gl/passes/border-stamp-pass.ts new file mode 100644 index 000000000..4d247c114 --- /dev/null +++ b/src/client/render/gl/passes/border-stamp-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/conquest-popup-pass.ts b/src/client/render/gl/passes/conquest-popup-pass.ts new file mode 100644 index 000000000..01cb68640 --- /dev/null +++ b/src/client/render/gl/passes/conquest-popup-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/coordinate-grid-pass.ts b/src/client/render/gl/passes/coordinate-grid-pass.ts new file mode 100644 index 000000000..b9a9f0e2f --- /dev/null +++ b/src/client/render/gl/passes/coordinate-grid-pass.ts @@ -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); +} diff --git a/src/client/render/gl/passes/crosshair-pass.ts b/src/client/render/gl/passes/crosshair-pass.ts new file mode 100644 index 000000000..9d07fe370 --- /dev/null +++ b/src/client/render/gl/passes/crosshair-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/fallout-bloom-pass.ts b/src/client/render/gl/passes/fallout-bloom-pass.ts new file mode 100644 index 000000000..62c6b27f7 --- /dev/null +++ b/src/client/render/gl/passes/fallout-bloom-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/fallout-light-pass.ts b/src/client/render/gl/passes/fallout-light-pass.ts new file mode 100644 index 000000000..15828cfe5 --- /dev/null +++ b/src/client/render/gl/passes/fallout-light-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/fx-pass/fx-attack-ring-pass.ts b/src/client/render/gl/passes/fx-pass/fx-attack-ring-pass.ts new file mode 100644 index 000000000..bc66eda30 --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/fx-attack-ring-pass.ts @@ -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(); + 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); + } +} diff --git a/src/client/render/gl/passes/fx-pass/fx-shockwave-pass.ts b/src/client/render/gl/passes/fx-pass/fx-shockwave-pass.ts new file mode 100644 index 000000000..62d011986 --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/fx-shockwave-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/fx-pass/fx-sprite-pass.ts b/src/client/render/gl/passes/fx-pass/fx-sprite-pass.ts new file mode 100644 index 000000000..ecd61d0a6 --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/fx-sprite-pass.ts @@ -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 { + 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); + } +} diff --git a/src/client/render/gl/passes/fx-pass/index.ts b/src/client/render/gl/passes/fx-pass/index.ts new file mode 100644 index 000000000..b46a8b7bf --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/index.ts @@ -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(); + } +} diff --git a/src/client/render/gl/passes/lightmap-pass.ts b/src/client/render/gl/passes/lightmap-pass.ts new file mode 100644 index 000000000..8107abe6e --- /dev/null +++ b/src/client/render/gl/passes/lightmap-pass.ts @@ -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 + } +} diff --git a/src/client/render/gl/passes/move-indicator-pass.ts b/src/client/render/gl/passes/move-indicator-pass.ts new file mode 100644 index 000000000..42c8ac30b --- /dev/null +++ b/src/client/render/gl/passes/move-indicator-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/name-pass/atlas-data.ts b/src/client/render/gl/passes/name-pass/atlas-data.ts new file mode 100644 index 000000000..73ef508c3 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/atlas-data.ts @@ -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 { + const map = new Map(); + const meta = flagAtlasMeta as { flags: Record }; + for (const [code, idx] of Object.entries(meta.flags)) { + map.set(code, idx); + } + return map; +} + +export function buildEmojiLookup(): Map { + const map = new Map(); + const meta = emojiAtlasMeta as { emojis: Record }; + for (const [ch, idx] of Object.entries(meta.emojis)) { + map.set(ch, idx); + } + return map; +} diff --git a/src/client/render/gl/passes/name-pass/data-textures.ts b/src/client/render/gl/passes/name-pass/data-textures.ts new file mode 100644 index 000000000..b97ceb507 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/data-textures.ts @@ -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), + }); +} diff --git a/src/client/render/gl/passes/name-pass/debug-program.ts b/src/client/render/gl/passes/name-pass/debug-program.ts new file mode 100644 index 000000000..71eab14d6 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/debug-program.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/name-pass/icon-program.ts b/src/client/render/gl/passes/name-pass/icon-program.ts new file mode 100644 index 000000000..fb04f6353 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/icon-program.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts new file mode 100644 index 000000000..3f011452d --- /dev/null +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -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; + private smallIDToPlayerID: Map; + private slots: Map = new Map(); + private maxPlayers: number; + private playerColors: Map = new Map(); + private flagCodeToIndex: Map; + private emojiCharToIndex: Map; + + // 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(); + private troopsByPlayerID = new Map(); + private playerStateByID = new Map(); + + 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, + players: Map, + snap: boolean, + statusData?: Map, + ): 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); + } +} diff --git a/src/client/render/gl/passes/name-pass/status-icon-program.ts b/src/client/render/gl/passes/name-pass/status-icon-program.ts new file mode 100644 index 000000000..6f04ad5b6 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/status-icon-program.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/name-pass/text-layout.ts b/src/client/render/gl/passes/name-pass/text-layout.ts new file mode 100644 index 000000000..5806084bc --- /dev/null +++ b/src/client/render/gl/passes/name-pass/text-layout.ts @@ -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); +} diff --git a/src/client/render/gl/passes/name-pass/text-program.ts b/src/client/render/gl/passes/name-pass/text-program.ts new file mode 100644 index 000000000..8db2cadae --- /dev/null +++ b/src/client/render/gl/passes/name-pass/text-program.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/name-pass/types.ts b/src/client/render/gl/passes/name-pass/types.ts new file mode 100644 index 000000000..85e3d9c8b --- /dev/null +++ b/src/client/render/gl/passes/name-pass/types.ts @@ -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; diff --git a/src/client/render/gl/passes/night-composite-pass.ts b/src/client/render/gl/passes/night-composite-pass.ts new file mode 100644 index 000000000..b87146bd4 --- /dev/null +++ b/src/client/render/gl/passes/night-composite-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/nuke-telegraph-pass.ts b/src/client/render/gl/passes/nuke-telegraph-pass.ts new file mode 100644 index 000000000..17ee6a419 --- /dev/null +++ b/src/client/render/gl/passes/nuke-telegraph-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/nuke-trajectory-pass.ts b/src/client/render/gl/passes/nuke-trajectory-pass.ts new file mode 100644 index 000000000..cb661bf7e --- /dev/null +++ b/src/client/render/gl/passes/nuke-trajectory-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/point-light-pass.ts b/src/client/render/gl/passes/point-light-pass.ts new file mode 100644 index 000000000..bbe535fac --- /dev/null +++ b/src/client/render/gl/passes/point-light-pass.ts @@ -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 = { + [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(); + 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): 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); + } +} diff --git a/src/client/render/gl/passes/radial-menu-pass.ts b/src/client/render/gl/passes/radial-menu-pass.ts new file mode 100644 index 000000000..d195897b9 --- /dev/null +++ b/src/client/render/gl/passes/radial-menu-pass.ts @@ -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 { + const map = new Map(); + const emojis = (emojiAtlasMeta as { emojis: Record }).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; + 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; + }; + 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); + } +} diff --git a/src/client/render/gl/passes/railroad-pass.ts b/src/client/render/gl/passes/railroad-pass.ts new file mode 100644 index 000000000..1892f8d66 --- /dev/null +++ b/src/client/render/gl/passes/railroad-pass.ts @@ -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 + } +} diff --git a/src/client/render/gl/passes/range-circle-pass.ts b/src/client/render/gl/passes/range-circle-pass.ts new file mode 100644 index 000000000..33241e6cc --- /dev/null +++ b/src/client/render/gl/passes/range-circle-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/sam-radius-pass.ts b/src/client/render/gl/passes/sam-radius-pass.ts new file mode 100644 index 000000000..b5cf0cc98 --- /dev/null +++ b/src/client/render/gl/passes/sam-radius-pass.ts @@ -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(); + + // Owner-color mode fields + private paletteData: Float32Array | null = null; + private colorMode: "perspective" | "owner" = "perspective"; + private allianceClusters: Map = new Map(); + private lastStructures: Map | 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): 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): 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): 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); + } +} diff --git a/src/client/render/gl/passes/selection-box-pass.ts b/src/client/render/gl/passes/selection-box-pass.ts new file mode 100644 index 000000000..cf4af16cf --- /dev/null +++ b/src/client/render/gl/passes/selection-box-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/spawn-overlay-pass.ts b/src/client/render/gl/passes/spawn-overlay-pass.ts new file mode 100644 index 000000000..7d9936e12 --- /dev/null +++ b/src/client/render/gl/passes/spawn-overlay-pass.ts @@ -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 + } +} diff --git a/src/client/render/gl/passes/structure-level-pass.ts b/src/client/render/gl/passes/structure-level-pass.ts new file mode 100644 index 000000000..27e616458 --- /dev/null +++ b/src/client/render/gl/passes/structure-level-pass.ts @@ -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(); + /** 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): 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); + } +} diff --git a/src/client/render/gl/passes/structure-pass.ts b/src/client/render/gl/passes/structure-pass.ts new file mode 100644 index 000000000..d0f89c258 --- /dev/null +++ b/src/client/render/gl/passes/structure-pass.ts @@ -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(); + 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 { + 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): 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); + } +} diff --git a/src/client/render/gl/passes/terrain-pass.ts b/src/client/render/gl/passes/terrain-pass.ts new file mode 100644 index 000000000..812f43c13 --- /dev/null +++ b/src/client/render/gl/passes/terrain-pass.ts @@ -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) + } +} diff --git a/src/client/render/gl/passes/territory-pass.ts b/src/client/render/gl/passes/territory-pass.ts new file mode 100644 index 000000000..c2e748575 --- /dev/null +++ b/src/client/render/gl/passes/territory-pass.ts @@ -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 + } +} diff --git a/src/client/render/gl/passes/trail-pass.ts b/src/client/render/gl/passes/trail-pass.ts new file mode 100644 index 000000000..5cad9b737 --- /dev/null +++ b/src/client/render/gl/passes/trail-pass.ts @@ -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); + } +} diff --git a/src/client/render/gl/passes/unit-pass.ts b/src/client/render/gl/passes/unit-pass.ts new file mode 100644 index 000000000..08be21d47 --- /dev/null +++ b/src/client/render/gl/passes/unit-pass.ts @@ -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 = 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 = 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(); + private mapW: number; + + // Trade-friendly detection: enemy trade ships heading to a self/allied port + private localPlayerID = 0; + private friendlyOwners = new Set(); + private structures: Map = 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 { + 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, 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): void { + this.friendlyOwners = allies; + } + setStructures(structs: Map): 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); + } +} diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json new file mode 100644 index 000000000..c956c4ce8 --- /dev/null +++ b/src/client/render/gl/render-settings.json @@ -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 + } + } +} diff --git a/src/client/render/gl/render-settings.ts b/src/client/render/gl/render-settings.ts new file mode 100644 index 000000000..bd67bfea5 --- /dev/null +++ b/src/client/render/gl/render-settings.ts @@ -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; + 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; +} + +/** 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); +} diff --git a/src/client/render/gl/renderer.ts b/src/client/render/gl/renderer.ts new file mode 100644 index 000000000..dd42383bb --- /dev/null +++ b/src/client/render/gl/renderer.ts @@ -0,0 +1,1136 @@ +/** + * GPURenderer v2 — normalized render pipeline. + * + * Draw order: + * DATA SYNC: tile flush → heat update → border compute + * BASE PASS (darkened by night): terrain → territory fill + fallout charcoal + * NIGHT COMPOSITE (optional): lightmap → scene × (ambient + lightmap) + * FULL BRIGHTNESS (always): borders → railroads → ground units → structures → + * structure levels → bars → bloom → trails → missiles → fx → conquest → names + */ + +import type { + AttackRingInput, + BonusEvent, + ConquestFx, + DeadUnitFx, + GhostPreviewData, + NameEntry, + NukeTelegraphData, + NukeTrajectoryData, + PlayerState, + PlayerStatic, + PlayerStatusData, + RendererConfig, + TilePair, + UnitState, +} from "../types"; +import { Camera } from "./camera"; +import type { RadialMenuItem } from "./events"; +import { BarPass } from "./passes/bar-pass"; +import { BorderComputePass } from "./passes/border-compute-pass"; +import { BorderStampPass } from "./passes/border-stamp-pass"; +import { ConquestPopupPass } from "./passes/conquest-popup-pass"; +import { CoordinateGridPass } from "./passes/coordinate-grid-pass"; +import { CrosshairPass } from "./passes/crosshair-pass"; +import { FalloutBloomPass } from "./passes/fallout-bloom-pass"; +import { FalloutLightPass } from "./passes/fallout-light-pass"; +import { FxPass } from "./passes/fx-pass"; +import { LightmapPass } from "./passes/lightmap-pass"; +import { MoveIndicatorPass } from "./passes/move-indicator-pass"; +import { NamePass } from "./passes/name-pass"; +import { NightCompositePass } from "./passes/night-composite-pass"; +import { NukeTelegraphPass } from "./passes/nuke-telegraph-pass"; +import { NukeTrajectoryPass } from "./passes/nuke-trajectory-pass"; +import { PointLightPass } from "./passes/point-light-pass"; +import { RadialMenuPass } from "./passes/radial-menu-pass"; +import { RailroadPass } from "./passes/railroad-pass"; +import { RangeCirclePass } from "./passes/range-circle-pass"; +import { SAMRadiusPass } from "./passes/sam-radius-pass"; +import { SelectionBoxPass } from "./passes/selection-box-pass"; +import type { SpawnCenter } from "./passes/spawn-overlay-pass"; +import { SpawnOverlayPass } from "./passes/spawn-overlay-pass"; +import { StructureLevelPass } from "./passes/structure-level-pass"; +import { StructurePass } from "./passes/structure-pass"; +import { TerrainPass } from "./passes/terrain-pass"; +import { TerritoryPass } from "./passes/territory-pass"; +import { TrailPass } from "./passes/trail-pass"; +import { UnitPass } from "./passes/unit-pass"; +import { createRenderSettings, type RenderSettings } from "./render-settings"; +import { AffiliationPalette } from "./utils/affiliation"; +import { buildTerrainRGBA, getPaletteSize } from "./utils/color-utils"; +import { + createTexture2D, + toScreen, + toTarget, + type RenderTarget, +} from "./utils/gl-utils"; +import { + createGPUResources, + disposeGPUResources, + type GPUResources, +} from "./utils/gpu-resources"; +import { HeatManager } from "./utils/heat-manager"; + +/** Ghost types that trigger SAM radius overlay (matches upstream SAMRadiusLayer). */ +const SAM_RADIUS_GHOST_TYPES = new Set([ + "Missile Silo", + "SAM Launcher", + "City", + "Atom Bomb", + "Hydrogen Bomb", +]); + +/** Subset for build-button hover — excludes City/Silo (SAM radii irrelevant). */ +const SAM_RADIUS_HIGHLIGHT_TYPES = new Set([ + "SAM Launcher", + "Atom Bomb", + "Hydrogen Bomb", +]); + +export class GPURenderer { + private gl: WebGL2RenderingContext; + private camera: Camera; + private res: GPUResources; + + // Passes + private terrainPass: TerrainPass; + private territoryPass: TerritoryPass; + private trailPass: TrailPass; + private borderStampPass: BorderStampPass; + private borderPass: BorderComputePass; + private bloomPass: FalloutBloomPass; + private pointLightPass: PointLightPass; + private falloutLightPass: FalloutLightPass; + private lightmapPass: LightmapPass; + private nightCompositePass: NightCompositePass; + private structurePass: StructurePass; + private structureLevelPass: StructureLevelPass; + private unitPass: UnitPass; + private namePass: NamePass; + private fxPass: FxPass; + private rangeCirclePass: RangeCirclePass; + private samRadiusPass: SAMRadiusPass; + private crosshairPass: CrosshairPass; + private railroadPass: RailroadPass; + private barPass: BarPass; + private conquestPopupPass: ConquestPopupPass; + private radialMenuPass: RadialMenuPass; + private selectionBoxPass: SelectionBoxPass; + private moveIndicatorPass: MoveIndicatorPass; + private nukeTrajectoryPass: NukeTrajectoryPass; + private nukeTelegraphPass: NukeTelegraphPass; + private heatManager: HeatManager; + private affiliationPalette: AffiliationPalette; + private coordinateGridPass: CoordinateGridPass; + private spawnOverlayPass: SpawnOverlayPass; + + private paletteTex: WebGLTexture; + private paletteData: Float32Array; + private canvas: HTMLCanvasElement; + private settings: RenderSettings; + private sceneTarget: RenderTarget; + private raf: typeof requestAnimationFrame; + private caf: typeof cancelAnimationFrame; + + private animId: number | null = null; + private frameTick = 0; + private gameTick = 0; + private mapW = 0; + private mapH = 0; + + // FPS tracking + private frameTimes: Float64Array = new Float64Array(60); + private frameIdx = 0; + private frameCount = 0; + fps = 0; + onFrame: ((ms: number) => void) | null = null; + afterRender: ((canvas: HTMLCanvasElement) => void) | null = null; + + // Hit-testing references + private lastUnits: Map = new Map(); + private lastStructures: Map = new Map(); + + // Local player relationship data (for SAM radius coloring) + private localPlayerID = 0; + private playerTeams = new Map(); // smallID → team + + // Alt-view: affiliation recoloring (space hold) + private altView = false; + // Grid-view: coordinate grid overlay (M toggle) + private gridView = false; + + // SAM radius visibility tracking (show if either source is true) + private samGhostVisible = false; + private samHighlightVisible = false; + + // Warship selection + private selectedUnitId: number | null = null; + + constructor( + canvas: HTMLCanvasElement, + header: RendererConfig, + terrainBytes: Uint8Array, + paletteData: Float32Array, + raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window), + caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window), + ) { + this.canvas = canvas; + this.settings = createRenderSettings(); + this.raf = raf; + this.caf = caf; + + const gl = canvas.getContext("webgl2", { + alpha: false, + antialias: false, + powerPreference: "high-performance", + }); + if (!gl) throw new Error("WebGL2 not supported"); + this.gl = gl; + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + + const floatExt = gl.getExtension("EXT_color_buffer_float"); + if (!floatExt) + console.warn("EXT_color_buffer_float not available — palette may fail"); + + const mapW = header.mapWidth; + const mapH = header.mapHeight; + this.mapW = mapW; + this.mapH = mapH; + + this.camera = new Camera(mapW, mapH); + + // --- Terrain (static) --- + const terrainRGBA = buildTerrainRGBA(terrainBytes, mapW, mapH); + this.terrainPass = new TerrainPass(gl, terrainRGBA, mapW, mapH); + + // --- Shared palette texture (RGBA32F, 4096×2) --- + this.paletteData = paletteData; + const palW = getPaletteSize(); + this.paletteTex = createTexture2D(gl, { + width: palW, + height: 2, + internalFormat: gl.RGBA32F, + format: gl.RGBA, + type: gl.FLOAT, + data: paletteData, + filter: gl.NEAREST, + }); + + // --- Border compute (creates its own borderTex) --- + // Need a temporary tileTex reference for border compute — we'll create + // GPUResources first, then wire everything. + // But borderPass creates its own borderTex internally, so we need to + // create GPUResources with it. Let's sequence carefully: + + // 1. Create GPUResources (creates tileTex, trailTex, heatTexA/B) + // borderTex placeholder — we'll get it from borderPass + // First create a dummy, then replace after borderPass is created. + + // Actually: borderPass creates its own internal borderTex (RGBA8). + // We need tileTex to exist before borderPass. So: + // a) Create shared resources (tileTex, trailTex, heatA/B) + // b) Create borderPass with tileTex → gives us borderTex + // c) Store borderTex in res + + // Create shared textures except borderTex + this.res = createGPUResources(gl, mapW, mapH, this.paletteTex, null!); + + // --- Border compute (needs tileTex) --- + this.borderPass = new BorderComputePass( + gl, + mapW, + mapH, + this.res.tileTex, + this.settings, + ); + this.res.borderTex = this.borderPass.getBorderTex(); + + // --- Heat manager (needs tileTex, heatTexA/B) --- + this.heatManager = new HeatManager( + gl, + mapW, + mapH, + this.res.tileTex, + this.res.heatTexA, + this.res.heatTexB, + this.settings, + ); + + // --- Territory (needs tileTex, trailTex, paletteTex) --- + this.territoryPass = new TerritoryPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.res.trailTex, + this.paletteTex, + this.settings, + ); + + // --- Spawn overlay (needs tileTex) --- + this.spawnOverlayPass = new SpawnOverlayPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.settings.spawnOverlay, + ); + + // --- Trail (needs trailTex, paletteTex) --- + this.trailPass = new TrailPass( + gl, + mapW, + mapH, + this.res.trailTex, + this.paletteTex, + this.settings, + ); + + // --- Border stamp (needs tileTex, paletteTex, borderTex) --- + this.borderStampPass = new BorderStampPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.paletteTex, + this.res.borderTex, + this.settings, + ); + + // --- Fallout bloom (needs tileTex, heatManager) --- + this.bloomPass = new FalloutBloomPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.heatManager, + this.settings, + ); + + // --- Point lights --- + this.pointLightPass = new PointLightPass( + gl, + header, + paletteData, + this.settings, + ); + + // --- Fallout light (needs tileTex, borderTex, heatManager) --- + this.falloutLightPass = new FalloutLightPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.res.borderTex, + this.heatManager, + this.settings, + ); + + // --- Lightmap orchestrator --- + this.lightmapPass = new LightmapPass( + gl, + mapW, + mapH, + this.pointLightPass, + this.falloutLightPass, + this.settings, + ); + + // --- Night composite --- + this.nightCompositePass = new NightCompositePass(gl, this.settings); + + // --- Railroad (needs tileTex) --- + this.railroadPass = new RailroadPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.paletteTex, + terrainBytes, + this.settings, + ); + + // --- Range circle (ghost preview radius) --- + this.rangeCirclePass = new RangeCirclePass(gl); + + // --- SAM radius overlay (dashed green circles during build mode) --- + this.samRadiusPass = new SAMRadiusPass(gl, mapW, this.settings); + this.samRadiusPass.setPaletteData(paletteData); + + // --- Crosshair (warship placement) --- + this.crosshairPass = new CrosshairPass(gl); + + // --- Remaining passes (unchanged from v1) --- + this.structurePass = new StructurePass( + gl, + header, + this.paletteTex, + this.settings, + ); + this.structureLevelPass = new StructureLevelPass(gl, header, this.settings); + this.unitPass = new UnitPass(gl, header, this.paletteTex, this.settings); + this.namePass = new NamePass(gl, header, paletteData, this.settings); + this.fxPass = new FxPass(gl, header, this.settings); + this.barPass = new BarPass(gl, header, this.settings); + this.conquestPopupPass = new ConquestPopupPass(gl, this.settings); + this.conquestPopupPass.setMapWidth(this.mapW); + this.radialMenuPass = new RadialMenuPass(gl); + this.selectionBoxPass = new SelectionBoxPass(gl); + this.moveIndicatorPass = new MoveIndicatorPass(gl, this.settings); + this.nukeTrajectoryPass = new NukeTrajectoryPass(gl, this.settings); + this.nukeTelegraphPass = new NukeTelegraphPass(gl, this.settings); + + // --- Scene capture target (for night composite) --- + const sceneTex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, sceneTex); + 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); + const sceneFbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + sceneTex, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + this.sceneTarget = { fbo: sceneFbo, tex: sceneTex, w: 1, h: 1 }; + + // --- Alt-view passes --- + this.affiliationPalette = new AffiliationPalette(gl); + const affTex = this.affiliationPalette.getTexture(); + this.borderStampPass.setAffiliationTex(affTex); + this.unitPass.setAffiliationTex(affTex); + this.structurePass.setAffiliationTex(affTex); + this.trailPass.setAffiliationTex(affTex); + this.coordinateGridPass = new CoordinateGridPass( + gl, + mapW, + mapH, + this.settings, + ); + + for (const p of header.players) { + if (p.team !== null) this.playerTeams.set(p.smallID, p.team); + } + + this.startLoop(); + } + + private renderLoop = (): void => { + this.draw(); + this.animId = this.raf(this.renderLoop); + }; + + private startLoop(): void { + this.animId ??= this.raf(this.renderLoop); + } + + private stopLoop(): void { + if (this.animId !== null) { + this.caf(this.animId); + this.animId = null; + } + } + + // --------------------------------------------------------------------------- + // Canvas / Camera + // --------------------------------------------------------------------------- + + resize(cssWidth: number, cssHeight: number): void { + const dpr = window.devicePixelRatio || 1; + this.canvas.width = Math.round(cssWidth * dpr); + this.canvas.height = Math.round(cssHeight * dpr); + this.camera.resize(cssWidth, cssHeight); + } + + screenToWorld(screenX: number, screenY: number): { x: number; y: number } { + return this.camera.screenToWorld(screenX, screenY); + } + + worldToScreen(worldX: number, worldY: number): { x: number; y: number } { + return this.camera.worldToScreen(worldX, worldY); + } + + panTo(worldX: number, worldY: number): void { + this.camera.panTo(worldX, worldY); + } + panBy(dx: number, dy: number): void { + this.camera.panBy(dx, dy); + } + zoomTo(level: number): void { + this.camera.zoomTo(level); + } + zoomBy(factor: number): void { + this.camera.zoomBy(factor); + } + zoomAtScreen(factor: number, screenX: number, screenY: number): void { + this.camera.zoomAtScreen(factor, screenX, screenY); + } + fitMap(): void { + this.camera.fitMap(); + } + focusBBox( + minX: number, + minY: number, + maxX: number, + maxY: number, + padding?: number, + ): void { + this.camera.focusBBox(minX, minY, maxX, maxY, padding); + } + getCameraState(): { x: number; y: number; z: number } { + return { + x: this.camera.offsetX, + y: this.camera.offsetY, + z: this.camera.zoom, + }; + } + setCameraState(x: number, y: number, z: number): void { + this.camera.setCameraState(x, y, z); + } + get zoom(): number { + return this.camera.zoom; + } + + // --------------------------------------------------------------------------- + // Data upload + // --------------------------------------------------------------------------- + + applyFullFrame( + tileState: Uint16Array, + trailState: Uint8Array, + nukeEvents?: Array<{ tick: number; tiles: number[] }>, + currentTick?: number, + ): void { + this.territoryPass.uploadFullTileState(tileState); + this.territoryPass.uploadFullTrailState(trailState); + this.heatManager.resetForSeek(tileState, nukeEvents, currentTick); + } + + applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void { + this.territoryPass.uploadFullTileState(tileState); + this.territoryPass.uploadFullTrailState(trailState); + } + + applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void { + this.territoryPass.uploadDeltaTiles(changedTiles); + this.territoryPass.uploadFullTrailState(trailState); + } + + uploadTileAndTrailState( + tileState: Uint16Array, + trailState: Uint8Array, + ): void { + this.territoryPass.setLiveRefs(tileState, trailState); + } + + uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void { + this.territoryPass.applyLiveDelta(tileState, changedTiles); + } + + uploadLiveTrailDelta( + trailState: Uint8Array, + dirtyRowMin: number, + dirtyRowMax: number, + ): void { + this.territoryPass.applyLiveTrailDelta( + trailState, + dirtyRowMin, + dirtyRowMax, + ); + } + + /** Re-upload palette data to the GPU texture (e.g. when players appear after initial startup). */ + updatePalette(paletteData: Float32Array): void { + const gl = this.gl; + // Mutate the stored array in-place so all passes sharing the reference see the update. + this.paletteData.set(paletteData); + // Re-upload to the GPU texture + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + getPaletteSize(), + 2, + gl.RGBA, + gl.FLOAT, + this.paletteData, + ); + // SAM radius pass stores its own copy + this.samRadiusPass.setPaletteData(this.paletteData); + } + + /** Register late-arriving players (updates palette + NamePass lookup maps). */ + addPlayers(players: PlayerStatic[], paletteData: Float32Array): void { + this.updatePalette(paletteData); + this.namePass.addPlayers(players, this.paletteData); + for (const p of players) { + if (p.team !== null) this.playerTeams.set(p.smallID, p.team); + } + } + + uploadRailroadState(data: Uint8Array): void { + this.railroadPass.uploadRailroadState(data); + } + + updateUnits(units: Map, gameTick: number): void { + this.lastUnits = units; + this.frameTick++; + this.gameTick = gameTick; + this.unitPass.updateUnits(units, this.frameTick); + this.barPass.updateBars(units, this.lastStructures, gameTick); + this.pointLightPass.updateLights(units); + this.heatManager.decayHeat(); + } + + updateNames( + names: Map, + players: Map, + snap: boolean, + statusData?: Map, + ): void { + this.namePass.updateNames(names, players, snap, statusData); + + // Extract local player's allies + teammates for SAM radius coloring + if (this.localPlayerID > 0) { + const localPS = players.get(this.localPlayerID); + const friendly = new Set(localPS?.allies ?? []); + const myTeam = this.playerTeams.get(this.localPlayerID); + if (myTeam !== undefined) { + for (const [sid, team] of this.playerTeams) { + if (team === myTeam && sid !== this.localPlayerID) friendly.add(sid); + } + } + this.samRadiusPass.setAllies(friendly); + this.unitPass.setAllies(friendly); + } + } + + updateRelations(data: Uint8Array, size: number): void { + this.borderPass.updateRelations(data, size); + this.affiliationPalette.updateRelations(data, size); + } + + updateStructures(units: Map): void { + this.lastStructures = units; + this.structurePass.updateStructures(units); + this.structureLevelPass.updateStructures(units); + this.samRadiusPass.updateStructures(units); + this.unitPass.setStructures(units); + const posts: { x: number; y: number; ownerID: number }[] = []; + const w = this.mapW; + for (const u of units.values()) { + if (u.unitType === "Defense Post" && !u.underConstruction) { + posts.push({ + x: u.pos % w, + y: (u.pos - (u.pos % w)) / w, + ownerID: u.ownerID, + }); + } + } + this.borderPass.updateDefensePosts(posts); + } + + applyDeadUnits(deadUnits: DeadUnitFx[]): void { + if (deadUnits.length > 0) this.fxPass.applyDeadUnits(deadUnits); + } + + applyRailroadDust(tileRefs: number[]): void { + if (tileRefs.length > 0) this.fxPass.applyRailroadDust(tileRefs); + } + + applyConquestEvents(events: ConquestFx[]): void { + if (events.length > 0) { + this.fxPass.applyConquestEvents(events); + this.conquestPopupPass.applyConquestEvents(events); + } + } + + applyBonusEvents(events: BonusEvent[]): void { + if (events.length === 0) return; + // In live game, filter to local player only. In replay (localPlayerID=0), show all. + const filtered = + this.localPlayerID > 0 + ? events.filter((e) => e.smallID === this.localPlayerID) + : events; + if (filtered.length > 0) this.conquestPopupPass.applyBonusEvents(filtered); + } + + updateAttackRings(rings: AttackRingInput[]): void { + this.fxPass.updateAttackRings(rings); + } + + clearFx(): void { + this.fxPass.clear(); + this.conquestPopupPass.clear(); + } + setFxTimeFn(fn: () => number): void { + this.fxPass.setTimeFn(fn); + this.conquestPopupPass.setTimeFn(fn); + } + + updateGhostPreview(data: GhostPreviewData | null): void { + this.structurePass.updateGhostPreview(data); + this.railroadPass.updateGhostPreview(data); + this.rangeCirclePass.updateGhostPreview(data); + this.crosshairPass.updateGhostPreview(data); + if (data) this.localPlayerID = data.ownerID; + this.samRadiusPass.setLocalPlayer(this.localPlayerID); + this.affiliationPalette.setLocalPlayer(this.localPlayerID); + this.unitPass.setLocalPlayer(this.localPlayerID); + this.samGhostVisible = + data !== null && SAM_RADIUS_GHOST_TYPES.has(data.ghostType); + this.samRadiusPass.setVisible( + this.samGhostVisible || this.samHighlightVisible, + ); + } + + updateNukeTrajectory(data: NukeTrajectoryData | null): void { + this.nukeTrajectoryPass.update(data); + } + + updateNukeTelegraphs(data: NukeTelegraphData[]): void { + this.nukeTelegraphPass.update(data); + } + + updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void { + this.spawnOverlayPass.update(inSpawnPhase, centers); + } + + // --------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------- + + setHighlightOwner(ownerID: number): void { + this.borderPass.setHighlightOwner(ownerID); + } + setHighlightStructureTypes(unitTypes: string[] | null): void { + this.structurePass.setHighlightTypes(unitTypes); + this.structureLevelPass.setHighlightTypes(unitTypes); + this.samHighlightVisible = + unitTypes !== null && + unitTypes.some((t) => SAM_RADIUS_HIGHLIGHT_TYPES.has(t)); + this.samRadiusPass.setVisible( + this.samGhostVisible || this.samHighlightVisible, + ); + } + + focusOwner(ownerID: number): void { + if (ownerID !== 0) { + const bbox = this.territoryPass.getBBoxForOwner(ownerID); + if (bbox) { + this.camera.focusBBox(bbox.minX, bbox.minY, bbox.maxX, bbox.maxY); + return; + } + } + this.camera.focusBBox(0, 0, this.mapW - 1, this.mapH - 1); + } + + getOwnerAtWorld(worldX: number, worldY: number): number { + const tx = Math.floor(worldX); + const ty = Math.floor(worldY); + if (tx < 0 || ty < 0 || tx >= this.mapW || ty >= this.mapH) return 0; + return this.territoryPass.getOwnerAt(ty * this.mapW + tx); + } + + getUnitAtWorld( + worldX: number, + worldY: number, + radius: number, + ): UnitState | null { + let best: UnitState | null = null; + let bestDist = radius * radius; + const w = this.mapW; + for (const u of this.lastUnits.values()) { + const dx = (u.pos % w) - worldX; + const dy = Math.floor(u.pos / w) - worldY; + const d2 = dx * dx + dy * dy; + if (d2 < bestDist) { + bestDist = d2; + best = u; + } + } + return best; + } + + getStructureAtWorld( + worldX: number, + worldY: number, + radius: number, + ): UnitState | null { + let best: UnitState | null = null; + let bestDist = radius * radius; + const w = this.mapW; + for (const s of this.lastStructures.values()) { + const dx = (s.pos % w) - worldX; + const dy = Math.floor(s.pos / w) - worldY; + const d2 = dx * dx + dy * dy; + if (d2 < bestDist) { + bestDist = d2; + best = s; + } + } + return best; + } + + setLocalPlayerID(id: number): void { + if (id === this.localPlayerID) return; + this.localPlayerID = id; + this.samRadiusPass.setLocalPlayer(id); + this.affiliationPalette.setLocalPlayer(id); + this.unitPass.setLocalPlayer(id); + } + + setSAMRadiusVisible(visible: boolean): void { + this.samRadiusPass.setVisible(visible); + } + + setSAMPerspective(playerID: number, allies: Set): void { + this.samRadiusPass.setLocalPlayer(playerID); + this.samRadiusPass.setAllies(allies); + this.unitPass.setLocalPlayer(playerID); + this.unitPass.setAllies(allies); + } + + setSAMColorMode(mode: "perspective" | "owner"): void { + this.samRadiusPass.setColorMode(mode); + } + + setSAMAllianceClusters(clusters: Map): void { + this.samRadiusPass.setAllianceClusters(clusters); + } + + setAltView(active: boolean): void { + this.altView = active; + this.territoryPass.setAltView(active); + this.borderStampPass.setAltView(active); + this.unitPass.setAltView(active); + this.structurePass.setAltView(active); + this.trailPass.setAltView(active); + } + + setGridView(active: boolean): void { + this.gridView = active; + } + + getSettings(): RenderSettings { + return this.settings; + } + + // --------------------------------------------------------------------------- + // Radial menu + // --------------------------------------------------------------------------- + + showRadialMenu( + anchorX: number, + anchorY: number, + items: RadialMenuItem[], + centerItem?: RadialMenuItem, + ): void { + this.radialMenuPass.show(anchorX, anchorY, items, centerItem); + } + + hideRadialMenu(): void { + this.radialMenuPass.hide(); + } + openRadialSubMenu(subItems: RadialMenuItem[]): void { + this.radialMenuPass.openSubMenu(subItems); + } + goBackRadialMenu(): void { + this.radialMenuPass.goBack(); + } + setRadialMenuHover(index: number): void { + this.radialMenuPass.setHover(index); + } + radialMenuHitTest(screenX: number, screenY: number): number { + return this.radialMenuPass.hitTest(screenX, screenY); + } + get radialMenuVisible(): boolean { + return this.radialMenuPass.isVisible; + } + getRadialMenuItems(): readonly RadialMenuItem[] { + return this.radialMenuPass.getItems(); + } + getRadialMenuItemAt(index: number): RadialMenuItem | null { + return this.radialMenuPass.getItemAt(index); + } + registerRadialMenuIcons( + icons: { key: string; img: CanvasImageSource }[], + ): void { + this.radialMenuPass.registerIcons(icons); + } + + // --------------------------------------------------------------------------- + // Selection box (warship selection) + // --------------------------------------------------------------------------- + + setSelectedUnit(unitId: number | null): void { + this.selectedUnitId = unitId; + if (unitId === null) { + this.selectionBoxPass.hide(); + } + // Position + color are updated each frame in draw() from lastUnits. + } + + private updateSelectionBox(): void { + if (this.selectedUnitId === null) return; + const unit = this.lastUnits.get(this.selectedUnitId); + if (!unit || !unit.isActive) { + this.selectedUnitId = null; + this.selectionBoxPass.hide(); + return; + } + const x = unit.pos % this.mapW; + const y = Math.floor(unit.pos / this.mapW); + + // Lighten the owner's territory color by ~20% (mix toward white) + const off = unit.ownerID * 4; + const lr = Math.min( + 1, + this.paletteData[off] + (1 - this.paletteData[off]) * 0.3, + ); + const lg = Math.min( + 1, + this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3, + ); + const lb = Math.min( + 1, + this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3, + ); + + this.selectionBoxPass.update(true, x, y, lr, lg, lb); + } + + // --------------------------------------------------------------------------- + // Move indicator (warship move-target chevrons) + // --------------------------------------------------------------------------- + + showMoveIndicator(tileX: number, tileY: number, ownerID: number): void { + const off = ownerID * 4; + const r = Math.min( + 1, + this.paletteData[off] + (1 - this.paletteData[off]) * 0.3, + ); + const g = Math.min( + 1, + this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3, + ); + const b = Math.min( + 1, + this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3, + ); + this.moveIndicatorPass.show(tileX, tileY, r, g, b); + } + + // --------------------------------------------------------------------------- + // Render — normalized draw order + // --------------------------------------------------------------------------- + + draw(): void { + const now = performance.now(); + this.trackFps(now); + this.uploadTextures(); + this.computeTextures(); + this.renderFrame(); + if (this.onFrame) this.onFrame(performance.now() - now); + if (this.afterRender) this.afterRender(this.canvas); + } + + private trackFps(now: number): void { + this.frameTimes[this.frameIdx] = now; + this.frameIdx = (this.frameIdx + 1) % this.frameTimes.length; + if (this.frameCount < this.frameTimes.length) this.frameCount++; + if (this.frameCount > 1) { + const oldest = + this.frameTimes[ + (this.frameIdx - this.frameCount + this.frameTimes.length) % + this.frameTimes.length + ]; + this.fps = (this.frameCount - 1) / ((now - oldest) / 1000); + } + } + + private uploadTextures(): void { + if (this.altView) this.affiliationPalette.flush(); + if (this.territoryPass.flushTileTexture()) + this.borderPass.notifyTilesChanged(); + this.territoryPass.flushTrailTexture(); + this.heatManager.updateHeat(); + } + + private computeTextures(): void { + if (this.settings.passEnabled.mapOverlay) + this.borderPass.draw(this.frameTick); + } + + private renderFrame(): void { + const cam = this.camera.getMatrix(); + const zoom = this.camera.zoom; + const cw = this.canvas.width; + const ch = this.canvas.height; + const nightActive = this.isNightActive(); + + if (nightActive) { + this.resizeSceneTargetIfNeeded(cw, ch); + const sceneTex = toTarget(this.gl, this.sceneTarget, () => + this.drawBaseLayer(cam), + ); + const lightTex = this.lightmapPass.draw(cam, cw, ch); + toScreen(this.gl, cw, ch, () => + this.nightCompositePass.draw(this.gameTick, sceneTex, lightTex), + ); + } else { + toScreen(this.gl, cw, ch, () => this.drawBaseLayer(cam)); + } + + this.renderOverlays(cam, zoom); + } + + private isNightActive(): boolean { + const mode = this.settings.dayNight.mode; + return ( + mode === "dark" || + (mode === "cycle" && this.settings.passEnabled.dayNight) + ); + } + + private resizeSceneTargetIfNeeded(cw: number, ch: number): void { + if (this.sceneTarget.w === cw && this.sceneTarget.h === ch) return; + this.sceneTarget.w = cw; + this.sceneTarget.h = ch; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.sceneTarget.tex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + cw, + ch, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + } + + private drawBaseLayer(cam: Float32Array): void { + const gl = this.gl; + const pe = this.settings.passEnabled; + gl.clearColor(0.04, 0.04, 0.06, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.disable(gl.BLEND); + if (pe.terrain) this.terrainPass.draw(cam); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + if (pe.mapOverlay) this.territoryPass.draw(cam); + } + + private renderOverlays(cam: Float32Array, zoom: number): void { + const gl = this.gl; + const pe = this.settings.passEnabled; + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + this.spawnOverlayPass.draw(cam); + if (pe.mapOverlay) this.borderStampPass.draw(cam); + if (pe.railroad) this.railroadPass.draw(cam, zoom); + if (pe.unit) this.unitPass.drawGround(cam); + this.samRadiusPass.draw(cam); + this.rangeCirclePass.draw(cam); + this.nukeTrajectoryPass.draw(cam); + this.crosshairPass.draw(cam); + if (pe.structure) this.structurePass.draw(cam, zoom); + if (pe.structure) this.structureLevelPass.draw(cam, zoom); + if (pe.bar) this.barPass.draw(cam); + this.updateSelectionBox(); + this.selectionBoxPass.draw(cam, this.frameTick); + this.moveIndicatorPass.draw(cam, zoom); + this.nukeTelegraphPass.draw(cam); + if (pe.falloutBloom) this.bloomPass.draw(cam, this.frameTick); + if (pe.mapOverlay) this.trailPass.draw(cam); + if (pe.unit) this.unitPass.drawMissiles(cam); + + if (pe.fx) { + this.fxPass.tick(); + this.fxPass.draw(cam, zoom); + } + + this.conquestPopupPass.tick(); + this.conquestPopupPass.draw(cam, zoom); + + if (this.gridView) this.coordinateGridPass.draw(cam, zoom); + if (pe.name && !this.gridView) + this.namePass.draw( + cam, + this.nightCompositePass.getAmbient(this.gameTick), + ); + + this.radialMenuPass.draw(); + + gl.disable(gl.BLEND); + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + dispose(): void { + this.stopLoop(); + this.terrainPass.dispose(); + this.territoryPass.dispose(); + this.trailPass.dispose(); + this.borderStampPass.dispose(); + this.borderPass.dispose(); + this.bloomPass.dispose(); + this.pointLightPass.dispose(); + this.falloutLightPass.dispose(); + this.lightmapPass.dispose(); + this.nightCompositePass.dispose(); + this.heatManager.dispose(); + this.affiliationPalette.dispose(); + this.coordinateGridPass.dispose(); + this.spawnOverlayPass.dispose(); + this.railroadPass.dispose(); + this.rangeCirclePass.dispose(); + this.samRadiusPass.dispose(); + this.crosshairPass.dispose(); + this.structurePass.dispose(); + this.structureLevelPass.dispose(); + this.unitPass.dispose(); + this.namePass.dispose(); + this.fxPass.dispose(); + this.conquestPopupPass.dispose(); + this.radialMenuPass.dispose(); + this.selectionBoxPass.dispose(); + this.moveIndicatorPass.dispose(); + this.nukeTrajectoryPass.dispose(); + this.nukeTelegraphPass.dispose(); + this.barPass.dispose(); + disposeGPUResources(this.gl, this.res); + this.gl.deleteTexture(this.paletteTex); + this.gl.deleteFramebuffer(this.sceneTarget.fbo); + this.gl.deleteTexture(this.sceneTarget.tex); + this.lastUnits = new Map(); + this.lastStructures = new Map(); + } +} diff --git a/src/client/render/gl/settings-utils.ts b/src/client/render/gl/settings-utils.ts new file mode 100644 index 000000000..fa48e0086 --- /dev/null +++ b/src/client/render/gl/settings-utils.ts @@ -0,0 +1,49 @@ +/** + * Utilities for RenderSettings persistence — deep-assign, deep-diff. + */ + +type Obj = Record; + +/** 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; +} diff --git a/src/client/render/gl/shaders/bar/bar.frag.glsl b/src/client/render/gl/shaders/bar/bar.frag.glsl new file mode 100644 index 000000000..9ebb15ab6 --- /dev/null +++ b/src/client/render/gl/shaders/bar/bar.frag.glsl @@ -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); + } +} diff --git a/src/client/render/gl/shaders/bar/bar.vert.glsl b/src/client/render/gl/shaders/bar/bar.vert.glsl new file mode 100644 index 000000000..237b906ff --- /dev/null +++ b/src/client/render/gl/shaders/bar/bar.vert.glsl @@ -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; +} diff --git a/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl b/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl new file mode 100644 index 000000000..c5ca55d80 --- /dev/null +++ b/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl b/src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl new file mode 100644 index 000000000..870ce6f0e --- /dev/null +++ b/src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl b/src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl new file mode 100644 index 000000000..7f508abbc --- /dev/null +++ b/src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl @@ -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; +} diff --git a/src/client/render/gl/shaders/crosshair/crosshair.frag.glsl b/src/client/render/gl/shaders/crosshair/crosshair.frag.glsl new file mode 100644 index 000000000..430797df2 --- /dev/null +++ b/src/client/render/gl/shaders/crosshair/crosshair.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/crosshair/crosshair.vert.glsl b/src/client/render/gl/shaders/crosshair/crosshair.vert.glsl new file mode 100644 index 000000000..99e6c1fcb --- /dev/null +++ b/src/client/render/gl/shaders/crosshair/crosshair.vert.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl new file mode 100644 index 000000000..7d8c7a5cd --- /dev/null +++ b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl @@ -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; +} diff --git a/src/client/render/gl/shaders/day-night/border-stamp.vert.glsl b/src/client/render/gl/shaders/day-night/border-stamp.vert.glsl new file mode 100644 index 000000000..a1bc71dc3 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/border-stamp.vert.glsl @@ -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; +} diff --git a/src/client/render/gl/shaders/day-night/composite.frag.glsl b/src/client/render/gl/shaders/day-night/composite.frag.glsl new file mode 100644 index 000000000..611e788c2 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/composite.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/day-night/fallout-composite.frag.glsl b/src/client/render/gl/shaders/day-night/fallout-composite.frag.glsl new file mode 100644 index 000000000..011e09ffb --- /dev/null +++ b/src/client/render/gl/shaders/day-night/fallout-composite.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/day-night/fallout-composite.vert.glsl b/src/client/render/gl/shaders/day-night/fallout-composite.vert.glsl new file mode 100644 index 000000000..0794903b2 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/fallout-composite.vert.glsl @@ -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; +} diff --git a/src/client/render/gl/shaders/day-night/fallout-light.frag.glsl b/src/client/render/gl/shaders/day-night/fallout-light.frag.glsl new file mode 100644 index 000000000..3d4246d09 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/fallout-light.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/day-night/light.frag.glsl b/src/client/render/gl/shaders/day-night/light.frag.glsl new file mode 100644 index 000000000..199e2657e --- /dev/null +++ b/src/client/render/gl/shaders/day-night/light.frag.glsl @@ -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); +} diff --git a/src/client/render/gl/shaders/day-night/light.vert.glsl b/src/client/render/gl/shaders/day-night/light.vert.glsl new file mode 100644 index 000000000..73c88a433 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/light.vert.glsl @@ -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]; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/composite.frag.glsl b/src/client/render/gl/shaders/fallout-bloom/composite.frag.glsl new file mode 100644 index 000000000..0ce5ef82c --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/composite.frag.glsl @@ -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; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl b/src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl new file mode 100644 index 000000000..0794903b2 --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl @@ -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; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/extract.frag.glsl b/src/client/render/gl/shaders/fallout-bloom/extract.frag.glsl new file mode 100644 index 000000000..e6653425d --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/extract.frag.glsl @@ -0,0 +1,82 @@ +#version 300 es +precision highp float; +precision highp usampler2D; +uniform usampler2D uTileTex; +uniform vec2 uMapSize; +uniform float uTick; + +uniform float uBroilSpeedCold; +uniform float uBroilSpeedHot; +uniform float uNoiseFreq1; +uniform float uNoiseFreq2; +uniform float uContrastLoCold; +uniform float uContrastLoHot; +uniform float uContrastHiCold; +uniform float uContrastHiHot; +uniform float uMetaFreq; +uniform float uIntensityCold; +uniform float uIntensityHot; +uniform float uMetaInfluenceCold; +uniform float uMetaInfluenceHot; +uniform float uOpacityFadeEnd; +uniform vec3 uBloomColor; + +uniform sampler2D uHeatTex; + +out vec4 fragColor; + +float hash3(vec3 p) { + return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); +} +float vnoise3(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float n000 = hash3(i); + float n100 = hash3(i + vec3(1, 0, 0)); + float n010 = hash3(i + vec3(0, 1, 0)); + float n110 = hash3(i + vec3(1, 1, 0)); + float n001 = hash3(i + vec3(0, 0, 1)); + float n101 = hash3(i + vec3(1, 0, 1)); + float n011 = hash3(i + vec3(0, 1, 1)); + float n111 = hash3(i + vec3(1, 1, 1)); + return mix( + mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y), + mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), + f.z); +} + +void main() { + // Tile-space: viewport is mapW x mapH, one fragment per tile. + // gl_FragCoord.xy gives exact integer tile coords — completely + // deterministic, independent of camera position/zoom. + 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; + if ((raw & (1u << FALLOUT_BIT)) == 0u) discard; + + float heat = texelFetch(uHeatTex, tc, 0).r; + vec2 tileCenter = vec2(tc) + 0.5; + + float speed = mix(uBroilSpeedCold, uBroilSpeedHot, heat); + float t = uTick * speed; + + float n1 = vnoise3(vec3(tileCenter * uNoiseFreq1, t)); + float n2 = vnoise3(vec3(tileCenter * uNoiseFreq2, t * 1.3)); + float broil = n1 * 0.6 + n2 * 0.4; + + float lo = mix(uContrastLoCold, uContrastLoHot, heat); + float hi = mix(uContrastHiCold, uContrastHiHot, heat); + broil = smoothstep(lo, hi, broil); + + float meta = vnoise3(vec3(tileCenter * uMetaFreq, t * 0.5)); + + float baseIntensity = mix(uIntensityCold, uIntensityHot, heat); + float metaInfluence = mix(uMetaInfluenceCold, uMetaInfluenceHot, heat); + float intensity = baseIntensity * mix(1.0, meta, metaInfluence); + + float opacity = smoothstep(0.0, uOpacityFadeEnd, heat); + + fragColor = vec4(uBloomColor, 1.0) * broil * intensity * opacity; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/heat-decay.frag.glsl b/src/client/render/gl/shaders/fallout-bloom/heat-decay.frag.glsl new file mode 100644 index 000000000..41519a983 --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/heat-decay.frag.glsl @@ -0,0 +1,30 @@ +#version 300 es +precision highp float; +precision highp usampler2D; +uniform sampler2D uHeatTex; +uniform usampler2D uTileTex; +uniform usampler2D uPrevTileTex; +uniform vec2 uMapSize; +uniform float uDecay; +out vec4 fragColor; +void main() { + ivec2 tc = ivec2(gl_FragCoord.xy); + if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard; + + float heat = texelFetch(uHeatTex, tc, 0).r; + uint curr = texelFetch(uTileTex, tc, 0).r; + uint prev = texelFetch(uPrevTileTex, tc, 0).r; + + bool wasFallout = (prev & (1u << FALLOUT_BIT)) != 0u; + bool isFallout = (curr & (1u << FALLOUT_BIT)) != 0u; + + if (isFallout && !wasFallout) { + heat = 1.0; + } else if (!isFallout && wasFallout) { + heat = 0.0; + } else { + heat = max(0.0, heat - uDecay / 255.0); + } + + fragColor = vec4(heat, 0.0, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/fx/attack-ring.frag.glsl b/src/client/render/gl/shaders/fx/attack-ring.frag.glsl new file mode 100644 index 000000000..8dd9b3969 --- /dev/null +++ b/src/client/render/gl/shaders/fx/attack-ring.frag.glsl @@ -0,0 +1,40 @@ +#version 300 es +precision highp float; + +uniform float uTime; // seconds, for rotation +uniform float uRingWidth; // line thickness in normalized coords + +in vec2 vLocalPos; +flat in float vAlpha; + +out vec4 fragColor; + +const float INNER_R = 0.5; +const float OUTER_R = 0.8; +const float INNER_DASHES = 8.0; +const float OUTER_DASHES = 2.0; +const float PI = 3.14159265; + +void main() { + float dist = length(vLocalPos); + float angle = atan(vLocalPos.y, vLocalPos.x); + + // Inner ring — thin, many dashes, rotating clockwise + float innerDist = abs(dist - INNER_R); + float innerRing = 1.0 - smoothstep(0.0, uRingWidth * 2.0, innerDist); + float innerAngle = angle + uTime * 1.2; + float innerDash = smoothstep(0.4, 0.5, abs(fract(innerAngle * INNER_DASHES / (2.0 * PI)) - 0.5) * 2.0); + innerRing *= innerDash; + + // Outer ring — thick, few dashes, counter-rotating + float outerDist = abs(dist - OUTER_R); + float outerRing = 1.0 - smoothstep(0.0, uRingWidth * 3.0, outerDist); + float outerAngle = angle - uTime * 0.6; + float outerDash = smoothstep(0.3, 0.4, abs(fract(outerAngle * OUTER_DASHES / (2.0 * PI)) - 0.5) * 2.0); + outerRing *= outerDash; + + float ring = max(innerRing, outerRing); + if (ring < 0.01) discard; + + fragColor = vec4(1.0, 0.0, 0.0, ring * vAlpha); +} diff --git a/src/client/render/gl/shaders/fx/attack-ring.vert.glsl b/src/client/render/gl/shaders/fx/attack-ring.vert.glsl new file mode 100644 index 000000000..3c66df397 --- /dev/null +++ b/src/client/render/gl/shaders/fx/attack-ring.vert.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec3 aInstData; // x, y, alpha + +uniform mat3 uCamera; +uniform float uTilesPerPx; + +out vec2 vLocalPos; +flat out float vAlpha; + +// Upstream outer ring = 16 screen-px; quad needs headroom for SDF AA. +const float RING_SCREEN_PX = 20.0; + +void main() { + vec2 center = vec2(aInstData.x + 0.5, aInstData.y + 0.5); + vAlpha = aInstData.z; + + float worldRadius = RING_SCREEN_PX * uTilesPerPx; + vec2 worldPos = center + (aPos - 0.5) * worldRadius * 2.0; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = (aPos - 0.5) * 2.0; +} diff --git a/src/client/render/gl/shaders/fx/shockwave.frag.glsl b/src/client/render/gl/shaders/fx/shockwave.frag.glsl new file mode 100644 index 000000000..81bb071b1 --- /dev/null +++ b/src/client/render/gl/shaders/fx/shockwave.frag.glsl @@ -0,0 +1,17 @@ +#version 300 es +precision highp float; + +uniform float uRingWidth; + +in vec2 vLocalPos; +flat in float vAlpha; + +out vec4 fragColor; + +void main() { + float dist = length(vLocalPos); + float ringDist = abs(dist - 1.0); + float ring = 1.0 - smoothstep(0.0, uRingWidth, ringDist); + if (ring < 0.01) discard; + fragColor = vec4(1.0, 1.0, 1.0, ring * vAlpha); +} diff --git a/src/client/render/gl/shaders/fx/shockwave.vert.glsl b/src/client/render/gl/shaders/fx/shockwave.vert.glsl new file mode 100644 index 000000000..e27aa4037 --- /dev/null +++ b/src/client/render/gl/shaders/fx/shockwave.vert.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec4 aInstData; // x, y, radius, alpha + +uniform mat3 uCamera; + +out vec2 vLocalPos; +flat out float vAlpha; + +// Extra margin so the ring's outer feathering isn't clipped at the quad edge. +const float MARGIN = 1.1; // 10% beyond ring radius + +void main() { + vec2 center = vec2(aInstData.x + 0.5, aInstData.y + 0.5); + float r = aInstData.z; + vAlpha = aInstData.w; + + vec2 worldPos = center + (aPos - 0.5) * r * 2.0 * MARGIN; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // Scale vLocalPos by the same margin so dist=1.0 stays at the ring radius + vLocalPos = (aPos - 0.5) * 2.0 * MARGIN; +} diff --git a/src/client/render/gl/shaders/fx/sprite.frag.glsl b/src/client/render/gl/shaders/fx/sprite.frag.glsl new file mode 100644 index 000000000..f076ea689 --- /dev/null +++ b/src/client/render/gl/shaders/fx/sprite.frag.glsl @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +uniform sampler2D uAtlas; + +in vec2 vAtlasUV; +flat in float vAlpha; + +out vec4 fragColor; + +void main() { + vec4 texel = texture(uAtlas, vAtlasUV); + if (texel.a < 0.01) discard; + fragColor = vec4(texel.rgb, texel.a * vAlpha); +} diff --git a/src/client/render/gl/shaders/fx/sprite.vert.glsl b/src/client/render/gl/shaders/fx/sprite.vert.glsl new file mode 100644 index 000000000..56f83aeb3 --- /dev/null +++ b/src/client/render/gl/shaders/fx/sprite.vert.glsl @@ -0,0 +1,33 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec3 aInstPos; // x, y, fxType +layout(location = 2) in vec2 aInstFlags; // frameIdx (uint8), alpha (uint8) + +uniform mat3 uCamera; +uniform vec4 uFxUV[FX_TYPE_COUNT]; // vTop, vSpan, uFrameSpan, 0 +uniform vec4 uFxWorld[FX_TYPE_COUNT]; // worldW, worldH, 0, 0 + +out vec2 vAtlasUV; +flat out float vAlpha; + +void main() { + int type = int(aInstPos.z + 0.5); + float frameIdx = floor(aInstFlags.x + 0.5); + float alpha = aInstFlags.y / 255.0; + + vec4 uv = uFxUV[type]; + vec4 world = uFxWorld[type]; + + vec2 center = vec2(aInstPos.x + 0.5, aInstPos.y + 0.5); + vec2 worldPos = center + (aPos - 0.5) * vec2(world.x, world.y); + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + float u = (frameIdx + aPos.x) * uv.z; + float v = uv.x + aPos.y * uv.y; + vAtlasUV = vec2(u, v); + vAlpha = alpha; +} diff --git a/src/client/render/gl/shaders/glsl.d.ts b/src/client/render/gl/shaders/glsl.d.ts new file mode 100644 index 000000000..03d75630b --- /dev/null +++ b/src/client/render/gl/shaders/glsl.d.ts @@ -0,0 +1,4 @@ +declare module "*.glsl?raw" { + const source: string; + export default source; +} diff --git a/src/client/render/gl/shaders/grid/grid.frag.glsl b/src/client/render/gl/shaders/grid/grid.frag.glsl new file mode 100644 index 000000000..7174d0efa --- /dev/null +++ b/src/client/render/gl/shaders/grid/grid.frag.glsl @@ -0,0 +1,104 @@ +#version 300 es +precision highp float; + +uniform vec2 uMapSize; +uniform float uCellSize; +uniform float uZoom; +uniform float uFontSize; +uniform sampler2D uGlyphTex; + +in vec2 vWorldPos; +out vec4 fragColor; + +const float GLYPH_COUNT = 36.0; // 0-9, A-Z + +void main() { + vec2 wp = vWorldPos; + if (wp.x < 0.0 || wp.y < 0.0 || wp.x >= uMapSize.x || wp.y >= uMapSize.y) + discard; + + float cs = uCellSize; + float px = 1.0 / uZoom; // 1 screen pixel in world units + float lineW = px * 1.25; + + // Grid cell index + position within cell + int cellCol = int(floor(wp.x / cs)); + int cellRow = int(floor(wp.y / cs)); + float localX = wp.x - float(cellCol) * cs; + float localY = wp.y - float(cellRow) * cs; + + // --- Grid lines (at cell boundaries) --- + if (localX < lineW || localY < lineW) { + fragColor = vec4(1.0, 1.0, 1.0, 0.35); + return; + } + + // --- Labels (only when cells are large enough on screen) --- + float cellScreenPx = cs * uZoom; + if (cellScreenPx < 60.0) discard; + + float fontSize = clamp(uFontSize + (uZoom - 1.0) * 1.2, uFontSize * 0.9, uFontSize * 1.6); + float gw = fontSize * 0.6 * px; // glyph width in world units + float gh = fontSize * px; // glyph height + float pad = 8.0 * px; // padding from cell corner + float bgPad = 2.0 * px; // background extends beyond text + + float lx = localX - pad; + float ly = localY - pad; + + // Compute label characters: row alpha + col digits + // Atlas indices: 0-9 = digits '0'-'9', 10-35 = letters 'A'-'Z' + int c0, c1 = -1, c2 = -1, c3 = -1; + int nc; + + // Row part (A, B, ..., Z, AA, AB, ...) + if (cellRow < 26) { + c0 = cellRow + 10; + nc = 1; + } else { + c0 = (cellRow / 26 - 1) + 10; + c1 = (cellRow % 26) + 10; + nc = 2; + } + + // Col part (1-indexed: 1, 2, ..., 50) + int colNum = cellCol + 1; + if (nc == 1) { + if (colNum < 10) { c1 = colNum; nc = 2; } + else { c1 = colNum / 10; c2 = colNum % 10; nc = 3; } + } else { + if (colNum < 10) { c2 = colNum; nc = 3; } + else { c2 = colNum / 10; c3 = colNum % 10; nc = 4; } + } + + float totalW = float(nc) * gw; + + // Check label background area (text + padding) + if (lx < -bgPad || ly < -bgPad || lx >= totalW + bgPad || ly >= gh + bgPad) + discard; + + // Check if on actual glyph + if (lx >= 0.0 && ly >= 0.0 && lx < totalW && ly < gh) { + int ci = int(floor(lx / gw)); + if (ci < nc) { + int g; + if (ci == 0) g = c0; + else if (ci == 1) g = c1; + else if (ci == 2) g = c2; + else g = c3; + + float cu = fract(lx / gw); + float cv = ly / gh; + float au = (float(g) + cu) / GLYPH_COUNT; + float mask = texture(uGlyphTex, vec2(au, cv)).r; + + if (mask > 0.3) { + fragColor = vec4(1.0, 1.0, 1.0, 0.9); + return; + } + } + } + + // Background behind label + fragColor = vec4(0.08, 0.08, 0.08, 0.7); +} diff --git a/src/client/render/gl/shaders/map-overlay/overlay.vert.glsl b/src/client/render/gl/shaders/map-overlay/overlay.vert.glsl new file mode 100644 index 000000000..80f9eb8dc --- /dev/null +++ b/src/client/render/gl/shaders/map-overlay/overlay.vert.glsl @@ -0,0 +1,14 @@ +#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; +} diff --git a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl new file mode 100644 index 000000000..94c0579b4 --- /dev/null +++ b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl @@ -0,0 +1,42 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTileTex; // R16UI — tile state per cell +uniform sampler2D uPalette; // RGBA32F — player colors + +uniform vec2 uMapSize; +uniform int uAltView; +uniform float uCharcoalBase; +uniform float uCharcoalVariation; +uniform float uCharcoalAlpha; + +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); + bool fallout = (raw & (1u << FALLOUT_BIT)) != 0u; + + if (owner == 0u && !fallout) discard; + + // Alt-view: hide territory fill, keep fallout charcoal + if (uAltView != 0 && owner != 0u) discard; + + // --- Fallout charcoal ground (unowned) --- + if (owner == 0u && fallout) { + float h = fract(sin(float(tc.x) * 12.9898 + float(tc.y) * 78.233) * 43758.5453); + float charcoal = uCharcoalBase + h * uCharcoalVariation; + fragColor = vec4(vec3(charcoal), uCharcoalAlpha); + return; + } + + // --- Territory fill (owned) --- + float u = (float(owner) + 0.5) / float(PALETTE_SIZE); + fragColor = texture(uPalette, vec2(u, 0.25)); +} diff --git a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl new file mode 100644 index 000000000..45d832819 --- /dev/null +++ b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl @@ -0,0 +1,31 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTrailTex; // R8UI — trail ownerID per cell (0 = none) +uniform sampler2D uPalette; // RGBA32F — player colors +uniform sampler2D uAffiliation; // RGBA8 — affiliation colors (row 0 = border, row 1 = unit) +uniform vec2 uMapSize; +uniform float uTrailAlpha; +uniform int uAltView; + +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 trailOwner = texelFetch(uTrailTex, tc, 0).r; + if (trailOwner == 0u) discard; + + vec3 color; + if (uAltView != 0) { + color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb; + } else { + float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE); + color = texture(uPalette, vec2(u, 0.25)).rgb; + } + fragColor = vec4(color, uTrailAlpha); +} diff --git a/src/client/render/gl/shaders/move-indicator/move-indicator.frag.glsl b/src/client/render/gl/shaders/move-indicator/move-indicator.frag.glsl new file mode 100644 index 000000000..f347a311d --- /dev/null +++ b/src/client/render/gl/shaders/move-indicator/move-indicator.frag.glsl @@ -0,0 +1,62 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; // [-1, +1] over ±HALF tiles + +uniform float uElapsed; // wall-clock ms since activation +uniform vec3 uColor; // RGB [0-1] +uniform float uPxPerTile; // camera zoom (pixels per tile) +uniform float uStartRadius; // screen px +uniform float uChevronSize; // screen px +uniform float uLineWidth; // screen px +uniform float uDuration; // ms +uniform float uConverge; // 0–1 + +out vec4 fragColor; + +const float HALF = 16.0; // quad half-size in tiles (must match vertex shader) + +// SDF: distance to a V-chevron pointing in +Y, centered at origin. +// The chevron has wings at (±w, -wingOff) meeting tip at (0, +tipOff). +float chevronSDF(vec2 p, float w, float tipOff, float wingOff) { + p.x = abs(p.x); + vec2 a = vec2(w, -wingOff); + vec2 b = vec2(0.0, tipOff); + vec2 ab = b - a; + float t = clamp(dot(p - a, ab) / dot(ab, ab), 0.0, 1.0); + return length(p - a - ab * t); +} + +void main() { + float t = uElapsed / uDuration; + if (t >= 1.0) discard; + + // Convert vLocal to screen pixels relative to center + float px = vLocal.x * HALF * uPxPerTile; + float py = vLocal.y * HALF * uPxPerTile; + + // Scale factor (matches game: grows above zoom 10) + float sc = uPxPerTile > 10.0 ? 1.0 + (uPxPerTile - 10.0) / 10.0 : 1.0; + + float radius = uStartRadius * sc * (1.0 - t * uConverge); + float cs = uChevronSize * sc; + float tipOff = cs * 0.4; + float wingOff = cs * 0.6; + float w = cs; // wing half-width + + // 4 chevrons pointing inward + float d = chevronSDF(vec2(px, -(py - radius)), w, tipOff, wingOff); + d = min(d, chevronSDF(vec2(px, py + radius), w, tipOff, wingOff)); + d = min(d, chevronSDF(vec2(py, -(px - radius)), w, tipOff, wingOff)); + d = min(d, chevronSDF(vec2(py, px + radius), w, tipOff, wingOff)); + + // Anti-aliased stroke (in screen pixels) + float half_w = uLineWidth * sc * 0.5; + float aa = 1.0; + float mask = 1.0 - smoothstep(half_w - aa, half_w + aa, d); + + if (mask < 0.01) discard; + + float alpha = 1.0 - t; + fragColor = vec4(uColor, alpha * mask); +} diff --git a/src/client/render/gl/shaders/move-indicator/move-indicator.vert.glsl b/src/client/render/gl/shaders/move-indicator/move-indicator.vert.glsl new file mode 100644 index 000000000..aaf41ed73 --- /dev/null +++ b/src/client/render/gl/shaders/move-indicator/move-indicator.vert.glsl @@ -0,0 +1,20 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; +uniform vec2 uCenter; // world-space tile center + +out vec2 vLocal; // [-1, +1] local quad space + +void main() { + vLocal = aPos * 2.0 - 1.0; + + // Quad covers ±16 tiles around center (enough for the chevrons) + float r = 16.0; + vec2 world = uCenter + 0.5 + vLocal * r; + + vec3 clip = uCamera * vec3(world, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/name/debug-box.frag.glsl b/src/client/render/gl/shaders/name/debug-box.frag.glsl new file mode 100644 index 000000000..6c8fb4b74 --- /dev/null +++ b/src/client/render/gl/shaders/name/debug-box.frag.glsl @@ -0,0 +1,32 @@ +#version 300 es +precision highp float; + +in vec2 vUV; +flat in int vBoxType; +flat in vec4 vColor; + +out vec4 fragColor; + +void main() { + if (vColor.a <= 0.0) discard; + + if (vBoxType == 2) { + // Center crosshair — draw a + shape, discard the four corner quadrants + float cx = abs(vUV.x - 0.5); + float cy = abs(vUV.y - 0.5); + // Each arm is 0.15 wide (30% of half-width) + if (cx > 0.15 && cy > 0.15) discard; + fragColor = vColor; + } else { + // Wireframe border for name/flag boxes + float borderWidth = 1.5; + vec2 pixelSize = fwidth(vUV); + vec2 border = borderWidth * pixelSize; + + if (vUV.x > border.x && vUV.x < 1.0 - border.x && + vUV.y > border.y && vUV.y < 1.0 - border.y) { + discard; + } + fragColor = vColor; + } +} diff --git a/src/client/render/gl/shaders/name/debug-box.vert.glsl b/src/client/render/gl/shaders/name/debug-box.vert.glsl new file mode 100644 index 000000000..2ec7fdcd6 --- /dev/null +++ b/src/client/render/gl/shaders/name/debug-box.vert.glsl @@ -0,0 +1,108 @@ +#version 300 es +precision highp float; +precision highp int; + +layout(location = 0) in vec2 aPos; // unit quad [0,0]→[1,1] + +uniform sampler2D uPlayerData; +uniform mat3 uCamera; +uniform float uTime; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uFontSize; +uniform float uFontBase; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; + +// Flag layout (for computing flag box) +uniform float uFlagCellW; +uniform float uFlagCellH; + +out vec2 vUV; +flat out int vBoxType; // 0=name, 1=flag, 2=center +flat out vec4 vColor; + +void main() { + // 3 debug boxes per player: 0=name, 1=flag, 2=center crosshair + int playerIdx = gl_InstanceID / 3; + int boxType = gl_InstanceID - playerIdx * 3; + + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); + vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); + vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); + + // Skip dead players + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vBoxType = -1; + vColor = vec4(0.0); + return; + } + + // Lerped world position (same as name.vert.glsl) + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // Sizing pipeline (must match name.vert.glsl exactly) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + + // Zoom-based culling + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uFontBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vBoxType = -1; + vColor = vec4(0.0); + return; + } + + float nameHalfWidth = pd3.w; + + vec2 boxMin, boxMax; + + if (boxType == 0) { + // Name text bounding box (green) + float halfW = nameHalfWidth * nameWorldScale; + float halfH = uFontBase * nameWorldScale * 0.5; + boxMin = vec2(wx - halfW, wy - halfH); + boxMax = vec2(wx + halfW, wy + halfH); + vColor = vec4(0.0, 1.0, 0.0, 0.9); + } else if (boxType == 1) { + // Flag bounding box (yellow) + float flagIdx = pd4.x; + if (flagIdx < 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vBoxType = -1; + vColor = vec4(0.0); + return; + } + float halfW = nameHalfWidth * nameWorldScale; + float flagWorldH = uFontBase * nameWorldScale * 1.2; + float flagWorldW = flagWorldH * (uFlagCellW / uFlagCellH); + boxMin = vec2(wx - halfW - flagWorldW, wy - flagWorldH * 0.5); + boxMax = vec2(wx - halfW, wy + flagWorldH * 0.5); + vColor = vec4(1.0, 1.0, 0.0, 0.9); + } else { + // Center crosshair (cyan) — fixed world size proportional to name + float arm = uFontBase * nameWorldScale * 0.3; + boxMin = vec2(wx - arm, wy - arm); + boxMax = vec2(wx + arm, wy + arm); + vColor = vec4(0.0, 1.0, 1.0, 1.0); + } + + vUV = aPos; + vBoxType = boxType; + vec2 worldPos = mix(boxMin, boxMax, aPos); + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/name/icon.frag.glsl b/src/client/render/gl/shaders/name/icon.frag.glsl new file mode 100644 index 000000000..050b83984 --- /dev/null +++ b/src/client/render/gl/shaders/name/icon.frag.glsl @@ -0,0 +1,24 @@ +#version 300 es +precision highp float; + +uniform sampler2D uFlagAtlas; +uniform sampler2D uEmojiAtlas; + +in vec2 vUV; +flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard + +out vec4 fragColor; + +void main() { + if (vIconType < 0) discard; + + vec4 texel; + if (vIconType == 0) { + texel = texture(uFlagAtlas, vUV); + } else { + texel = texture(uEmojiAtlas, vUV); + } + + if (texel.a < 0.01) discard; + fragColor = texel; +} diff --git a/src/client/render/gl/shaders/name/icon.vert.glsl b/src/client/render/gl/shaders/name/icon.vert.glsl new file mode 100644 index 000000000..1caad7ae1 --- /dev/null +++ b/src/client/render/gl/shaders/name/icon.vert.glsl @@ -0,0 +1,154 @@ +#version 300 es +precision highp float; +precision highp int; + +// Unit quad vertex position [0,0]→[1,1] +layout(location = 0) in vec2 aPos; + +// Data textures (shared with name shader) +uniform sampler2D uPlayerData; // PLAYER_DATA_COLS × MAX_PLAYERS, RGBA32F + +// Uniforms +uniform mat3 uCamera; +uniform float uTime; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; +uniform float uFontSize; // atlas reference font size (same as name shader's uFontSize) +uniform float uFontBase; // atlas baseline height (same as name shader's uBase) + +// Flag atlas layout +uniform float uFlagCellW; // texels per flag cell (width) +uniform float uFlagCellH; // texels per flag cell (height) +uniform float uFlagCols; // columns in flag atlas +uniform float uFlagAtlasW; // flag atlas texture width +uniform float uFlagAtlasH; // flag atlas texture height + +// Emoji atlas layout +uniform float uEmojiCell; // texels per emoji cell (square) +uniform float uEmojiCols; // columns in emoji atlas +uniform float uEmojiAtlasW; // emoji atlas texture width +uniform float uEmojiAtlasH; // emoji atlas texture height + +// Row offset (multiples of uFontBase * nameWorldScale) +uniform float uEmojiRowOffset; + +out vec2 vUV; +flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard + +void main() { + // Decode instance ID → playerIdx + iconType (0=flag, 1=emoji) + int playerIdx = gl_InstanceID / 2; + int iconType = gl_InstanceID - playerIdx * 2; + + // Read player data + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive + vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, nameHalfWidth + vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, [free], [free] + + // Early out: dead player + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + return; + } + + // Get atlas index for this icon type + float atlasIdx = (iconType == 0) ? pd4.x : pd4.y; + if (atlasIdx < 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + return; + } + + // Lerped world position and size (same math as name.vert.glsl) + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // Sizing pipeline (must match name.vert.glsl exactly) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + + // Zoom-based culling (same as name shader) + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uFontBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + return; + } + + float nameHalfWidth = pd3.w; // in font units (pre-scaled by nameWorldScale at runtime) + + // Compute icon size and position based on type + float iconW, iconH; + float cellW, cellH, cols, atlasW, atlasH; + vec2 iconOrigin; + + if (iconType == 0) { + // FLAG — to the left of the name + cellW = uFlagCellW; + cellH = uFlagCellH; + cols = uFlagCols; + atlasW = uFlagAtlasW; + atlasH = uFlagAtlasH; + + // Flag world size: height matches ~120% of the name text height + float flagWorldH = uFontBase * nameWorldScale * 1.2; + float flagWorldW = flagWorldH * (cellW / cellH); + + // Position: left of name, vertically centered on the name baseline + iconOrigin = vec2( + wx - nameHalfWidth * nameWorldScale - flagWorldW, + wy - flagWorldH * 0.5 + ); + iconW = flagWorldW; + iconH = flagWorldH; + } else { + // EMOJI — above the name + cellW = uEmojiCell; + cellH = uEmojiCell; + cols = uEmojiCols; + atlasW = uEmojiAtlasW; + atlasH = uEmojiAtlasH; + + // Emoji world size: slightly larger than name text height + float emojiWorldSize = uFontBase * nameWorldScale * 1.0; + + // Position: centered above name + iconOrigin = vec2( + wx - emojiWorldSize * 0.5, + wy - uFontBase * nameWorldScale * uEmojiRowOffset + ); + iconW = emojiWorldSize; + iconH = emojiWorldSize; + } + + // Quad world position + vec2 worldPos = iconOrigin + aPos * vec2(iconW, iconH); + + // Camera transform + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // UV from atlas grid + int idx = int(atlasIdx); + int col = idx - (idx / int(cols)) * int(cols); + int row = idx / int(cols); + float u0 = float(col) * cellW / atlasW; + float v0 = float(row) * cellH / atlasH; + float u1 = u0 + cellW / atlasW; + float v1 = v0 + cellH / atlasH; + vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); + vIconType = iconType; +} diff --git a/src/client/render/gl/shaders/name/name.frag.glsl b/src/client/render/gl/shaders/name/name.frag.glsl new file mode 100644 index 000000000..b2524f8b5 --- /dev/null +++ b/src/client/render/gl/shaders/name/name.frag.glsl @@ -0,0 +1,62 @@ +#version 300 es +precision highp float; + +uniform sampler2D uAtlas; +uniform float uDistRange; +uniform float uOutlineWidth; +uniform float uNightAmbient; +uniform vec3 uOutlineColor; +uniform float uOutlineUsePlayerColor; +uniform float uFillUsePlayerColor; + +in vec2 vUV; +in vec4 vPlayerColor; // player territory color (rgb) + alpha +in float vIsHuman; // 1.0 = human, 0.0 = bot/nation +out vec4 fragColor; + +float median(float r, float g, float b) { + return max(min(r, g), min(max(r, g), b)); +} + +void main() { + // Degenerate fragment — skip + if (vPlayerColor.a <= 0.0) discard; + + // Stagger fill/border curves so they never share the same gray. + // t² for border (stays dark longer, snaps white late) and √t for fill (inverse). + // At midpoint t=0.5: border=0.25 (dark), fill=0.71 (light) — always distinct. + float t = 1.0 - uNightAmbient; + float borderT = t * t; + float fillT = sqrt(t); + + // Compute fill color: player color, or cycle-aware white↔black (inverse of border) + vec3 defaultFill = mix(uOutlineColor, vec3(0.0), fillT); + vec3 fillColor = mix(defaultFill, vPlayerColor.rgb, uFillUsePlayerColor); + + 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); + + if (uOutlineWidth > 0.0) { + // The SDF saturates at sd=0 (screenPxDist = -screenPxRange*0.5). + // Reserve a 1px margin so saturated fragments always get alpha=0. + float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0); + float effectiveOutline = min(uOutlineWidth, maxOutline); + + float outlineDist = screenPxDist + effectiveOutline; + float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0); + + vec3 nightOutlineColor = mix(vec3(0.0), uOutlineColor, borderT); + vec3 borderColor = mix(nightOutlineColor, vPlayerColor.rgb, uOutlineUsePlayerColor); + vec3 color = mix(borderColor, fillColor, fillAlpha); + fragColor = vec4(color, vPlayerColor.a * outlineAlpha); + } else { + fragColor = vec4(fillColor, vPlayerColor.a * fillAlpha); + } +} diff --git a/src/client/render/gl/shaders/name/name.vert.glsl b/src/client/render/gl/shaders/name/name.vert.glsl new file mode 100644 index 000000000..42b6c0c3d --- /dev/null +++ b/src/client/render/gl/shaders/name/name.vert.glsl @@ -0,0 +1,156 @@ +#version 300 es +precision highp float; +precision highp int; +precision highp usampler2D; + +// Unit quad vertex position [0,0]→[1,1] +layout(location = 0) in vec2 aPos; + +// Data textures +uniform sampler2D uGlyphMetrics; // CHAR_RANGE × 2, RGBA32F +uniform sampler2D uCursorX; // MAX_CHARS × (MAX_PLAYERS*2), R32F — pre-computed centered cursor X +uniform usampler2D uStrings; // MAX_CHARS × (MAX_PLAYERS*2), R8UI +uniform sampler2D uPlayerData; // 4 × MAX_PLAYERS, RGBA32F + +// Uniforms +uniform mat3 uCamera; +uniform float uTime; +uniform float uFontSize; // atlas reference font size +uniform float uAtlasScaleW; // atlas texture width +uniform float uAtlasScaleH; // atlas texture height +uniform float uBase; // atlas baseline height + +const int MAX_CHARS_PER_LINE = MAX_CHARS; +const int LINES = LINES_PER_PLAYER; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; +uniform float uTroopSizeMultiplier; + +out vec2 vUV; +out vec4 vPlayerColor; // player territory color (rgb) + alpha +out float vIsHuman; // 1.0 for human, 0.0 for bot/nation + +void main() { + // 1. Decode instance ID → playerIdx, lineIdx, charPos + int slotsPerPlayer = LINES * MAX_CHARS_PER_LINE; + int playerIdx = gl_InstanceID / slotsPerPlayer; + int remainder = gl_InstanceID - playerIdx * slotsPerPlayer; + int lineIdx = remainder / MAX_CHARS_PER_LINE; + int charPos = remainder - lineIdx * MAX_CHARS_PER_LINE; + + // 2. Read player data + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive + vec4 pd2 = texelFetch(uPlayerData, ivec2(2, playerIdx), 0); // r, g, b, a + vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, 0 + + // Early out: dead player + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // String length for this line + int len = (lineIdx == 0) ? int(pd3.x) : int(pd3.y); + if (charPos >= len) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 3. Read char code at this position + int stringRow = playerIdx * LINES + lineIdx; + uint charCode = texelFetch(uStrings, ivec2(charPos, stringRow), 0).r; + if (charCode == 0u) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 4. Compute lerped world position and size + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // 5. Sizing pipeline (matches NameLayer.ts) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + float worldScale = nameWorldScale; + + // Troop count is smaller + if (lineIdx == 1) { + worldScale *= uTroopSizeMultiplier; + } + + // Zoom-based culling: compute screen-space size and skip if too small + // uCamera[0][0] is the x-scale component of the camera matrix + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 6. Read pre-computed centered cursor X position + float cursorX = texelFetch(uCursorX, ivec2(charPos, stringRow), 0).r; + + // 7. Glyph metrics for this character + vec4 m0 = texelFetch(uGlyphMetrics, ivec2(int(charCode), 0), 0); // xadvance, xoffset, yoffset, width + vec4 m1 = texelFetch(uGlyphMetrics, ivec2(int(charCode), 1), 0); // height, atlasU0, atlasV0, atlasU1 + // atlasV1 packed: we need 5 values from 2 RGBA texels (8 slots), so atlasV1 is in m0 slot? + // Actually let's use: m0=(xadvance, xoffset, yoffset, width), m1=(height, u0, v0, u1), and compute v1 + 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; + + // Degenerate if glyph has no size (e.g. space) + if (glyphW <= 0.0 || glyphH <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 8. Compute world-space quad position + float baselineY = -uBase * 0.5; // center vertically + // Use name-line scale for offset so troops sit below the name, not overlapping + float lineOffsetY = (lineIdx == 1) ? uBase * nameWorldScale * 1.1 : 0.0; + + vec2 glyphOrigin = vec2( + cursorX + m0.y, // + xoffset + baselineY + m0.z // + yoffset + ) * worldScale; + + vec2 glyphSize = vec2(glyphW, glyphH) * worldScale; + + vec2 worldPos = vec2(wx, wy + lineOffsetY) + glyphOrigin + aPos * glyphSize; + + // 9. Camera transform + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // 10. UV interpolation across quad + vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); + vPlayerColor = pd2; // player territory color (rgb) + alpha + vIsHuman = pd3.z; // 1.0 = human, 0.0 = bot/nation +} diff --git a/src/client/render/gl/shaders/name/status-icon.frag.glsl b/src/client/render/gl/shaders/name/status-icon.frag.glsl new file mode 100644 index 000000000..e8ac082c0 --- /dev/null +++ b/src/client/render/gl/shaders/name/status-icon.frag.glsl @@ -0,0 +1,40 @@ +#version 300 es +precision highp float; + +uniform sampler2D uStatusAtlas; + +in vec2 vUV; +in vec2 vLocalUV; +flat in int vDiscard; +flat in float vAllianceFraction; +flat in vec2 vFadedUV0; +flat in vec2 vFadedUV1; +flat in float vFlashAlpha; + +out vec4 fragColor; + +void main() { + if (vDiscard != 0) discard; + + vec4 texel = texture(uStatusAtlas, vUV); + + // Alliance drain: composite faded icon behind colored icon, clipped by fraction. + // Matches the game's CSS clip-path: inset(topCut% -2px 0 -2px) behavior. + if (vAllianceFraction > 0.0) { + // Game formula: topCut = 20 + (1-fraction) * 80 * 0.78 (% → 0..1) + float topCut = 0.20 + (1.0 - vAllianceFraction) * 0.624; + + // Sample faded icon at corresponding local position + vec2 fadedUV = mix(vFadedUV0, vFadedUV1, vLocalUV); + vec4 fadedTexel = texture(uStatusAtlas, fadedUV); + + // Above the cut line → show faded; below → show colored + texel = vLocalUV.y < topCut ? fadedTexel : texel; + } + + // Traitor flash: modulate alpha for urgency pulse + texel.a *= vFlashAlpha; + + if (texel.a < 0.01) discard; + fragColor = texel; +} diff --git a/src/client/render/gl/shaders/name/status-icon.vert.glsl b/src/client/render/gl/shaders/name/status-icon.vert.glsl new file mode 100644 index 000000000..dd5c6af27 --- /dev/null +++ b/src/client/render/gl/shaders/name/status-icon.vert.glsl @@ -0,0 +1,217 @@ +#version 300 es +precision highp float; +precision highp int; + +// Unit quad vertex position [0,0]→[1,1] +layout(location = 0) in vec2 aPos; + +// Data textures (shared with name shader) +uniform sampler2D uPlayerData; // 8 × MAX_PLAYERS, RGBA32F + +// Uniforms +uniform mat3 uCamera; +uniform float uTime; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; +uniform float uFontSize; +uniform float uFontBase; + +// Status atlas layout +uniform float uStatusCell; // texels per cell (square) +uniform float uStatusCols; // columns in atlas +uniform float uStatusAtlasW; // atlas texture width +uniform float uStatusAtlasH; // atlas texture height +uniform float uStatusPad; // transparent padding in texels per side + +// Configurable layout +uniform float uStatusRowOffset; // row Y offset (multiples of uFontBase * nameWorldScale) + +out vec2 vUV; +out vec2 vLocalUV; // 0..1 within the icon cell +flat out int vDiscard; +flat out float vAllianceFraction; // 0 = no drain effect, >0 = active drain +flat out vec2 vFadedUV0; // top-left UV of faded alliance cell +flat out vec2 vFadedUV1; // bottom-right UV of faded alliance cell +flat out float vFlashAlpha; // traitor flash opacity (1.0 = fully visible) + +// Status flag float array — indexed by icon slot. +// Slot mapping: 0=crown, 1=traitor, 2=disconnected, 3=alliance, +// 4=allianceReq, 5=target, 6=embargo, 7=nukeActive +float statusFlag[8]; + +// Read status flags from pd5/pd6 into the statusFlag array. +void readStatusFlags(int playerIdx) { + vec4 pd5 = texelFetch(uPlayerData, ivec2(5, playerIdx), 0); + vec4 pd6 = texelFetch(uPlayerData, ivec2(6, playerIdx), 0); + statusFlag[0] = pd5.x; // crown + statusFlag[1] = pd5.y; // traitor + statusFlag[2] = pd5.z; // disconnected + statusFlag[3] = pd5.w; // alliance + statusFlag[4] = pd6.x; // allianceReq + statusFlag[5] = pd6.y; // target + statusFlag[6] = pd6.z; // embargo + statusFlag[7] = pd6.w; // nukeActive +} + +// Count active icons with index < pos. +int countBelow(int pos) { + int count = 0; + for (int i = 0; i < pos; i++) { + if (statusFlag[i] > 0.5) count++; + } + return count; +} + +// Compute padded UV rect for an atlas cell. +// Returns (u0, v0) in xy and (u1, v1) in zw, inset by pad pixels. +vec4 cellUV(int idx) { + int col = idx - (idx / int(uStatusCols)) * int(uStatusCols); + int row = idx / int(uStatusCols); + float u0 = (float(col) * uStatusCell + uStatusPad) / uStatusAtlasW; + float v0 = (float(row) * uStatusCell + uStatusPad) / uStatusAtlasH; + float iconSize = uStatusCell - 2.0 * uStatusPad; + float u1 = u0 + iconSize / uStatusAtlasW; + float v1 = v0 + iconSize / uStatusAtlasH; + return vec4(u0, v0, u1, v1); +} + +void main() { + // Decode instance ID → playerIdx + iconSlot (0..7) + int playerIdx = gl_InstanceID / 8; + int iconSlot = gl_InstanceID - playerIdx * 8; + + // Read player data + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive + vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, [free] + + // Early out: dead player + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vLocalUV = vec2(0.0); + vDiscard = 1; + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + vFlashAlpha = 1.0; + return; + } + + // Read status flags into array + readStatusFlags(playerIdx); + + // Early out: this icon slot is inactive + if (statusFlag[iconSlot] < 0.5) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vLocalUV = vec2(0.0); + vDiscard = 1; + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + vFlashAlpha = 1.0; + return; + } + + // Lerped world position and size (same math as name.vert.glsl) + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // Sizing pipeline (must match name.vert.glsl exactly) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + + // Zoom-based culling (same as name shader) + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uFontBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vLocalUV = vec2(0.0); + vDiscard = 1; + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + vFlashAlpha = 1.0; + return; + } + + // Icon world size: matches name text height + float iconWorldSize = uFontBase * nameWorldScale * 1.1; + + // Count active icons and position of this one (left-to-right) + int totalActive = 0; + for (int i = 0; i < 8; i++) { + if (statusFlag[i] > 0.5) totalActive++; + } + int myIndex = countBelow(iconSlot); + + // Horizontal centering: spread icons evenly above the name + float gap = iconWorldSize * 0.15; + float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap; + float startX = wx - totalWidth * 0.5; + float iconX = startX + float(myIndex) * (iconWorldSize + gap); + + // Position: row above the emoji row + float iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset; + + // Determine atlas index + // Slots 0-6 map directly to atlas indices 0-6 + // Slot 7 (nuke): use nukeRed (7) if nukeTargetsMe, else nukeWhite (8) + int atlasIdx = iconSlot; + if (iconSlot == 7) { + atlasIdx = (pd7.x > 0.5) ? 7 : 8; + } + + // Quad world position + vec2 iconOrigin = vec2(iconX, iconY); + vec2 worldPos = iconOrigin + aPos * vec2(iconWorldSize, iconWorldSize); + + // Camera transform + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // UV from atlas grid (padded to avoid mipmap bleed) + vec4 uv = cellUV(atlasIdx); + vUV = vec2(mix(uv.x, uv.z, aPos.x), mix(uv.y, uv.w, aPos.y)); + vLocalUV = aPos; + + // Alliance drain: slot 3 = alliance icon + float allianceFrac = pd7.z; + if (iconSlot == 3 && allianceFrac > 0.0 && allianceFrac < 1.0) { + vAllianceFraction = allianceFrac; + // Faded alliance icon is at atlas index 9 + vec4 fadedUV = cellUV(9); + vFadedUV0 = fadedUV.xy; + vFadedUV1 = fadedUV.zw; + } else { + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + } + + // Traitor flash: slot 1 = traitor icon + // Frequency ramps linearly from 2 Hz (at 15s) to 5 Hz (at 0s). + // Phase = uTime*2 + elapsed²*0.1 — the quadratic term adds smooth + // acceleration without phase discontinuities between ticks. + vFlashAlpha = 1.0; + if (iconSlot == 1) { + float remaining = pd7.y; // ticks (0-300, 10/sec) + float remainingSec = remaining / 10.0; // seconds + if (remainingSec <= 15.0 && remainingSec > 0.0) { + float elapsed = 15.0 - remainingSec; + float phase = uTime * 2.0 + elapsed * elapsed * 0.1; + vFlashAlpha = 0.3 + 0.7 * (0.5 + 0.5 * cos(phase * 6.2832)); + } + } + + vDiscard = 0; +} diff --git a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl new file mode 100644 index 000000000..dbc2081f7 --- /dev/null +++ b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl @@ -0,0 +1,56 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; +flat in float vInnerRadius; +flat in float vOuterRadius; + +uniform float uTime; // seconds +uniform vec4 uTelegraphStyle; // (strokeWidth, dashLen, gapLen, rotationSpeed) +uniform vec4 uTelegraphAlpha; // (baseAlpha, pulseAmplitude, pulseSpeed, fillAlphaOffset) +uniform vec3 uTelegraphColor; + +out vec4 fragColor; + +void main() { + float strokeWidth = uTelegraphStyle.x; + float dashLen = uTelegraphStyle.y; + float gapLen = uTelegraphStyle.z; + float rotationSpeed = uTelegraphStyle.w; + float baseAlphaVal = uTelegraphAlpha.x; + float pulseAmp = uTelegraphAlpha.y; + float pulseSpd = uTelegraphAlpha.z; + float fillAlphaOff = uTelegraphAlpha.w; + + float paddedR = vOuterRadius + 2.0; + float dist = length(vLocal) * paddedR; + + // Base alpha with gentle pulsation + float baseAlpha = baseAlphaVal + pulseAmp * sin(uTime * pulseSpd); + + // Inner circle: filled disc + stroke + float innerFill = 1.0 - smoothstep(vInnerRadius - 0.5, vInnerRadius, dist); + float innerStroke = smoothstep(vInnerRadius - strokeWidth - 0.5, vInnerRadius - strokeWidth, dist) + * (1.0 - smoothstep(vInnerRadius + strokeWidth, vInnerRadius + strokeWidth + 0.5, dist)); + + // Outer circle: dashed ring + float outerRing = smoothstep(vOuterRadius - strokeWidth - 0.5, vOuterRadius - strokeWidth, dist) + * (1.0 - smoothstep(vOuterRadius + strokeWidth, vOuterRadius + strokeWidth + 0.5, dist)); + + // Dash pattern on outer ring + float angle = atan(vLocal.y, vLocal.x); + float arcPos = angle * vOuterRadius; + float period = dashLen + gapLen; + float dashPhase = mod(arcPos + uTime * rotationSpeed, period); + float dashAlpha = 1.0 - smoothstep(dashLen - 0.5, dashLen + 0.5, dashPhase); + + // Combine + float fillAlpha = innerFill * max(0.0, baseAlpha - fillAlphaOff); + float strokeAlpha = innerStroke * baseAlpha; + float outerAlpha = outerRing * dashAlpha * baseAlpha; + + float alpha = max(max(fillAlpha, strokeAlpha), outerAlpha); + if (alpha < 0.01) discard; + + fragColor = vec4(uTelegraphColor, alpha); +} diff --git a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl new file mode 100644 index 000000000..a53be58d0 --- /dev/null +++ b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +// Unit quad [0,1] +layout(location = 0) in vec2 aPos; +// Per-instance: x, y, innerRadius, outerRadius +layout(location = 1) in vec4 aInstance; + +uniform mat3 uCamera; + +out vec2 vLocal; // [-1, +1] local coords +flat out float vInnerRadius; +flat out float vOuterRadius; + +void main() { + vLocal = aPos * 2.0 - 1.0; + vInnerRadius = aInstance.z; + vOuterRadius = aInstance.w; + + // Expand quad to cover outer circle bbox + padding + float r = aInstance.w + 2.0; + vec2 center = aInstance.xy + 0.5; + vec2 worldPos = center + vLocal * r; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl new file mode 100644 index 000000000..62ce07569 --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl @@ -0,0 +1,45 @@ +#version 300 es +precision highp float; + +in vec2 vUV; +flat in float vType; + +out vec4 fragColor; + +// Colors matching upstream +const vec3 COLOR_WHITE = vec3(1.0); +const vec3 OUTLINE_GRAY = vec3(0.549); // rgba(140, 140, 140) +const vec3 COLOR_RED = vec3(1.0, 0.0, 0.0); +const vec3 OUTLINE_BLACK = vec3(0.0); + +void main() { + float alpha = 0.0; + vec3 color = vec3(1.0); + + if (vType < 0.5) { + // Circle marker at untargetable zone boundary + // White ring with gray outline (upstream: 4px radius, 1.25px stroke) + float dist = length(vUV); + float ring = abs(dist - 0.55); + float lineAlpha = 1.0 - smoothstep(0.06, 0.12, ring); + float outlineAlpha = 1.0 - smoothstep(0.14, 0.22, ring); + float blend = outlineAlpha > 0.01 ? lineAlpha / outlineAlpha : 1.0; + color = mix(OUTLINE_GRAY, COLOR_WHITE, blend); + alpha = outlineAlpha; + } else { + // X marker at SAM intercept point + // Red X with black outline (upstream: 6px arms, 2px stroke) + float d1 = abs(vUV.x - vUV.y) * 0.7071; + float d2 = abs(vUV.x + vUV.y) * 0.7071; + float minD = min(d1, d2); + float circleMask = 1.0 - smoothstep(0.7, 0.85, length(vUV)); + float lineAlpha = (1.0 - smoothstep(0.08, 0.16, minD)) * circleMask; + float outlineAlpha = (1.0 - smoothstep(0.18, 0.28, minD)) * circleMask; + float blend = outlineAlpha > 0.01 ? lineAlpha / outlineAlpha : 1.0; + color = mix(OUTLINE_BLACK, COLOR_RED, blend); + alpha = outlineAlpha; + } + + if (alpha < 0.01) discard; + fragColor = vec4(color, alpha); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl new file mode 100644 index 000000000..4f75479ce --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl @@ -0,0 +1,33 @@ +#version 300 es +precision highp float; + +// Unit quad [-1, +1] +layout(location = 0) in vec2 aCorner; + +uniform mat3 uCamera; +uniform vec2 uP0, uP1, uP2, uP3; +uniform float uPixelSize; +uniform vec4 uMarker; // (t, type: 0=circle 1=X, 0, 0) +uniform vec2 uMarkerRadii; // (circleRadiusPx, xRadiusPx) + +out vec2 vUV; +flat out float vType; + +vec2 bezier(float t) { + float T = 1.0 - t; + float TT = T * T; + float tt = t * t; + return TT * T * uP0 + 3.0 * TT * t * uP1 + 3.0 * T * tt * uP2 + tt * t * uP3; +} + +void main() { + vType = uMarker.y; + vUV = aCorner; + + vec2 center = bezier(uMarker.x) + 0.5; + float radius = (vType < 0.5 ? uMarkerRadii.x : uMarkerRadii.y) * uPixelSize; + vec2 pos = center + aCorner * radius; + + vec3 clip = uCamera * vec3(pos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.frag.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.frag.glsl new file mode 100644 index 000000000..f291e702b --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.frag.glsl @@ -0,0 +1,54 @@ +#version 300 es +precision highp float; + +in float vT; +in float vArcDist; +in float vEdgeDist; + +uniform float uPixelSize; +uniform float uTUntargetableStart; // -1 = no zone +uniform float uTUntargetableEnd; // -1 = no zone +uniform float uTSamIntercept; // 1.0 = no intercept + +// Settings uniforms +uniform float uQuadHalfPx; // total half-width of quad in pixels +uniform float uLineHalfPx; // main line half-width in pixels +uniform float uOutlineHalfPx; // outline half-width in pixels +uniform vec4 uDashPattern; // (dashTargetable, gapTargetable, dashUntargetable, gapUntargetable) +uniform vec3 uLineColor; // normal line color +uniform vec3 uInterceptColor; // line color after SAM intercept +uniform vec3 uOutlineColor; // outline color (normal) +uniform vec3 uInterceptOutlineColor; // outline color (after intercept) + +out vec4 fragColor; + +void main() { + // Zone classification + bool inUntargetable = uTUntargetableStart >= 0.0 + && vT >= uTUntargetableStart + && vT <= uTUntargetableEnd; + bool intercepted = vT >= uTSamIntercept; + + // Dash pattern (pixel space) + float dashLen = inUntargetable ? uDashPattern.z : uDashPattern.x; + float gapLen = inUntargetable ? uDashPattern.w : uDashPattern.y; + float period = dashLen + gapLen; + float pixelDist = vArcDist / uPixelSize; + float phase = mod(pixelDist, period); + float dashAlpha = 1.0 - smoothstep(dashLen - 0.5, dashLen + 0.5, phase); + if (dashAlpha < 0.01) discard; + + // Line vs outline (pixel distance from center line) + float d = abs(vEdgeDist) * uQuadHalfPx; + float lineAlpha = 1.0 - smoothstep(uLineHalfPx - 0.4, uLineHalfPx + 0.4, d); + float outlineAlpha = 1.0 - smoothstep(uOutlineHalfPx - 0.4, uOutlineHalfPx + 0.4, d); + if (outlineAlpha < 0.01) discard; + + // Color selection + vec3 lineColor = intercepted ? uInterceptColor : uLineColor; + vec3 outlineColor = intercepted ? uInterceptOutlineColor : uOutlineColor; + float blend = outlineAlpha > 0.01 ? lineAlpha / outlineAlpha : 1.0; + vec3 color = mix(outlineColor, lineColor, blend); + + fragColor = vec4(color, outlineAlpha * dashAlpha); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.vert.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.vert.glsl new file mode 100644 index 000000000..fd7a4d4be --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.vert.glsl @@ -0,0 +1,48 @@ +#version 300 es +precision highp float; + +// Per-vertex: (t along curve, side: -1 or +1, cumulative arc distance) +layout(location = 0) in vec3 aVertex; + +uniform mat3 uCamera; +uniform vec2 uP0, uP1, uP2, uP3; // Bezier control points +uniform float uPixelSize; // world units per pixel +uniform float uQuadHalfPx; // half-width of quad in pixels + +out float vT; // curve parameter (0..1) +out float vArcDist; // cumulative arc distance (world units) +out float vEdgeDist; // -1..+1 across the line width + +vec2 bezier(float t) { + float T = 1.0 - t; + float TT = T * T; + float tt = t * t; + return TT * T * uP0 + 3.0 * TT * t * uP1 + 3.0 * T * tt * uP2 + tt * t * uP3; +} + +vec2 bezierDeriv(float t) { + float T = 1.0 - t; + return 3.0 * (T * T * (uP1 - uP0) + 2.0 * T * t * (uP2 - uP1) + t * t * (uP3 - uP2)); +} + +void main() { + float t = aVertex.x; + float side = aVertex.y; + + vec2 pos = bezier(t); + vec2 tang = bezierDeriv(t); + float tangLen = length(tang); + vec2 normTang = tangLen > 0.001 ? tang / tangLen : vec2(1.0, 0.0); + vec2 perp = vec2(-normTang.y, normTang.x); + + float halfWidth = uQuadHalfPx * uPixelSize; + pos += perp * side * halfWidth; + pos += 0.5; // tile center offset + + vT = t; + vEdgeDist = side; + vArcDist = aVertex.z; + + vec3 clip = uCamera * vec3(pos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/radial-menu/arcs.frag.glsl b/src/client/render/gl/shaders/radial-menu/arcs.frag.glsl new file mode 100644 index 000000000..12481b223 --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/arcs.frag.glsl @@ -0,0 +1,132 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; // [-1, +1], distance 1.0 = outerR + +uniform float uInnerR; // inner radius as fraction of outerR [0,1] +uniform int uSegCount; // number of segments (1..8) +uniform int uHoveredSeg; // hovered segment index (-1 = none) +uniform vec4 uSegColors[8]; // per-segment: rgb + enabled (a: 1 = enabled, 0 = disabled) + +// Center button +uniform int uHasCenterBtn; // 1 = show center button +uniform vec3 uCenterColor; // center button RGB +uniform int uCenterHovered; // 1 = center button hovered + +out vec4 fragColor; + +const float GAP = 0.03; // radians gap between segments (game: padAngle 0.03) +const float AA = 0.010; // anti-alias width (normalized coords) +const float BORDER_W = 0.024; // border width, non-hovered +const float BORDER_W_HOV = 0.034; // border width, hovered (thicker) +const float PI = 3.14159265359; +const float TWO_PI = 6.28318530718; + +void main() { + float dist = length(vLocal); + + // --- Center button zone --- + if (dist < uInnerR - AA) { + if (uHasCenterBtn == 0) discard; + + // Solid center fill — fade alpha only at outer edge + float centerAlpha = 1.0 - smoothstep(uInnerR - AA * 3.0, uInnerR - AA, dist); + + bool cHov = uCenterHovered > 0; + float cbw = cHov ? BORDER_W_HOV : BORDER_W; + vec3 cbCol = cHov ? vec3(1.0) : vec3(0.88); + + // Crisp border at outer edge of center circle + float borderDist = uInnerR - AA - dist; + float border = 1.0 - smoothstep(cbw - AA, cbw + AA, borderDist); + + vec3 color = uCenterColor; + if (cHov) color = mix(color, vec3(1.0), 0.2); + color = mix(color, cbCol, border); + + float cAlpha = cHov ? 0.92 : 0.6; + fragColor = vec4(color, cAlpha * centerAlpha); + return; + } + + // --- Ring zone --- + if (uSegCount == 0) discard; // center-only mode + + // Annulus mask + float outer = 1.0 - smoothstep(1.0 - AA, 1.0, dist); + float inner = smoothstep(uInnerR - AA, uInnerR + AA, dist); + float ring = outer * inner; + if (ring < 0.01) discard; + + // Angle: 0 at top, increasing clockwise [0, 2π] + float angle = atan(vLocal.x, -vLocal.y); + if (angle < 0.0) angle += TWO_PI; + + // Rotate so first segment is centered at top (game: startAngle = -π/n) + float segArc = TWO_PI / float(uSegCount); + float offset = PI / float(uSegCount); + float shifted = mod(angle + offset, TWO_PI); + + // Segment index (in rotated space) + int segIdx = int(floor(shifted / segArc)); + segIdx = min(segIdx, uSegCount - 1); + + // Gap mask between segments + float segStart = float(segIdx) * segArc; + float segEnd = segStart + segArc; + float halfGap = GAP * 0.5; + + float gap = 1.0; + if (uSegCount > 1) { + gap = smoothstep(segStart + halfGap - AA, segStart + halfGap + AA, shifted) + * (1.0 - smoothstep(segEnd - halfGap - AA, segEnd - halfGap + AA, shifted)); + } + + float alpha = ring * gap; + if (alpha < 0.01) discard; + + // Segment color + hover state + vec4 seg = uSegColors[segIdx]; + vec3 color = seg.rgb; + bool enabled = seg.a > 0.5; + bool hovered = (segIdx == uHoveredSeg && enabled); + + // Pick border width & color based on hover + float bw = hovered ? BORDER_W_HOV : BORDER_W; + vec3 borderCol = hovered ? vec3(1.0) : vec3(0.88); + + // --- Borders --- + // Outer edge + float outerBorder = 1.0 - smoothstep(bw - AA, bw + AA, 1.0 - dist); + // Inner edge + float innerBorder = 1.0 - smoothstep(bw - AA, bw + AA, dist - uInnerR); + // Radial lines at gap edges + float angBorder = 0.0; + if (uSegCount > 1) { + float angleInSeg = shifted - segStart; + float distToStart = angleInSeg - halfGap; + float distToEnd = (segArc - halfGap) - angleInSeg; + // Convert angular distance to approximate normalized arc-length + float nearestAng = min(distToStart, distToEnd) * dist; + angBorder = 1.0 - smoothstep(bw - AA, bw + AA, nearestAng); + } + float border = max(max(outerBorder, innerBorder), angBorder); + + // Disabled segments: desaturate + darken + if (!enabled) { + float lum = dot(color, vec3(0.3, 0.6, 0.1)); + color = vec3(lum) * 0.4; + } + + // Hover highlight: brighten fill + if (hovered) { + color = mix(color, vec3(1.0), 0.2); + } + + // Blend border on top + color = mix(color, borderCol, border); + + // Opacity: hovered → nearly opaque, default → slightly transparent, disabled → dim + float segAlpha = enabled ? (hovered ? 0.92 : 0.6) : 0.4; + fragColor = vec4(color, alpha * segAlpha); +} diff --git a/src/client/render/gl/shaders/radial-menu/arcs.vert.glsl b/src/client/render/gl/shaders/radial-menu/arcs.vert.glsl new file mode 100644 index 000000000..5e5b379ab --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/arcs.vert.glsl @@ -0,0 +1,24 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; // [0,1] quad + +uniform vec2 uAnchor; // anchor in device pixels +uniform float uOuterR; // outer radius in device pixels +uniform vec2 uViewport; // drawingBuffer width, height + +out vec2 vLocal; // [-1, +1] square pixel-space + +void main() { + vLocal = aPos * 2.0 - 1.0; + + // Expand quad to [-outerR, +outerR] in device pixels around anchor + vec2 pos = uAnchor + vLocal * uOuterR; + + // Device pixels → NDC + gl_Position = vec4( + pos.x / uViewport.x * 2.0 - 1.0, + 1.0 - pos.y / uViewport.y * 2.0, + 0.0, 1.0 + ); +} diff --git a/src/client/render/gl/shaders/radial-menu/icon.frag.glsl b/src/client/render/gl/shaders/radial-menu/icon.frag.glsl new file mode 100644 index 000000000..79862e014 --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/icon.frag.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +in vec2 vUV; +flat in float vAtlasIdx; +flat in float vOpacity; + +uniform sampler2D uEmojiAtlas; +uniform float uEmojiCell; +uniform float uEmojiCols; +uniform float uEmojiAtlasW; +uniform float uEmojiAtlasH; + +out vec4 fragColor; + +void main() { + if (vAtlasIdx < 0.0) discard; + + float col = mod(vAtlasIdx, uEmojiCols); + float row = floor(vAtlasIdx / uEmojiCols); + + vec2 cellOrigin = vec2(col * uEmojiCell / uEmojiAtlasW, row * uEmojiCell / uEmojiAtlasH); + vec2 cellSize = vec2(uEmojiCell / uEmojiAtlasW, uEmojiCell / uEmojiAtlasH); + + vec4 texel = texture(uEmojiAtlas, cellOrigin + vUV * cellSize); + fragColor = vec4(texel.rgb, texel.a * vOpacity); +} diff --git a/src/client/render/gl/shaders/radial-menu/icon.vert.glsl b/src/client/render/gl/shaders/radial-menu/icon.vert.glsl new file mode 100644 index 000000000..ee6fc4899 --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/icon.vert.glsl @@ -0,0 +1,74 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; // [0,1] quad + +uniform vec2 uAnchor; // anchor in device pixels +uniform float uOuterR; // outer radius in device pixels +uniform float uInnerR; // inner radius as fraction of outerR [0,1] +uniform vec2 uViewport; // drawingBuffer width, height +uniform int uSegCount; // number of segments +uniform float uIconHalf; // icon half-size in device pixels +uniform float uEmojiIndices[8]; // atlas index per segment (-1 = none) +uniform float uCenterEmojiIdx; // atlas index for center icon (-1 = none) +uniform float uSegOpacity[8]; // per-segment opacity (0..1) + +out vec2 vUV; +flat out float vAtlasIdx; +flat out float vOpacity; + +const float PI = 3.14159265359; +const float TWO_PI = 6.28318530718; + +void main() { + int segIdx = gl_InstanceID; + + // Center icon: last instance (index == uSegCount) + if (segIdx == uSegCount) { + vAtlasIdx = uCenterEmojiIdx; + vOpacity = 1.0; // center icon always full opacity + if (vAtlasIdx < 0.0) { + gl_Position = vec4(2.0, 2.0, 0.0, 1.0); + vUV = vec2(0.0); + return; + } + // Position at anchor center — always upright + vec2 local = aPos * 2.0 - 1.0; + vec2 pos = uAnchor + local * uIconHalf; + gl_Position = vec4( + pos.x / uViewport.x * 2.0 - 1.0, + 1.0 - pos.y / uViewport.y * 2.0, + 0.0, 1.0 + ); + vUV = aPos; + return; + } + + vAtlasIdx = uEmojiIndices[segIdx]; + vOpacity = uSegOpacity[segIdx]; + + if (vAtlasIdx < 0.0 || segIdx >= uSegCount) { + gl_Position = vec4(2.0, 2.0, 0.0, 1.0); + vUV = vec2(0.0); + return; + } + + // Arc center position — rotated so first segment is centered at top + float segArc = TWO_PI / float(uSegCount); + float offset = PI / float(uSegCount); + float angle = (float(segIdx) + 0.5) * segArc - offset; + float midR = (uInnerR + 1.0) * 0.5 * uOuterR; + vec2 center = uAnchor + vec2(sin(angle), -cos(angle)) * midR; + + // Quad corners — always axis-aligned (upright icons) + vec2 local = aPos * 2.0 - 1.0; + vec2 pos = center + local * uIconHalf; + + gl_Position = vec4( + pos.x / uViewport.x * 2.0 - 1.0, + 1.0 - pos.y / uViewport.y * 2.0, + 0.0, 1.0 + ); + + vUV = aPos; +} diff --git a/src/client/render/gl/shaders/railroad/railroad.frag.glsl b/src/client/render/gl/shaders/railroad/railroad.frag.glsl new file mode 100644 index 000000000..0b363625d --- /dev/null +++ b/src/client/render/gl/shaders/railroad/railroad.frag.glsl @@ -0,0 +1,155 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uRailroadTex; // R8UI — rail type per tile (0=none, 1-6) +uniform usampler2D uGhostRailTex; // R8UI — ghost rail type per tile (0=none, 1-6) +uniform usampler2D uTileTex; // R16UI — tile state (for owner lookup) +uniform sampler2D uPalette; // RGBA32F — player colors +uniform usampler2D uTerrainTex; // R8UI — terrain bytes (bit 7 = isLand) + +uniform vec2 uMapSize; +uniform float uZoom; +uniform float uRailDetailZoom; +uniform float uRailAlpha; +uniform float uGhostOwnerID; // Player smallID for ghost rail color + +in vec2 vWorldPos; +out vec4 fragColor; + +// Bridge pixel positions per rail type, from OpenFrontIO's RailroadSprites.ts. +// Tests whether 2x-pixel offset (lp) from a tile origin is a bridge pixel. +// Bridge pixel positions from game's RailroadSprites.ts, with -2 offsets +// shifted to -1 to close the gap (game's rail extends into neighbors, ours doesn't). +bool isBridgePixel(uint rt, ivec2 lp) { + int x = lp.x, y = lp.y; + if (rt == 1u) { // Vertical + return (x == -1 || x == 2) && y >= -1 && y <= 1; + } else if (rt == 2u) { // Horizontal + return (y == -1 && x >= -1 && x <= 1) + || (y == 2 && x >= -1 && x <= 1) + || (y == 3 && (x == -1 || x == 1)); + } else if (rt == 3u) { // TopLeft + return (x == -1 && (y == -1 || y == 2)) + || (x == 0 && y == 1) + || (x == 1 && y == 0) + || (x == 2 && y == -1); + } else if (rt == 4u) { // TopRight + return (x == -1 && (y == -1 || y == 0)) + || (x == 0 && y == 1) + || (x == 1 && y == 2) + || (x == 2 && (y == -1 || y == 2)); + } else if (rt == 5u) { // BottomLeft + return (x == -1 && (y == -1 || y == 2)) + || (x == 0 && y == -1) + || (x == 1 && y == 0) + || (x == 2 && (y == 1 || y == 2)); + } else if (rt == 6u) { // BottomRight + return (x == -1 && y >= 0 && y <= 2) + || (x == 0 && y == -1) + || (x == 1 && y == -1) + || (x == 2 && (y == -1 || y == 2)); + } + return false; +} + +// Compute rail pixel coverage for a given rail type at fractional tile position. +// Returns 0.0 for miss, 1.0 for hit (detailed mode), or AA coverage (line mode). +float railCoverage(uint rt, vec2 f) { + if (rt == 0u) return 0.0; + + if (uZoom >= uRailDetailZoom) { + // Detailed mode: 3x3 sub-grid with cross-ties + float T = 1.0 / 3.0; + float T2 = 2.0 / 3.0; + bool center = (f.x >= T && f.x < T2 && f.y >= T && f.y < T2); + bool hit = false; + if (rt == 1u) { + hit = (f.x < T) || (f.x >= T2) || center; + } else if (rt == 2u) { + hit = (f.y < T) || (f.y >= T2) || center; + } else if (rt == 3u) { + hit = (f.y < T) || (f.x < T) || center; + } else if (rt == 4u) { + hit = (f.y < T) || (f.x >= T2) || center; + } else if (rt == 5u) { + hit = (f.y >= T2) || (f.x < T) || center; + } else if (rt == 6u) { + hit = (f.y >= T2) || (f.x >= T2) || center; + } + return hit ? 1.0 : 0.0; + } else { + // Simplified mode: fill entire tile (tiles are small at this zoom) + return 1.0; + } +} + +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 railType = texelFetch(uRailroadTex, tc, 0).r; + uint ghostRailType = texelFetch(uGhostRailTex, tc, 0).r; + vec2 f = fract(vWorldPos); + + // Compute coverage for real and ghost rails + float realCov = railCoverage(railType, f); + // Ghost only renders where there is no real rail (values 1-6 = ghost path) + // Value 7 = highlight marker (existing rail turns green) + float ghostCov = (ghostRailType >= 1u && ghostRailType <= 6u && railType == 0u) + ? railCoverage(ghostRailType, f) + : 0.0; + bool highlighted = (ghostRailType == 7u && railType != 0u); + + bool hitRail = (realCov * uRailAlpha > 0.001); + bool hitGhost = (ghostCov * uRailAlpha > 0.001); + + // --- Bridge: check 3x3 neighborhood for water+rail tiles --- + bool hitBridge = false; + ivec2 fp = ivec2(floor(vWorldPos * 2.0)); // fragment pos in game's 2x-pixel grid + + for (int dy = -1; dy <= 1 && !hitBridge; dy++) { + for (int dx = -1; dx <= 1 && !hitBridge; dx++) { + ivec2 ntc = tc + ivec2(dx, dy); + if (ntc.x < 0 || ntc.y < 0 || ntc.x >= int(uMapSize.x) || ntc.y >= int(uMapSize.y)) + continue; + uint nRail = texelFetch(uRailroadTex, ntc, 0).r; + if (nRail == 0u) continue; + uint nTerr = texelFetch(uTerrainTex, ntc, 0).r; + if ((nTerr & 0x80u) != 0u) continue; // land tile, no bridge + ivec2 lp = fp - ntc * 2; + if (isBridgePixel(nRail, lp)) hitBridge = true; + } + } + + if (!hitBridge && !hitRail && !hitGhost) discard; + + // --- Color output --- + vec3 bridgeColor = vec3(0.773, 0.271, 0.282); + + if (hitRail) { + float railAlpha = uRailAlpha * realCov; + uint tileRaw = texelFetch(uTileTex, tc, 0).r; + uint owner = tileRaw & uint(OWNER_MASK); + vec3 railColor = owner != 0u + ? texture(uPalette, vec2((float(owner) + 0.5) / float(PALETTE_SIZE), 0.75)).rgb + : vec3(0.75); + // Overlapping railroad highlight — green tint + if (highlighted) railColor = vec3(0.2, 0.85, 0.3); + if (hitBridge) { + fragColor = vec4(mix(bridgeColor, railColor, railAlpha), 1.0); + } else { + fragColor = vec4(railColor, railAlpha); + } + } else if (hitGhost) { + float ghostAlpha = uRailAlpha * ghostCov * 0.5; + vec3 ghostColor = uGhostOwnerID > 0.0 + ? texture(uPalette, vec2((uGhostOwnerID + 0.5) / float(PALETTE_SIZE), 0.75)).rgb + : vec3(0.75); + fragColor = vec4(ghostColor, ghostAlpha); + } else { + fragColor = vec4(bridgeColor, 1.0); + } +} diff --git a/src/client/render/gl/shaders/range-circle/range-circle.frag.glsl b/src/client/render/gl/shaders/range-circle/range-circle.frag.glsl new file mode 100644 index 000000000..62744ab37 --- /dev/null +++ b/src/client/render/gl/shaders/range-circle/range-circle.frag.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; // [-1, +1] + +uniform float uRadius; + +out vec4 fragColor; + +void main() { + float dist = length(vLocal) * (uRadius + 1.0); // world-space distance from center + float edge = uRadius; + + // Smooth fill: inside the circle at 20% white + float fill = 1.0 - smoothstep(edge - 0.5, edge + 0.5, dist); + + // Stroke: 1-tile-wide ring at the edge + float strokeInner = edge - 1.0; + float strokeOuter = edge; + float stroke = smoothstep(strokeInner - 0.5, strokeInner + 0.5, dist) + * (1.0 - smoothstep(strokeOuter - 0.5, strokeOuter + 0.5, dist)); + + float alpha = fill * 0.2 + stroke * 0.5; + if (alpha < 0.001) discard; + + fragColor = vec4(1.0, 1.0, 1.0, alpha); +} diff --git a/src/client/render/gl/shaders/range-circle/range-circle.vert.glsl b/src/client/render/gl/shaders/range-circle/range-circle.vert.glsl new file mode 100644 index 000000000..407dbf57b --- /dev/null +++ b/src/client/render/gl/shaders/range-circle/range-circle.vert.glsl @@ -0,0 +1,24 @@ +#version 300 es +precision highp float; + +// Unit quad [0,1] +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; +uniform vec2 uCenter; // world-space circle center (tile coords) +uniform float uRadius; // world-space radius in tiles + +out vec2 vLocal; // [-1, +1] local coords within the quad + +void main() { + // Map [0,1] → [-1,+1] + vLocal = aPos * 2.0 - 1.0; + + // Expand quad to cover circle bbox in world space + // Add 1-tile padding for the stroke + float r = uRadius + 1.0; + vec2 worldPos = uCenter + 0.5 + vLocal * r; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/sam-radius/sam-radius.frag.glsl b/src/client/render/gl/shaders/sam-radius/sam-radius.frag.glsl new file mode 100644 index 000000000..a3c08ee5a --- /dev/null +++ b/src/client/render/gl/shaders/sam-radius/sam-radius.frag.glsl @@ -0,0 +1,74 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; +flat in float vRadius; +flat in vec3 vColor; +flat in vec2 vArcBounds; + +uniform float uTime; +uniform float uOutline; // 1.0 = owner mode (outline edges), 0.0 = perspective +uniform float uStrokeWidth; // ring half-width (world units) +uniform float uDashLen; // dash length (world units) +uniform float uGapLen; // gap length (world units) +uniform float uRotationSpeed; // rotation (world units/sec) +uniform float uAlpha; // base opacity +uniform float uOutlineWidth; // outline border width (world units) +uniform float uOutlineSoftness; // smoothstep range (0 = hard edge) + +out vec4 fragColor; + +const float TWO_PI = 6.2831853; + +void main() { + float paddedR = vRadius + 2.0; + float dist = length(vLocal) * paddedR; + + // Ring mask: stroke centered on the circle edge + float ringAlpha = smoothstep(vRadius - uStrokeWidth - 0.5, vRadius - uStrokeWidth, dist) + * (1.0 - smoothstep(vRadius + uStrokeWidth, vRadius + uStrokeWidth + 0.5, dist)); + + if (ringAlpha < 0.01) discard; + + // Arc clipping + float angle = atan(vLocal.y, vLocal.x); + float normAngle = angle < 0.0 ? angle + TWO_PI : angle; + bool fullCircle = vArcBounds.y - vArcBounds.x >= TWO_PI - 0.001; + if (!fullCircle) { + if (normAngle < vArcBounds.x || normAngle > vArcBounds.y) discard; + } + + // Dash pattern along circumference + float arcPos = angle * vRadius; + float period = uDashLen + uGapLen; + float dashPhase = mod(arcPos + uTime * uRotationSpeed, period); + float dashAlpha = 1.0 - smoothstep(uDashLen - 0.5, uDashLen + 0.5, dashPhase); + + float alpha = ringAlpha * dashAlpha * uAlpha; + if (alpha < 0.01) discard; + + // Outline: darken fragments near any edge of each dash segment + float edgeFade = 1.0; + if (uOutline > 0.5) { + float ow = uOutlineWidth; + float soft = uOutlineSoftness; + // Radial edges (inner/outer ring boundary) + float fromInner = dist - (vRadius - uStrokeWidth); + float fromOuter = (vRadius + uStrokeWidth) - dist; + edgeFade = min(smoothstep(ow - soft, ow + soft, fromInner), + smoothstep(ow - soft, ow + soft, fromOuter)); + // Dash start/end edges (circumferential) + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, dashPhase)); + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, uDashLen - dashPhase)); + // Arc endpoint edges (where circle union clips the arc) + if (!fullCircle) { + float arcDistStart = (normAngle - vArcBounds.x) * vRadius; + float arcDistEnd = (vArcBounds.y - normAngle) * vRadius; + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, arcDistStart)); + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, arcDistEnd)); + } + } + + vec3 finalColor = vColor * edgeFade; + fragColor = vec4(finalColor, alpha); +} diff --git a/src/client/render/gl/shaders/sam-radius/sam-radius.vert.glsl b/src/client/render/gl/shaders/sam-radius/sam-radius.vert.glsl new file mode 100644 index 000000000..a0682028f --- /dev/null +++ b/src/client/render/gl/shaders/sam-radius/sam-radius.vert.glsl @@ -0,0 +1,33 @@ +#version 300 es +precision highp float; + +// Unit quad [0,1] +layout(location = 0) in vec2 aPos; +// Per-instance: x, y, radius +layout(location = 1) in vec3 aInstance; +// Per-instance: r, g, b +layout(location = 2) in vec3 aColor; +// Per-instance: arcStart, arcEnd +layout(location = 3) in vec2 aArcBounds; + +uniform mat3 uCamera; + +out vec2 vLocal; // [-1, +1] local coords +flat out float vRadius; // world-space radius for this instance +flat out vec3 vColor; // relationship color +flat out vec2 vArcBounds; // arc start/end in [0, 2PI) + +void main() { + vLocal = aPos * 2.0 - 1.0; + vRadius = aInstance.z; + vColor = aColor; + vArcBounds = aArcBounds; + + // Expand quad to cover circle bbox + padding for stroke + float r = aInstance.z + 2.0; + vec2 center = aInstance.xy + 0.5; + vec2 worldPos = center + vLocal * r; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/selection-box/selection-box.frag.glsl b/src/client/render/gl/shaders/selection-box/selection-box.frag.glsl new file mode 100644 index 000000000..3d17fe268 --- /dev/null +++ b/src/client/render/gl/shaders/selection-box/selection-box.frag.glsl @@ -0,0 +1,38 @@ +#version 300 es +precision highp float; + +in vec2 vWorld; + +uniform vec2 uCenter; +uniform float uHalfSize; +uniform float uTime; // frame tick counter (increments each frame) +uniform vec3 uColor; // RGB [0-1], already lightened + +out vec4 fragColor; + +void main() { + // Tile-space relative to center + vec2 tile = floor(vWorld); + vec2 rel = tile - floor(uCenter); + float hs = uHalfSize; + + // Is this tile on the border of the selection box? + bool inXRange = rel.x >= -hs && rel.x <= hs; + bool inYRange = rel.y >= -hs && rel.y <= hs; + bool isXEdge = abs(rel.x - hs) < 0.5 || abs(rel.x + hs) < 0.5; + bool isYEdge = abs(rel.y - hs) < 0.5 || abs(rel.y + hs) < 0.5; + + bool onBorder = (isXEdge && inYRange) || (isYEdge && inXRange); + if (!onBorder) discard; + + // Stipple: checkerboard pattern (every other tile) + float stipple = mod(tile.x + tile.y, 2.0); + if (stipple > 0.5) discard; + + // Pulsating alpha (matches game: base 200/255 ± 55/255) + float baseAlpha = 0.784; // 200/255 + float pulseAmp = 0.216; // 55/255 + float alpha = baseAlpha + sin(uTime * 0.1) * pulseAmp; + + fragColor = vec4(uColor, alpha); +} diff --git a/src/client/render/gl/shaders/selection-box/selection-box.vert.glsl b/src/client/render/gl/shaders/selection-box/selection-box.vert.glsl new file mode 100644 index 000000000..03f2a86e4 --- /dev/null +++ b/src/client/render/gl/shaders/selection-box/selection-box.vert.glsl @@ -0,0 +1,22 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; +uniform vec2 uCenter; // world-space tile center (integer) +uniform float uHalfSize; // box half-size in tiles + +out vec2 vWorld; // world-space position + +void main() { + // Map [0,1] → [-1,+1] + vec2 local = aPos * 2.0 - 1.0; + + // Expand quad to cover box + 1-tile padding for AA + float r = uHalfSize + 1.0; + vWorld = uCenter + 0.5 + local * r; + + vec3 clip = uCamera * vec3(vWorld, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/shared/blur.frag.glsl b/src/client/render/gl/shaders/shared/blur.frag.glsl new file mode 100644 index 000000000..dc445f9ee --- /dev/null +++ b/src/client/render/gl/shaders/shared/blur.frag.glsl @@ -0,0 +1,16 @@ +#version 300 es +precision highp float; +uniform sampler2D uTex; +uniform vec2 uDir; +in vec2 vUV; +out vec4 fragColor; +const float w[5] = float[5](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216); +void main() { + vec4 result = texture(uTex, vUV) * w[0]; + for (int i = 1; i < 5; i++) { + vec2 off = uDir * float(i); + result += texture(uTex, vUV + off) * w[i]; + result += texture(uTex, vUV - off) * w[i]; + } + fragColor = result; +} diff --git a/src/client/render/gl/shaders/shared/fullscreen-no-uv.vert.glsl b/src/client/render/gl/shaders/shared/fullscreen-no-uv.vert.glsl new file mode 100644 index 000000000..95a8b6944 --- /dev/null +++ b/src/client/render/gl/shaders/shared/fullscreen-no-uv.vert.glsl @@ -0,0 +1,5 @@ +#version 300 es +layout(location = 0) in vec2 aPos; +void main() { + gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/shared/fullscreen.vert.glsl b/src/client/render/gl/shaders/shared/fullscreen.vert.glsl new file mode 100644 index 000000000..9a1858d75 --- /dev/null +++ b/src/client/render/gl/shaders/shared/fullscreen.vert.glsl @@ -0,0 +1,8 @@ +#version 300 es +precision highp float; +layout(location = 0) in vec2 aPos; +out vec2 vUV; +void main() { + gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); + vUV = aPos; +} diff --git a/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl new file mode 100644 index 000000000..fd0578d82 --- /dev/null +++ b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl @@ -0,0 +1,103 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTileTex; +uniform vec2 uMapSize; + +// Spawn center data packed as vec4 pairs: +// A[i] = (x, y, r, g) +// B[i] = (b, isSelf, isTeammate, _) +uniform vec4 uSpawnA[MAX_SPAWNS]; +uniform vec4 uSpawnB[MAX_SPAWNS]; +uniform int uSpawnCount; + +uniform float uBreathRadius; // normalized [0..1], animated via sin + +// Configurable parameters (from render settings) +uniform float uHighlightRadiusSq; // tile highlight radius squared +uniform float uHighlightAlpha; // tile highlight opacity +uniform vec4 uSelfRadii; // (minR, maxR, _, _) +uniform vec4 uMateRadii; // (minR, maxR, _, _) +uniform vec2 uGradientStops; // (innerEdge, solidEnd) + +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); + bool unowned = (owner == 0u); + + vec4 result = vec4(0.0); + + for (int i = 0; i < MAX_SPAWNS; i++) { + if (i >= uSpawnCount) break; + + vec2 center = uSpawnA[i].xy; + vec3 color = vec3(uSpawnA[i].zw, uSpawnB[i].x); + float isSelf = uSpawnB[i].y; + float isTeammate = uSpawnB[i].z; + + float dx = vWorldPos.x - center.x; + float dy = vWorldPos.y - center.y; + float distSq = dx * dx + dy * dy; + float dist = sqrt(distSq); + + // --- Tile highlights (not for self or teammates) --- + if (isSelf < 0.5 && isTeammate < 0.5 && unowned && distSq <= uHighlightRadiusSq) { + float a = uHighlightAlpha; + result.rgb = mix(result.rgb, color, a * (1.0 - result.a)); + result.a = result.a + a * (1.0 - result.a); + } + + // --- Breathing rings (self or teammate only) --- + float minR, maxR; + if (isSelf > 0.5) { + minR = uSelfRadii.x; + maxR = uSelfRadii.y; + } else if (isTeammate > 0.5) { + minR = uMateRadii.x; + maxR = uMateRadii.y; + } else { + continue; + } + + // Static outer ring: radial gradient from minR to maxR + float range = maxR - minR; + float t = (dist - minR) / range; + if (t > 0.0 && t <= 1.0) { + float innerEdge = uGradientStops.x; + float solidEnd = uGradientStops.y; + float alpha; + if (t < innerEdge) { + alpha = t / innerEdge; + } else if (t < solidEnd) { + alpha = 1.0; + } else { + alpha = 1.0 - (t - solidEnd) / (1.0 - solidEnd); + } + + result.rgb = mix(result.rgb, color, alpha * (1.0 - result.a)); + result.a = result.a + alpha * (1.0 - result.a); + } + + // Breathing ring: solid colored disc from minR to breathR + float breathR = minR + range * uBreathRadius; + if (breathR > minR + 0.01) { + if (dist >= minR && dist <= breathR) { + float edge = smoothstep(minR, minR + 0.1, dist); + result.rgb = color; + result.a = max(result.a, edge); + } + } + } + + if (result.a < 0.001) discard; + // result is premultiplied; convert to straight for SRC_ALPHA blending + fragColor = vec4(result.rgb / result.a, result.a); +} diff --git a/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl b/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl new file mode 100644 index 000000000..c4127fbdb --- /dev/null +++ b/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl @@ -0,0 +1,50 @@ +#version 300 es +precision highp float; + +uniform sampler2D uAtlas; +uniform float uDistRange; +uniform float uOutlineWidth; +uniform int uHighlightMask; +uniform float uHighlightDimAlpha; + +in vec2 vUV; +flat in float vAlive; +flat in float vAtlasIdx; +out vec4 fragColor; + +float median(float r, float g, float b) { + return max(min(r, g), min(max(r, g), b)); +} + +void main() { + if (vAlive <= 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); + + // White text with dark outline + float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0); + float effectiveOutline = min(uOutlineWidth, maxOutline); + float outlineDist = screenPxDist + effectiveOutline; + float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0); + + vec3 color = mix(vec3(0.0), vec3(1.0), fillAlpha); + float finalAlpha = outlineAlpha; + + // Dim level text for non-highlighted structure types + if (uHighlightMask != 0) { + int bit = 1 << int(vAtlasIdx + 0.5); + if ((uHighlightMask & bit) == 0) { + finalAlpha *= uHighlightDimAlpha; + } + } + + fragColor = vec4(color, finalAlpha); +} diff --git a/src/client/render/gl/shaders/structure-level/structure-level.vert.glsl b/src/client/render/gl/shaders/structure-level/structure-level.vert.glsl new file mode 100644 index 000000000..27ef723ca --- /dev/null +++ b/src/client/render/gl/shaders/structure-level/structure-level.vert.glsl @@ -0,0 +1,96 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +// Per-instance: worldX, worldY, cursorX, charCode +layout(location = 1) in vec4 aInst; +layout(location = 2) in float aAtlasIdx; + +uniform sampler2D uGlyphMetrics; // CHAR_RANGE x 2, RGBA32F + +uniform mat3 uCamera; +uniform float uZoom; + +// Structure icon sizing (mirrors structure.vert.glsl) +uniform float uIconSize; +uniform float uDotsThreshold; +uniform float uScaleFactor; + +// Text sizing +uniform float uFontSize; +uniform float uAtlasScaleH; +uniform float uBase; +uniform float uLevelScale; + +out vec2 vUV; +flat out float vAlive; +flat out float vAtlasIdx; + +void main() { + float worldX = aInst.x; + float worldY = aInst.y; + float cursorX = aInst.z; + int charCode = int(aInst.w); + + // Same icon scale logic as structure.vert.glsl + float iconScale; + if (uZoom <= uDotsThreshold) { + iconScale = 0.0; // hidden in dots mode + } else { + iconScale = min(1.0, uZoom / uScaleFactor); + } + + // Cull when invisible + if (iconScale <= 0.0 || charCode == 0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vAlive = 0.0; + return; + } + vAlive = 1.0; + vAtlasIdx = aAtlasIdx; + + float halfIconSize = uIconSize * iconScale * 0.5 / uZoom; + + // Level text scale: proportional to icon size + float levelScale = halfIconSize * uLevelScale / uFontSize; + + // Glyph metrics from data texture + vec4 m0 = texelFetch(uGlyphMetrics, ivec2(charCode, 0), 0); // xadvance, xoffset, yoffset, width + vec4 m1 = texelFetch(uGlyphMetrics, ivec2(charCode, 1), 0); // height, u0, v0, u1 + + 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; + + // Skip degenerate glyphs + if (glyphW <= 0.0 || glyphH <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vAlive = 0.0; + return; + } + + // Position above icon center + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + float baselineY = -uBase * 0.5; + float yOff = -halfIconSize - levelScale * uBase * 0.6; // above icon top edge + + vec2 glyphOrigin = vec2( + cursorX + m0.y, // + xoffset + baselineY + m0.z // + yoffset + ) * levelScale; + + vec2 glyphSize = vec2(glyphW, glyphH) * levelScale; + + vec2 worldPos = center + vec2(0.0, yOff) + 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)); +} diff --git a/src/client/render/gl/shaders/structure/structure.frag.glsl b/src/client/render/gl/shaders/structure/structure.frag.glsl new file mode 100644 index 000000000..677bb3b84 --- /dev/null +++ b/src/client/render/gl/shaders/structure/structure.frag.glsl @@ -0,0 +1,168 @@ +#version 300 es +precision highp float; + +uniform sampler2D uPalette; +uniform sampler2D uAtlas; +uniform sampler2D uAffiliation; // 256×2 RGBA8 — row 1 = unit affiliation +uniform float uDotsThreshold; +uniform float uGhostAlpha; // 1.0 = normal, <1.0 = ghost transparency +uniform vec3 uOutlineColor; // ghost outline color (vec3(0) = no outline) +uniform int uAltView; +uniform int uHighlightMask; // bitmask of atlas columns to highlight (0 = off) +uniform float uHighlightOutlineW; // outline width for highlighted structures +uniform float uHighlightDimAlpha; // alpha multiplier for non-highlighted structures + +in vec2 vLocalPos; +in vec2 vAtlasUV; +flat in float vOwnerID; +flat in float vUnderConstruction; +flat in float vMarkedForDeletion; +flat in float vZoom; +flat in float vAtlasIdx; +flat in float vShapeScale; + +out vec4 fragColor; + +vec3 rgb2hsv(vec3 c) { + vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} + +vec3 hsv2rgb(vec3 c) { + vec3 p = abs(fract(c.xxx + vec3(1.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0); + return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +vec3 darken(vec3 rgb, float vScale) { + vec3 hsv = rgb2hsv(rgb); + hsv.z *= vScale; + return hsv2rgb(hsv); +} + +#define PI 3.14159265 + +// Signed distance to regular polygon edge. +// R = circumradius (center-to-vertex), n = sides, rot = rotation in radians. +// Returns negative inside, positive outside. +float sdPolygon(vec2 p, float R, float n, float rot) { + float an = PI / n; + float a = atan(p.y, p.x) - rot; + a = mod(a + an, 2.0 * an) - an; + return length(p) * cos(a) - R * cos(an); +} + +// Per-structure-type shape SDF. +// Atlas indices: 0=City, 1=Port, 2=Factory, 3=DefensePost, 4=SAM, 5=Silo +float shapeSDF(vec2 p, float R) { + if (vAtlasIdx < 0.5 || (vAtlasIdx > 1.5 && vAtlasIdx < 2.5)) + return length(p) - R; // City / Factory → circle + if (vAtlasIdx < 1.5) + return sdPolygon(p, R, 5.0, PI * 0.5); // Port → pentagon (vertex up) + if (vAtlasIdx < 3.5) + return sdPolygon(p, R, 8.0, 0.0); // Defense Post → octagon (flat top) + if (vAtlasIdx < 4.5) + return sdPolygon(p, R, 4.0, 0.0); // SAM Launcher → square (flat sides) + return sdPolygon(p, R, 3.0, PI * 0.5); // Missile Silo → triangle (vertex up) +} + +void main() { + float dist = length(vLocalPos); + float radius = 0.45; + float borderWidth = 0.06 / vShapeScale; + + float sdf = shapeSDF(vLocalPos, radius); + float fw = fwidth(dist); + + // When highlight is active, expand the region to include the outer outline band. + float highlightOutlineW = uHighlightMask != 0 ? uHighlightOutlineW / vShapeScale : 0.0; + float outerAlpha = 1.0 - smoothstep(-fw, fw, sdf - highlightOutlineW); + + if (outerAlpha <= 0.0) discard; + + float borderMask = 1.0 - smoothstep(-fw, fw, sdf + borderWidth); + + // Player color + vec4 fillColor; + vec4 borderColor; + + if (uAltView != 0 && vUnderConstruction < 0.5) { + vec3 ac = texelFetch(uAffiliation, ivec2(int(vOwnerID), 1), 0).rgb; + fillColor = vec4(darken(ac, 0.65), 1.0); + borderColor = vec4(darken(ac, 0.35), 1.0); + } else if (vUnderConstruction > 0.5) { + fillColor = vec4(198.0/255.0, 198.0/255.0, 198.0/255.0, 1.0); + borderColor = vec4(127.0/255.0, 127.0/255.0, 127.0/255.0, 1.0); + } else { + float u = (vOwnerID + 0.5) / float(PALETTE_SIZE); + fillColor = texture(uPalette, vec2(u, 0.25)); + borderColor = texture(uPalette, vec2(u, 0.75)); + // Darken via HSV value so hue/saturation stay intact + // vScale < 1.0 = darker, > 1.0 = brighter + fillColor.rgb = darken(fillColor.rgb, 0.65); + borderColor.rgb = darken(borderColor.rgb, 0.35); + fillColor.a = 1.0; + borderColor.a = 1.0; + } + + vec4 bgColor = mix(borderColor, fillColor, borderMask); + + // Sample icon from atlas (white on transparent) + // Only show icon detail when zoomed in enough + float iconAlpha = 0.0; + if (vZoom > uDotsThreshold) { + // Clamp UV to this atlas column to prevent bleeding into neighbours + // when uIconFill shrinks the icon (expanding UV range beyond column). + float colStart = vAtlasIdx / float(ATLAS_COLS); + float colEnd = (vAtlasIdx + 1.0) / float(ATLAS_COLS); + vec2 safeUV = vec2(clamp(vAtlasUV.x, colStart, colEnd), clamp(vAtlasUV.y, 0.0, 1.0)); + vec4 iconSample = texture(uAtlas, safeUV); + // Zero out icon outside the valid UV region (clamped pixels would repeat the edge) + float inBounds = step(colStart, vAtlasUV.x) * step(vAtlasUV.x, colEnd) + * step(0.0, vAtlasUV.y) * step(vAtlasUV.y, 1.0); + // Clip to fill area so icon doesn't bleed into the border ring. + iconAlpha = iconSample.a * borderMask * inBounds; + } + + // Composite: white icon over player-colored shape + vec3 finalRGB = mix(bgColor.rgb, vec3(1.0), iconAlpha); + + // Red X overlay for units marked for deletion + if (vMarkedForDeletion > 0.5) { + float lineW = max(0.025, fw * 1.5); + float d1 = abs(vLocalPos.x - vLocalPos.y) * 0.7071; // dist to y=x diagonal + float d2 = abs(vLocalPos.x + vLocalPos.y) * 0.7071; // dist to y=-x diagonal + float dMin = min(d1, d2); + // Extend arms close to the circle edge + float maskR = max(radius * 1.55, fw * 6.0); + float mask = 1.0 - smoothstep(maskR - fw, maskR, dist); + float xLine = (1.0 - smoothstep(lineW - fw, lineW + fw, dMin)) * mask; + finalRGB = mix(finalRGB, vec3(1.0, 0.25, 0.25), xLine * 0.95); + } + + // Ghost tint — blend entire surface toward uOutlineColor when non-zero + float tintActive = step(0.01, dot(uOutlineColor, uOutlineColor)); + finalRGB = mix(finalRGB, uOutlineColor, tintActive * 0.5); + + float finalAlpha = bgColor.a * outerAlpha * uGhostAlpha; + + // Build-button hover highlight: white outline on matching types, dim the rest + if (uHighlightMask != 0) { + int bit = 1 << int(vAtlasIdx + 0.5); + if ((uHighlightMask & bit) != 0) { + // White outline band outside the shape edge (matches game's OutlineFilter) + float shapeEdge = 1.0 - smoothstep(-fw, fw, sdf); // 1 inside shape, 0 outside + float expandedEdge = 1.0 - smoothstep(-fw, fw, sdf - highlightOutlineW); // includes outline band + float outlineBand = expandedEdge - shapeEdge; // 1 in outline region only + finalRGB = mix(finalRGB, vec3(1.0), outlineBand); + finalAlpha = max(finalAlpha, outlineBand); + } else { + finalAlpha *= uHighlightDimAlpha; + } + } + + fragColor = vec4(finalRGB, finalAlpha); +} diff --git a/src/client/render/gl/shaders/structure/structure.vert.glsl b/src/client/render/gl/shaders/structure/structure.vert.glsl new file mode 100644 index 000000000..a0b1a6d75 --- /dev/null +++ b/src/client/render/gl/shaders/structure/structure.vert.glsl @@ -0,0 +1,65 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +// Per-instance: x, y, ownerID, underConstruction, atlasIdx, markedForDeletion +layout(location = 1) in vec4 aInst0; // x, y, ownerID, underConstruction +layout(location = 2) in vec2 aInst1; // atlasIdx, markedForDeletion + +uniform mat3 uCamera; +uniform float uZoom; + +uniform float uIconSize; +uniform float uDotsThreshold; +uniform float uScaleFactor; +uniform float uShapeScales[ATLAS_COLS]; +uniform float uIconFills[ATLAS_COLS]; + +out vec2 vLocalPos; +out vec2 vAtlasUV; +flat out float vOwnerID; +flat out float vUnderConstruction; +flat out float vMarkedForDeletion; +flat out float vZoom; +flat out float vAtlasIdx; +flat out float vShapeScale; + +void main() { + float worldX = aInst0.x; + float worldY = aInst0.y; + vOwnerID = aInst0.z; + vUnderConstruction = aInst0.w; + vMarkedForDeletion = aInst1.y; + vZoom = uZoom; + vAtlasIdx = aInst1.x; + + float iconScale; + if (uZoom <= uDotsThreshold) { + iconScale = 1.0 / 2.5; + } else { + iconScale = min(1.0, uZoom / uScaleFactor); + } + + int shapeIdx = int(aInst1.x); + float shapeScale = uShapeScales[shapeIdx]; + vShapeScale = shapeScale; + + float halfSize = uIconSize * iconScale * 0.5 / uZoom * shapeScale; + + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + vec2 worldPos = center + (aPos - 0.5) * halfSize * 2.0; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = aPos - 0.5; + + // Atlas UV: icons stay the same world size regardless of shape scaling, + // and are further shrunk by per-shape iconFill (0-1) to add padding inside the frame. + float uvExpand = shapeScale / uIconFills[shapeIdx]; + float scaledX = 0.5 + (aPos.x - 0.5) * uvExpand; + float scaledY = 0.5 + (aPos.y - 0.5) * uvExpand; + float colU = (aInst1.x + scaledX) / float(ATLAS_COLS); + vAtlasUV = vec2(colU, scaledY); +} diff --git a/src/client/render/gl/shaders/terrain/terrain.frag.glsl b/src/client/render/gl/shaders/terrain/terrain.frag.glsl new file mode 100644 index 000000000..869bfff50 --- /dev/null +++ b/src/client/render/gl/shaders/terrain/terrain.frag.glsl @@ -0,0 +1,11 @@ +#version 300 es +precision highp float; + +uniform sampler2D uTerrain; + +in vec2 vUV; +out vec4 fragColor; + +void main() { + fragColor = texture(uTerrain, vUV); +} diff --git a/src/client/render/gl/shaders/terrain/terrain.vert.glsl b/src/client/render/gl/shaders/terrain/terrain.vert.glsl new file mode 100644 index 000000000..a712bada2 --- /dev/null +++ b/src/client/render/gl/shaders/terrain/terrain.vert.glsl @@ -0,0 +1,17 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; + +out vec2 vUV; + +void main() { + vec3 clip = uCamera * vec3(aPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // aPos ranges [0, mapW] × [0, mapH] — normalize to [0,1] UV + // (mapSize is baked into the quad vertices, so we pass it as a uniform) + vUV = aPos / vec2(float(MAP_W), float(MAP_H)); +} diff --git a/src/client/render/gl/shaders/unit/unit.frag.glsl b/src/client/render/gl/shaders/unit/unit.frag.glsl new file mode 100644 index 000000000..266708233 --- /dev/null +++ b/src/client/render/gl/shaders/unit/unit.frag.glsl @@ -0,0 +1,93 @@ +#version 300 es +precision highp float; + +uniform sampler2D uPalette; +uniform sampler2D uAtlas; +uniform sampler2D uAffiliation; // 256×2 RGBA8 — row 1 = unit affiliation +uniform float uTick; +uniform float uFlickerSpeed; +uniform vec3 uAngryColor; +uniform int uAltView; + +in vec2 vLocalPos; +in vec2 vAtlasUV; +flat in float vOwnerID; +flat in float vFlags; +flat in float vHash; + +out vec4 fragColor; + +// Flag constants — must match CPU-side FLAG_* values +const float FLAG_FLICKER = 1.0; +const float FLAG_ANGRY = 2.0; +const float FLAG_TRADE_FRIENDLY = 3.0; + +// Ally color for trade-friendly override (yellow — matches affiliation.ts ALLY) +const vec3 ALLY_COLOR = vec3(1.0, 1.0, 0.0); + +// Flicker hot colors: red → orange → yellow → white +const vec3 FLICKER_COLORS[4] = vec3[4]( + vec3(1.0, 0.0, 0.0), // red + vec3(1.0, 0.5, 0.0), // orange + vec3(1.0, 1.0, 0.0), // yellow + vec3(1.0, 1.0, 1.0) // white +); + +void main() { + vec4 texel = texture(uAtlas, vAtlasUV); + + // Discard fully transparent pixels + if (texel.a < 0.01) discard; + + float gray = texel.r; + + // Alt-view: solid affiliation color, no gray-replacement bands + if (uAltView != 0) { + // Enemy trade ships heading to a self/allied port render as yellow (ally) + vec3 ac = vFlags > 2.5 + ? ALLY_COLOR + : texelFetch(uAffiliation, ivec2(int(vOwnerID), 1), 0).rgb; + fragColor = vec4(ac, texel.a); + return; + } + + // Player color lookup from palette + float u = (vOwnerID + 0.5) / float(PALETTE_SIZE); + vec3 territoryColor = texture(uPalette, vec2(u, 0.25)).rgb; + vec3 borderColor = texture(uPalette, vec2(u, 0.75)).rgb; + + // Flag states (uint8 passed as float via vertex attribute): + // 0 = normal + // 1 = flicker (nukes/warheads — cycling hot colors) + // 2 = angry (warships attacking — solid red territory band) + if (vFlags > 1.5) { + // Angry: solid red territory band + territoryColor = uAngryColor; + } else if (vFlags > 0.5) { + // Flicker: cycle through hot colors, offset by position hash + float phase = fract(uTick * uFlickerSpeed + vHash); + int idx = int(phase * 4.0) % 4; + territoryColor = FLICKER_COLORS[idx]; + borderColor = FLICKER_COLORS[(idx + 2) % 4]; + } + + // Three-band gray replacement: + // 180/255 ~ 0.706 -> territory color (light band) + // 130/255 ~ 0.510 -> spawn/mid color (interpolated) + // 70/255 ~ 0.275 -> border color (dark band) + vec3 spawnColor = mix(territoryColor, borderColor, 0.5); + + vec3 color; + if (gray > 0.6) { + // Light band (180) -> territory color + color = territoryColor; + } else if (gray > 0.4) { + // Mid band (130) -> spawn color + color = spawnColor; + } else { + // Dark band (70) -> border color + color = borderColor; + } + + fragColor = vec4(color, texel.a); +} diff --git a/src/client/render/gl/shaders/unit/unit.vert.glsl b/src/client/render/gl/shaders/unit/unit.vert.glsl new file mode 100644 index 000000000..640734d98 --- /dev/null +++ b/src/client/render/gl/shaders/unit/unit.vert.glsl @@ -0,0 +1,46 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +// Per-instance attributes +layout(location = 1) in vec3 aInstPos; // x, y, ownerID +layout(location = 2) in vec2 aInstFlags; // atlasIdx (uint8→float), flags (uint8→float) + +uniform mat3 uCamera; + +uniform float uUnitSize; + +out vec2 vLocalPos; +out vec2 vAtlasUV; +flat out float vOwnerID; +flat out float vFlags; // 0.0 = normal, 1.0 = flicker, 2.0 = angry +flat out float vHash; // per-instance hash for flicker phase offset + +void main() { + float worldX = aInstPos.x; + float worldY = aInstPos.y; + vOwnerID = aInstPos.z; + + float atlasCol = aInstFlags.x; + vFlags = aInstFlags.y; + + // Position-based hash so each unit flickers independently + vHash = fract(worldX * 0.1731 + worldY * 0.3179); + + // UNIT_SIZE is in world-space tiles — no zoom division needed. + // Units scale with the map like territory tiles do. + float halfSize = uUnitSize * 0.5; + + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + vec2 worldPos = center + (aPos - 0.5) * halfSize * 2.0; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = aPos; + + // Atlas UV: map quad [0,1] to the correct column + float colU = (atlasCol + aPos.x) / float(ATLAS_COLS); + vAtlasUV = vec2(colU, aPos.y); +} diff --git a/src/client/render/gl/utils/affiliation.ts b/src/client/render/gl/utils/affiliation.ts new file mode 100644 index 000000000..1dbda6b05 --- /dev/null +++ b/src/client/render/gl/utils/affiliation.ts @@ -0,0 +1,171 @@ +/** + * Affiliation palette — maps ownerID → affiliation color for alt-view. + * + * TEX_W×2 RGBA8 texture (TEX_W = PALETTE_SIZE = 4096): + * Row 0: border colors (4-state: self/ally/neutral/embargo) + * Row 1: unit colors (3-state: self/ally/enemy) + * + * Rebuilt when localPlayerID or relationship data changes. + */ + +import { getPaletteSize } from "./color-utils"; +import { createTexture2D } from "./gl-utils"; + +// Relationship constants (must match adapter.ts) +const RELATION_NEUTRAL = 0; +const RELATION_FRIENDLY = 1; +const RELATION_EMBARGO = 2; + +// Affiliation RGB values (upstream PastelTheme) +const SELF_R = 0, + SELF_G = 255, + SELF_B = 0; +const ALLY_R = 255, + ALLY_G = 255, + ALLY_B = 0; +const NEUTRAL_R = 128, + NEUTRAL_G = 128, + NEUTRAL_B = 128; +const ENEMY_R = 255, + ENEMY_G = 0, + ENEMY_B = 0; + +const TEX_W = getPaletteSize(); // 4096 — covers full 12-bit smallID range +const TEX_H = 2; + +export class AffiliationPalette { + private gl: WebGL2RenderingContext; + private tex: WebGLTexture; + private cpuData = new Uint8Array(TEX_W * TEX_H * 4); + private dirty = false; + + // Cached inputs for rebuilding + private localPlayerID = 0; + private relationData: Uint8Array | null = null; + private relationSize = 0; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.rebuild(); // initialize to spectator-mode defaults (gray borders, red units) + this.tex = createTexture2D(gl, { + width: TEX_W, + height: TEX_H, + internalFormat: gl.RGBA8, + format: gl.RGBA, + type: gl.UNSIGNED_BYTE, + data: this.cpuData, + filter: gl.NEAREST, + }); + this.dirty = false; // already baked into initial upload + } + + getTexture(): WebGLTexture { + return this.tex; + } + + setLocalPlayer(id: number): void { + if (id === this.localPlayerID) return; + this.localPlayerID = id; + this.rebuild(); + } + + updateRelations(data: Uint8Array, size: number): void { + this.relationData = data; + this.relationSize = size; + this.rebuild(); + } + + /** Flush to GPU if dirty (call before drawing alt-view passes). */ + flush(): void { + if (!this.dirty) return; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.tex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + TEX_W, + TEX_H, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.cpuData, + ); + this.dirty = false; + } + + private rebuild(): void { + const d = this.cpuData; + const lp = this.localPlayerID; + const rel = this.relationData; + const rs = this.relationSize; + + for (let owner = 0; owner < TEX_W; owner++) { + // Determine relationship + let relation = RELATION_NEUTRAL; + if (rel && lp > 0 && owner > 0 && owner < rs && lp < rs) { + relation = rel[lp * rs + owner]; + } + const isSelf = owner > 0 && owner === lp; + + // Row 0: border colors (4-state) + const bOff = owner * 4; + if (owner === 0) { + d[bOff] = 0; + d[bOff + 1] = 0; + d[bOff + 2] = 0; + d[bOff + 3] = 0; + } else if (isSelf) { + d[bOff] = SELF_R; + d[bOff + 1] = SELF_G; + d[bOff + 2] = SELF_B; + d[bOff + 3] = 255; + } else if (relation === RELATION_FRIENDLY) { + d[bOff] = ALLY_R; + d[bOff + 1] = ALLY_G; + d[bOff + 2] = ALLY_B; + d[bOff + 3] = 255; + } else if (relation === RELATION_EMBARGO) { + d[bOff] = ENEMY_R; + d[bOff + 1] = ENEMY_G; + d[bOff + 2] = ENEMY_B; + d[bOff + 3] = 255; + } else { + d[bOff] = NEUTRAL_R; + d[bOff + 1] = NEUTRAL_G; + d[bOff + 2] = NEUTRAL_B; + d[bOff + 3] = 255; + } + + // Row 1: unit colors (3-state — no neutral, neutral→enemy) + const uOff = (TEX_W + owner) * 4; + if (owner === 0) { + d[uOff] = 0; + d[uOff + 1] = 0; + d[uOff + 2] = 0; + d[uOff + 3] = 0; + } else if (isSelf) { + d[uOff] = SELF_R; + d[uOff + 1] = SELF_G; + d[uOff + 2] = SELF_B; + d[uOff + 3] = 255; + } else if (relation === RELATION_FRIENDLY) { + d[uOff] = ALLY_R; + d[uOff + 1] = ALLY_G; + d[uOff + 2] = ALLY_B; + d[uOff + 3] = 255; + } else { + d[uOff] = ENEMY_R; + d[uOff + 1] = ENEMY_G; + d[uOff + 2] = ENEMY_B; + d[uOff + 3] = 255; + } + } + + this.dirty = true; + } + + dispose(): void { + this.gl.deleteTexture(this.tex); + } +} diff --git a/src/client/render/gl/utils/color-utils.ts b/src/client/render/gl/utils/color-utils.ts new file mode 100644 index 000000000..68b008c45 --- /dev/null +++ b/src/client/render/gl/utils/color-utils.ts @@ -0,0 +1,90 @@ +/** + * GPU-ready color utilities. + * + * Terrain RGBA: Uint8Array(w × h × 4) — one RGBA pixel per tile, computed + * from PastelTheme rules applied to the raw terrain byte layout. + * + * Player palette is NOT built here — consumers provide a pre-built + * Float32Array(PALETTE_SIZE × 2 × 4) to the GPURenderer constructor. + */ + +/** Must cover 12-bit smallID range (0-4095). */ +const PALETTE_SIZE = 4096; + +export function getPaletteSize(): number { + return PALETTE_SIZE; +} + +// ---------- Terrain ---------- + +/** + * Compute a static RGBA8 texture from raw terrain bytes. + * Replicates PastelTheme.terrainColor() on the CPU. + * + * Terrain byte layout per tile: + * bit 7: isLand + * bit 6: isShoreline + * bit 5: isOcean (water only) + * bits 0-4: magnitude (0-31) + */ +export function buildTerrainRGBA( + terrainBytes: Uint8Array, + w: number, + h: number, +): Uint8Array { + const pixels = new Uint8Array(w * h * 4); + + for (let i = 0; i < w * h; i++) { + const tb = terrainBytes[i]; + const isLand = (tb & 0x80) !== 0; + const isShoreline = (tb & 0x40) !== 0; + const magnitude = tb & 0x1f; + + let r: number, g: number, b: number; + + if (isLand && isShoreline) { + // Shore (sand) + r = 204; + g = 203; + b = 158; + } else if (isLand) { + if (magnitude < 10) { + // Plains + r = 190; + g = 220 - 2 * magnitude; + b = 138; + } else if (magnitude < 20) { + // Highland + r = 200 + 2 * magnitude; + g = 183 + 2 * magnitude; + b = 138 + 2 * magnitude; + } else { + // Mountain + const v = Math.min(255, 230 + Math.floor(magnitude / 2)); + r = v; + g = v; + b = v; + } + } else if (isShoreline) { + // Shoreline water + r = 100; + g = 143; + b = 255; + } else { + // Deep water + const m = Math.min(magnitude, 10); + const offset = 11 - m; + r = Math.max(0, 70 - 10 + offset); + g = Math.max(0, 132 - 10 + offset); + b = Math.max(0, 180 - 10 + offset); + } + + const off = i * 4; + pixels[off] = r; + pixels[off + 1] = g; + pixels[off + 2] = b; + pixels[off + 3] = 255; + } + + return pixels; +} diff --git a/src/client/render/gl/utils/gl-utils.ts b/src/client/render/gl/utils/gl-utils.ts new file mode 100644 index 000000000..53b59abc0 --- /dev/null +++ b/src/client/render/gl/utils/gl-utils.ts @@ -0,0 +1,209 @@ +/** + * WebGL2 utility functions: shader compilation, texture creation, VAO helpers. + */ + +export function compileShader( + gl: WebGL2RenderingContext, + type: number, + source: string, +): WebGLShader { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader) ?? ""; + gl.deleteShader(shader); + throw new Error(`Shader compile error:\n${log}`); + } + return shader; +} + +export function createProgram( + gl: WebGL2RenderingContext, + vertSrc: string, + fragSrc: string, +): WebGLProgram { + const vs = compileShader(gl, gl.VERTEX_SHADER, vertSrc); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc); + const program = gl.createProgram()!; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(program) ?? ""; + gl.deleteProgram(program); + throw new Error(`Program link error:\n${log}`); + } + return program; +} + +export interface TextureOpts { + width: number; + height: number; + internalFormat: number; + format: number; + type: number; + data: ArrayBufferView | null; + filter?: number; + wrap?: number; +} + +export function createTexture2D( + gl: WebGL2RenderingContext, + opts: TextureOpts, +): WebGLTexture { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + opts.filter ?? gl.NEAREST, + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MAG_FILTER, + opts.filter ?? gl.NEAREST, + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_S, + opts.wrap ?? gl.CLAMP_TO_EDGE, + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_T, + opts.wrap ?? gl.CLAMP_TO_EDGE, + ); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + opts.internalFormat, + opts.width, + opts.height, + 0, + opts.format, + opts.type, + opts.data, + ); + return tex; +} + +/** + * Create a VAO with a quad covering [0,0]→[mapWidth, mapHeight] in world coords. + * Two triangles, positions only. Attribute location 0. + */ +/** + * Create a VAO with a [0,1]² fullscreen quad. Two triangles, positions only. + * Attribute location 0. Used for post-process passes (blur, composite, etc.). + */ +export function createFullscreenQuad( + gl: WebGL2RenderingContext, +): WebGLVertexArrayObject { + const vao = gl.createVertexArray()!; + gl.bindVertexArray(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); + return vao; +} + +/** + * Inject `#define` constants into a GLSL shader source string. + * Inserts definitions immediately after the `#version` line. + * + * Usage: + * shaderSrc(blurFrag, { PALETTE_SIZE: 4096 }) + * // → "#version 300 es\n#define PALETTE_SIZE 4096\n..." + */ +export function shaderSrc( + source: string, + defines: Record, +): string { + const defs = Object.entries(defines) + .map(([k, v]) => `#define ${k} ${v}`) + .join("\n"); + return source.replace("#version 300 es", `#version 300 es\n${defs}`); +} + +export interface RenderTarget { + fbo: WebGLFramebuffer; + tex: WebGLTexture; + w: number; + h: number; +} + +/** + * Bind a render target FBO, set viewport, clear, run draw callback, then + * restore the default framebuffer. Returns the target texture for chaining. + */ +export function toTarget( + gl: WebGL2RenderingContext, + target: RenderTarget, + draw: () => void, +): WebGLTexture { + gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo); + gl.viewport(0, 0, target.w, target.h); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + draw(); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return target.tex; +} + +/** + * Bind the screen (default framebuffer), set viewport, run draw callback. + */ +export function toScreen( + gl: WebGL2RenderingContext, + w: number, + h: number, + draw: () => void, +): void { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, w, h); + draw(); +} + +export function createMapQuad( + gl: WebGL2RenderingContext, + mapWidth: number, + mapHeight: number, +): WebGLVertexArrayObject { + const vao = gl.createVertexArray()!; + gl.bindVertexArray(vao); + + const positions = new Float32Array([ + 0, + 0, + mapWidth, + 0, + 0, + mapHeight, + 0, + mapHeight, + mapWidth, + 0, + mapWidth, + mapHeight, + ]); + + const buf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + gl.bindVertexArray(null); + return vao; +} diff --git a/src/client/render/gl/utils/gpu-resources.ts b/src/client/render/gl/utils/gpu-resources.ts new file mode 100644 index 000000000..7f86060ba --- /dev/null +++ b/src/client/render/gl/utils/gpu-resources.ts @@ -0,0 +1,78 @@ +/** + * GPUResources — shared GPU textures created once, passed to all passes. + * + * Eliminates getter chains, setBorderTex/setHeatTex late-wiring, and + * construction-order dependencies between passes. + */ + +import { createTexture2D } from "./gl-utils"; + +export interface GPUResources { + tileTex: WebGLTexture; // R16UI — tile ownership + flags + trailTex: WebGLTexture; // R8UI — trail owner per tile + paletteTex: WebGLTexture; // RGBA32F — player colors + borderTex: WebGLTexture; // RGBA8 — border type + ember + defense + heatTexA: WebGLTexture; // R8 — fallout heat ping-pong A + heatTexB: WebGLTexture; // R8 — fallout heat ping-pong B +} + +export function createGPUResources( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + paletteTex: WebGLTexture, + borderTex: WebGLTexture, +): GPUResources { + const tileTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R16UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_SHORT, + data: null, + filter: gl.NEAREST, + }); + + const trailTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + const heatTexA = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8, + format: gl.RED, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + const heatTexB = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8, + format: gl.RED, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + return { tileTex, trailTex, paletteTex, borderTex, heatTexA, heatTexB }; +} + +export function disposeGPUResources( + gl: WebGL2RenderingContext, + res: GPUResources, +): void { + gl.deleteTexture(res.tileTex); + gl.deleteTexture(res.trailTex); + // paletteTex and borderTex are owned by renderer and BorderComputePass respectively + gl.deleteTexture(res.heatTexA); + gl.deleteTexture(res.heatTexB); +} diff --git a/src/client/render/gl/utils/heat-manager.ts b/src/client/render/gl/utils/heat-manager.ts new file mode 100644 index 000000000..0a86230a5 --- /dev/null +++ b/src/client/render/gl/utils/heat-manager.ts @@ -0,0 +1,319 @@ +/** + * HeatManager — GPU-side fallout heat decay and transition detection. + * + * Extracted from FalloutBloomPass. Owns the heat ping-pong textures, the + * previous-tile-state snapshot, and the combined transition+decay shader. + * + * Used by both FalloutBloomPass (bloom extract reads heat) and LightmapPass + * (fallout light reads heat). Shared heat textures come from GPUResources. + */ + +import type { RenderSettings } from "../render-settings"; +import { + createFullscreenQuad, + createProgram, + createTexture2D, + shaderSrc, +} from "./gl-utils"; +import { FALLOUT_BIT, TILE_DEFINES } from "./tile-codec"; + +import heatDecayFragSrc from "../shaders/fallout-bloom/heat-decay.frag.glsl?raw"; +import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw"; + +export class HeatManager { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + private tileTex: WebGLTexture; + + // Heat ping-pong (R8, per-tile: 255=fresh, decays toward 0) + private heatTexA: WebGLTexture; + private heatTexB: WebGLTexture; + private heatFboA: WebGLFramebuffer; + private heatFboB: WebGLFramebuffer; + /** 0 = read A / write B, 1 = read B / write A */ + private heatCurrent = 0; + + // Previous tile state (R16UI) — GPU-side snapshot for transition detection + private prevTileTex: WebGLTexture; + private prevTileFbo: WebGLFramebuffer; + private tileTexReadFbo: WebGLFramebuffer; + /** True on first frame and after seek — blit tileTex→prevTileTex without transitions. */ + private needsPrevTileCopy = true; + + // Pending CPU → GPU writes + private pendingDecay = 0; + private pendingFullHeat: Uint8Array | null = null; + /** + * True when heat may be non-zero anywhere — gates the decay pass. + * Set true on each game tick (shader may detect new fallout transitions). + * Set false once accumulated decay since last activation exceeds 255 (fully drained). + */ + private heatActive = false; + /** Accumulated decay since heatActive was last set true. */ + private decayAccumulated = 0; + + // Decay program + private decayProg: WebGLProgram; + private uDecayMapSize: WebGLUniformLocation; + private uDecayAmount: WebGLUniformLocation; + + // Geometry + private quadVao: WebGLVertexArrayObject; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + heatTexA: WebGLTexture, + heatTexB: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.heatTexA = heatTexA; + this.heatTexB = heatTexB; + + this.heatFboA = this.createFboFor(heatTexA); + this.heatFboB = this.createFboFor(heatTexB); + + // Previous tile state texture (R16UI, for GPU transition detection) + this.prevTileTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R16UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_SHORT, + data: null, + filter: gl.NEAREST, + }); + this.prevTileFbo = this.createFboFor(this.prevTileTex); + this.tileTexReadFbo = this.createFboFor(tileTex); + + // Decay program (tile-space, combined transition + decay) + this.decayProg = createProgram( + gl, + fullscreenNoUvVertSrc, + shaderSrc(heatDecayFragSrc, TILE_DEFINES), + ); + this.uDecayMapSize = gl.getUniformLocation(this.decayProg, "uMapSize")!; + this.uDecayAmount = gl.getUniformLocation(this.decayProg, "uDecay")!; + gl.useProgram(this.decayProg); + gl.uniform1i(gl.getUniformLocation(this.decayProg, "uHeatTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.decayProg, "uTileTex"), 1); + gl.uniform1i(gl.getUniformLocation(this.decayProg, "uPrevTileTex"), 2); + + this.quadVao = createFullscreenQuad(gl); + } + + private createFboFor(tex: WebGLTexture): WebGLFramebuffer { + const gl = this.gl; + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + tex, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return fbo; + } + + /** Current heat read texture. */ + private get heatReadTex(): WebGLTexture { + return this.heatCurrent === 0 ? this.heatTexA : this.heatTexB; + } + private get heatWriteFbo(): WebGLFramebuffer { + return this.heatCurrent === 0 ? this.heatFboB : this.heatFboA; + } + private swapHeat(): void { + this.heatCurrent = 1 - this.heatCurrent; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** Current heat texture for reading (bloom extract and lightmap). */ + getHeatTex(): WebGLTexture { + return this.heatReadTex; + } + + /** + * Run GPU heat update: detect fallout-bit transitions, apply decay, + * then snapshot tileTex → prevTileTex. + * + * Call once per frame after tile texture is flushed to GPU. + */ + updateHeat(): void { + const gl = this.gl; + const mw = this.mapW; + const mh = this.mapH; + + // 1. Upload reconstructed heat on seek + if (this.pendingFullHeat) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.heatReadTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + mw, + mh, + gl.RED, + gl.UNSIGNED_BYTE, + this.pendingFullHeat, + ); + this.pendingFullHeat = null; + } + + // 2. First frame / seek: copy tileTex → prevTileTex, skip transitions + if (this.needsPrevTileCopy) { + this.blitTileToPrev(); + this.needsPrevTileCopy = false; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return; + } + + // 3. Skip decay pass when nothing to do — no pending decay and heat already settled. + // Still blit tileTex→prevTileTex when a tick fired (pendingDecay > 0) so transition + // detection stays accurate if heat activates later. + if (!this.heatActive && this.pendingDecay === 0) return; + if (!this.heatActive) { + // Tick fired but no heat — just keep prevTileTex in sync and bail. + this.blitTileToPrev(); + this.pendingDecay = 0; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return; + } + + // 4. Combined transition detection + decay (GPU ping-pong) + gl.bindFramebuffer(gl.FRAMEBUFFER, this.heatWriteFbo); + gl.viewport(0, 0, mw, mh); + gl.disable(gl.BLEND); + + gl.useProgram(this.decayProg); + gl.uniform2f(this.uDecayMapSize, mw, mh); + gl.uniform1f(this.uDecayAmount, this.pendingDecay); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.heatReadTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.prevTileTex); + gl.bindVertexArray(this.quadVao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + this.swapHeat(); + this.decayAccumulated += this.pendingDecay; + if (this.decayAccumulated >= 255) this.heatActive = false; + this.pendingDecay = 0; + + // 5. Snapshot current tileTex → prevTileTex for next frame + this.blitTileToPrev(); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + /** GPU blit: tileTex → prevTileTex (R16UI, NEAREST). */ + private blitTileToPrev(): void { + const gl = this.gl; + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.tileTexReadFbo); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.prevTileFbo); + gl.blitFramebuffer( + 0, + 0, + this.mapW, + this.mapH, + 0, + 0, + this.mapW, + this.mapH, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + } + + /** + * Reset heat state on seek. Reconstructs heat from nuke history and + * masks out recaptured tiles. + */ + resetForSeek( + tileState: Uint16Array, + nukeEvents?: Array<{ tick: number; tiles: number[] }>, + currentTick?: number, + ): void { + let hasHeat = false; + if (nukeEvents && nukeEvents.length > 0 && currentTick !== undefined) { + const heat = this.reconstructHeat(nukeEvents, currentTick); + this.maskHeat(heat, tileState); + this.pendingFullHeat = heat; + hasHeat = heat.some((v) => v > 0); + } else { + this.pendingFullHeat = new Uint8Array(this.mapW * this.mapH); + } + this.pendingDecay = 0; + this.decayAccumulated = 0; + this.heatActive = hasHeat; + this.needsPrevTileCopy = true; + } + + /** Accumulate heat decay for one game tick. */ + decayHeat(): void { + this.pendingDecay += this.settings.falloutBloom.heatDecayPerTick; + // A tick fired — the shader may detect new fallout transitions, so heat is potentially active. + if (!this.heatActive) { + this.heatActive = true; + this.decayAccumulated = 0; + } + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + private reconstructHeat( + nukeEvents: Array<{ tick: number; tiles: number[] }>, + currentTick: number, + ): Uint8Array { + const heat = new Uint8Array(this.mapW * this.mapH); + const decay = this.settings.falloutBloom.heatDecayPerTick; + for (const evt of nukeEvents) { + if (evt.tick > currentTick) continue; + const elapsed = currentTick - evt.tick; + const h = Math.round(255 - elapsed * decay); + if (h <= 0) continue; + for (const ref of evt.tiles) { + if (heat[ref] < h) heat[ref] = h; + } + } + return heat; + } + + private maskHeat(heat: Uint8Array, tileState: Uint16Array): void { + for (let i = 0; i < heat.length; i++) { + if (heat[i] > 0 && (tileState[i] & FALLOUT_BIT) === 0) { + heat[i] = 0; + } + } + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.decayProg); + gl.deleteFramebuffer(this.heatFboA); + gl.deleteFramebuffer(this.heatFboB); + gl.deleteFramebuffer(this.prevTileFbo); + gl.deleteFramebuffer(this.tileTexReadFbo); + gl.deleteTexture(this.prevTileTex); + gl.deleteVertexArray(this.quadVao); + } +} diff --git a/src/client/render/gl/utils/nuke-trajectory.ts b/src/client/render/gl/utils/nuke-trajectory.ts new file mode 100644 index 000000000..d020a6542 --- /dev/null +++ b/src/client/render/gl/utils/nuke-trajectory.ts @@ -0,0 +1,261 @@ +/** + * Nuke trajectory computation — Bezier control points and color thresholds. + * + * Matches upstream PathFinder.Parabola.ts + Line.ts math exactly. + * Pure functions, no game dependencies. + */ + +import type { NukeTrajectoryData } from "../../types"; + +// Upstream constants +const PARABOLA_MIN_HEIGHT = 50; +const TARGETABLE_RANGE = 150; +const TARGETABLE_RANGE_SQ = TARGETABLE_RANGE * TARGETABLE_RANGE; +const THRESHOLD_SAMPLES = 64; + +// SAM range formula: 150 - 480 / (level + 5) +const MAX_SAM_RANGE = 150; +const SAM_RANGE_DIVISOR = 480; +const SAM_RANGE_OFFSET = 5; + +export function samRange(level: number): number { + return MAX_SAM_RANGE - SAM_RANGE_DIVISOR / (level + SAM_RANGE_OFFSET); +} + +export interface SAMInfo { + x: number; + y: number; + rangeSq: number; +} + +/** Cubic Bezier evaluation at parameter t. */ +function bezier( + t: number, + p0: number, + p1: number, + p2: number, + p3: number, +): number { + const T = 1 - t; + return ( + T * T * T * p0 + 3 * T * T * t * p1 + 3 * T * t * t * p2 + t * t * t * p3 + ); +} + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +function distSq(ax: number, ay: number, bx: number, by: number): number { + const dx = ax - bx; + const dy = ay - by; + return dx * dx + dy * dy; +} + +/** + * Compute Bezier control points matching upstream parabola pathfinder. + * + * The curve bows perpendicular to the src→dst line. `directionUp` controls + * which side (in Y) the arc bows toward (upstream convention: true = -Y). + */ +export function computeNukeControlPoints( + srcX: number, + srcY: number, + dstX: number, + dstY: number, + mapH: number, + directionUp: boolean, +): { + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; +} { + const dx = dstX - srcX; + const dy = dstY - srcY; + const dist = Math.sqrt(dx * dx + dy * dy); + const maxHeight = Math.max(dist / 3, PARABOLA_MIN_HEIGHT); + const hm = directionUp ? -1 : 1; + + return { + p0x: srcX, + p0y: srcY, + p1x: srcX + dx / 4, + p1y: clamp(srcY + dy / 4 + hm * maxHeight, 0, mapH - 1), + p2x: srcX + (dx * 3) / 4, + p2y: clamp(srcY + (dy * 3) / 4 + hm * maxHeight, 0, mapH - 1), + p3x: dstX, + p3y: dstY, + }; +} + +/** Binary-search for the exact t where distSq to (cx,cy) crosses rangeSq. */ +function refineCrossing( + cp: { + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; + }, + cx: number, + cy: number, + rangeSq: number, + tLo: number, + tHi: number, + exitingRange: boolean, +): number { + for (let i = 0; i < 10; i++) { + const tMid = (tLo + tHi) * 0.5; + const x = bezier(tMid, cp.p0x, cp.p1x, cp.p2x, cp.p3x); + const y = bezier(tMid, cp.p0y, cp.p1y, cp.p2y, cp.p3y); + const inside = distSq(x, y, cx, cy) <= rangeSq; + if (exitingRange ? inside : !inside) tLo = tMid; + else tHi = tMid; + } + return (tLo + tHi) * 0.5; +} + +/** + * Sample the Bezier curve at regular t intervals and find color threshold + * t-values for untargetable zones and SAM intercept. + * + * Uses binary search refinement for sub-sample precision so that zone + * boundary markers don't jiggle when the cursor moves. + */ +export function computeTrajectoryThresholds( + cp: { + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; + }, + srcX: number, + srcY: number, + dstX: number, + dstY: number, + sams: readonly SAMInfo[], +): { + tUntargetableStart: number; + tUntargetableEnd: number; + tSamIntercept: number; +} { + let tUntargetableStart = -1; + let tUntargetableEnd = -1; + let tSamIntercept = 1.0; + + const dt = 1.0 / THRESHOLD_SAMPLES; + + // Pass 1: find untargetable zone boundaries + for (let i = 1; i <= THRESHOLD_SAMPLES; i++) { + const t = i * dt; + const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x); + const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y); + + if (tUntargetableStart < 0) { + // Looking for first point outside source range + if (distSq(x, y, srcX, srcY) > TARGETABLE_RANGE_SQ) { + if (distSq(x, y, dstX, dstY) < TARGETABLE_RANGE_SQ) { + // Overlapping source & target range — no untargetable zone + break; + } + tUntargetableStart = refineCrossing( + cp, + srcX, + srcY, + TARGETABLE_RANGE_SQ, + t - dt, + t, + true, + ); + } + } else { + // Looking for first point inside target range + if (distSq(x, y, dstX, dstY) < TARGETABLE_RANGE_SQ) { + tUntargetableEnd = refineCrossing( + cp, + dstX, + dstY, + TARGETABLE_RANGE_SQ, + t - dt, + t, + false, + ); + break; + } + } + } + + // Pass 2: find SAM intercept (skip untargetable zone) + if (sams.length > 0) { + for (let i = 1; i <= THRESHOLD_SAMPLES; i++) { + const t = i * dt; + + // Skip untargetable segment + if ( + tUntargetableStart >= 0 && + t >= tUntargetableStart && + t <= tUntargetableEnd + ) { + continue; + } + + const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x); + const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y); + + for (const sam of sams) { + if (distSq(x, y, sam.x, sam.y) <= sam.rangeSq) { + tSamIntercept = refineCrossing( + cp, + sam.x, + sam.y, + sam.rangeSq, + t - dt, + t, + false, + ); + break; + } + } + if (tSamIntercept < 1.0) break; + } + } + + return { tUntargetableStart, tUntargetableEnd, tSamIntercept }; +} + +/** + * Build complete NukeTrajectoryData from source/target positions. + * Convenience function combining control point + threshold computation. + */ +export function buildNukeTrajectory( + srcX: number, + srcY: number, + dstX: number, + dstY: number, + mapH: number, + directionUp: boolean, + sams: readonly SAMInfo[], +): NukeTrajectoryData { + const cp = computeNukeControlPoints( + srcX, + srcY, + dstX, + dstY, + mapH, + directionUp, + ); + const th = computeTrajectoryThresholds(cp, srcX, srcY, dstX, dstY, sams); + return { ...cp, ...th }; +} diff --git a/src/client/render/gl/utils/tile-codec.ts b/src/client/render/gl/utils/tile-codec.ts new file mode 100644 index 000000000..227016a4c --- /dev/null +++ b/src/client/render/gl/utils/tile-codec.ts @@ -0,0 +1,18 @@ +/** + * Tile state bit layout (R16UI). Single source of truth for TypeScript + GLSL. + * + * Bits 0-11: ownerID (player smallID, 0 = unowned) + * Bit 13: fallout + * Bit 14: defense bonus + */ + +export const OWNER_MASK = 0xfff; // bits 0-11 +export const FALLOUT_BIT = 1 << 13; // bit 13 +export const DEFENSE_BIT = 1 << 14; // bit 14 + +/** GLSL #define values for shaderSrc() injection. Bit indices, not masks. */ +export const TILE_DEFINES = { + OWNER_MASK: 0xfff, // used as uint(OWNER_MASK) in GLSL + FALLOUT_BIT: 13, // used as (1u << FALLOUT_BIT) in GLSL + DEFENSE_BIT: 14, // used as (1u << DEFENSE_BIT) in GLSL +}; diff --git a/src/client/render/gl/vite-env.d.ts b/src/client/render/gl/vite-env.d.ts new file mode 100644 index 000000000..04933cc42 --- /dev/null +++ b/src/client/render/gl/vite-env.d.ts @@ -0,0 +1,8 @@ +declare module "*.glsl?raw" { + const src: string; + export default src; +} +declare module "*.png?url" { + const url: string; + export default url; +} diff --git a/src/client/render/types/frame-data.ts b/src/client/render/types/frame-data.ts new file mode 100644 index 000000000..5e2574e60 --- /dev/null +++ b/src/client/render/types/frame-data.ts @@ -0,0 +1,76 @@ +import type { FrameEvents } from "./frame-events"; +import type { + AttackRingInput, + NameEntry, + NukeTelegraphData, + PlayerState, + PlayerStatusData, + TilePair, + UnitState, +} from "./renderer"; + +/** + * FrameData — the boundary contract between game integration and features. + * + * Produced once per frame by a driver (shim for live, codec for replay). + * All feature consumers (renderer, minimap, stats) read from this interface. + * They never touch game internals directly. + */ +export interface FrameData { + // ── Core accumulated state ──────────────────────────────────────────── + + readonly tick: number; + /** True during spawn phase (before gameplay begins). Always false for replay. */ + readonly inSpawnPhase: boolean; + readonly tileState: Uint16Array; + readonly trailState: Uint8Array; + readonly railroadState: Uint8Array; + readonly units: ReadonlyMap; + readonly players: ReadonlyMap; + readonly names: ReadonlyMap; + + // ── Per-frame events ────────────────────────────────────────────────── + + /** Everything that happened this frame — rendering FX and stats events. */ + readonly events: FrameEvents; + + // ── Upload hints ────────────────────────────────────────────────────── + + /** + * Changed tiles this frame for delta uploads. + * - `null` or `undefined` → full upload needed (live mode or keyframe seek) + * - array → delta upload (replay sequential advance) + */ + readonly changedTiles?: TilePair[] | null; + readonly railroadDirty: boolean; + readonly revealedRailTiles: number[]; + + /** + * Trail dirty row range for partial GPU upload. + * - `dirtyRowMin > dirtyRowMax` → no trail changes (skip upload) + * - Otherwise → upload rows [min, max] from trailState + * Only meaningful in `tileMode: "live"`. + */ + readonly trailDirtyRowMin: number; + readonly trailDirtyRowMax: number; + + // ── Derived (computed once by producer) ──────────────────────────────── + + readonly playerStatus: ReadonlyMap; + readonly relationMatrix: Uint8Array; + readonly relationSize: number; + readonly allianceClusters: ReadonlyMap; + readonly nukeTelegraphs: NukeTelegraphData[]; + readonly attackRings: AttackRingInput[]; + /** True when structures changed this tick (added/removed/level change). */ + readonly structuresDirty: boolean; + + // ── Upload semantics ────────────────────────────────────────────────── + + /** + * How tile data should reach the GPU: + * - `"live"` — arrays are mutated in-place by shim each tick (zero-copy refs) + * - `"copy"` — arrays may be swapped/reconstructed (renderer must copy) + */ + readonly tileMode: "live" | "copy"; +} diff --git a/src/client/render/types/frame-events.ts b/src/client/render/types/frame-events.ts new file mode 100644 index 000000000..ae5c9c214 --- /dev/null +++ b/src/client/render/types/frame-events.ts @@ -0,0 +1,114 @@ +import type { + ConquestFx, + DeadUnitFx, + PlayerState, + UnitState, +} from "./renderer"; + +// ── Supporting event types ────────────────────────────────────────────── + +export interface AllianceFormedEvent { + requestorID: number; + recipientID: number; +} + +export interface AllianceBrokenEvent { + traitorID: number; + betrayedID: number; +} + +export interface AllianceExpiredEvent { + player1ID: number; + player2ID: number; +} + +export interface EmbargoEvent { + type: "start" | "stop"; + playerID: number; + embargoedID: number; +} + +export interface TargetEvent { + playerID: number; + targetID: number; +} + +export interface BonusEvent { + playerID: string; + smallID: number; + tile: number; + gold: number; + troops: number; +} + +export interface NukeIncomingEvent { + playerID: number; +} + +export interface EmojiEvent { + senderID: number; + message: string; +} + +export interface DisplayMessageEvent { + messageType: number; + playerID: number | null; + goldAmount?: number; + params?: Record; +} + +export interface WinEvent { + /** Tuple: ["player", ...playerIds] or ["team"|"nation", name, ...playerIds] */ + winner: string[]; +} + +// ── Empty events constant ─────────────────────────────────────────────── + +/** Shared empty-events object. Safe to reuse — all arrays are empty and never mutated. */ +export const EMPTY_FRAME_EVENTS: FrameEvents = { + deadUnits: [], + conquestEvents: [], + unitUpdates: [], + playerUpdates: [], + allianceFormed: [], + allianceBroken: [], + allianceExpired: [], + embargoEvents: [], + targetEvents: [], + bonusEvents: [], + nukeIncoming: [], + emojis: [], + displayMessages: [], + wins: [], + gamePaused: null, +}; + +// ── FrameEvents ───────────────────────────────────────────────────────── + +/** + * Everything that happened THIS frame. Accumulated state and derived data + * live on FrameData directly — per-frame ephemeral events live here. + * + * Empty arrays when nothing happened. Producers must always populate every + * field (no undefined — consumers shouldn't need null checks). + */ +export interface FrameEvents { + // Rendering events + readonly deadUnits: DeadUnitFx[]; + readonly conquestEvents: ConquestFx[]; + + // Stats events + readonly unitUpdates: UnitState[]; + readonly playerUpdates: PlayerState[]; + readonly allianceFormed: AllianceFormedEvent[]; + readonly allianceBroken: AllianceBrokenEvent[]; + readonly allianceExpired: AllianceExpiredEvent[]; + readonly embargoEvents: EmbargoEvent[]; + readonly targetEvents: TargetEvent[]; + readonly bonusEvents: BonusEvent[]; + readonly nukeIncoming: NukeIncomingEvent[]; + readonly emojis: EmojiEvent[]; + readonly displayMessages: DisplayMessageEvent[]; + readonly wins: WinEvent[]; + readonly gamePaused: boolean | null; +} diff --git a/src/client/render/types/frame-source.ts b/src/client/render/types/frame-source.ts new file mode 100644 index 000000000..74f6f0217 --- /dev/null +++ b/src/client/render/types/frame-source.ts @@ -0,0 +1,38 @@ +import type { FrameData } from "./frame-data"; +import type { PlayerStatic } from "./renderer"; + +/** + * Static per-session metadata. Set once at game-start, never changes. + */ +export interface GameStartConfig { + gameID: string; + mapWidth: number; + mapHeight: number; + /** 0 for spectator/replay. */ + localPlayerSmallID: number; + players: PlayerStatic[]; + gameMode?: string; + difficulty?: string; + numLandTiles?: number; +} + +/** + * Mode-agnostic frame source. Features subscribe here and don't care + * whether data comes from a live game or a replay file. + * + * All subscription methods return an unsubscribe function. + * + * Late-join: `onGameStart` fires immediately with cached config if + * subscribed after game-start. `onFrame` does NOT late-fire — subscriber + * waits for the next real tick. + * + * Game-end: `onGameEnd` fires on win detection. `onFrame` continues + * emitting — the simulation runs past game-end. + */ +export interface FrameSource { + onFrame(handler: (frame: FrameData) => void): () => void; + onGameStart(handler: (config: GameStartConfig) => void): () => void; + onGameEnd(handler: () => void): () => void; + /** null before game-start. Stays valid after game-end (same session). */ + readonly config: GameStartConfig | null; +} diff --git a/src/client/render/types/game-updates.ts b/src/client/render/types/game-updates.ts new file mode 100644 index 000000000..40bae1a5d --- /dev/null +++ b/src/client/render/types/game-updates.ts @@ -0,0 +1,171 @@ +/** + * Game update type constants and typed event payloads. + * + * Shared contract between shim (live game) and codec (replay). + * Values must match the LIVE deployed game's GameUpdates.ts. + */ + +// --------------------------------------------------------------------------- +// GameUpdateType constants +// --------------------------------------------------------------------------- + +export const GameUpdateType = { + Tile: 0, + Unit: 1, + Player: 2, + DisplayEvent: 3, + DisplayChatEvent: 4, + AllianceRequest: 5, + AllianceRequestReply: 6, + BrokeAlliance: 7, + AllianceExpired: 8, + AllianceExtension: 9, + TargetPlayer: 10, + Emoji: 11, + Win: 12, + Hash: 13, + UnitIncoming: 14, + BonusEvent: 15, + RailroadDestructionEvent: 16, + RailroadConstructionEvent: 17, + RailroadSnapEvent: 18, + ConquestEvent: 19, + EmbargoEvent: 20, + GamePaused: 21, + NukeDetonation: 22, +} as const; + +/** MessageType enum values from the game source. */ +export const MessageType = { + SAM_HIT: 9, + SENT_GOLD_TO_PLAYER: 18, + RECEIVED_GOLD_FROM_PLAYER: 19, + RECEIVED_GOLD_FROM_TRADE: 20, + SENT_TROOPS_TO_PLAYER: 21, + RECEIVED_TROOPS_FROM_PLAYER: 22, +} as const; + +// --------------------------------------------------------------------------- +// Typed update payloads (keyed by GameUpdateType values) +// --------------------------------------------------------------------------- + +export type PlayerType = "HUMAN" | "NATION" | "BOT"; + +export interface UnitEventUpdate { + id: number; + unitType: string; + ownerID: number; + pos: number; + lastPos?: number; + isActive: boolean; + level: number; + underConstruction?: boolean; + markedForDeletion: number | false; + lastOwnerID?: number; + trainType?: string; + loaded?: boolean; + targetUnitId?: number; + targetTile?: number; + health?: number; + troops?: number; + reachedTarget?: boolean; + retreating?: boolean; + targetable?: boolean; + hasTrainStation?: boolean; + missileTimerQueue?: number[]; +} + +export interface PlayerEventUpdate { + id: string; + clientID?: string | null; + smallID: number; + displayName: string; + playerType: PlayerType; + team?: string | null; + isAlive: boolean; + troops: number; + gold: bigint; + tilesOwned: number; + outgoingAttacks?: AttackEventUpdate[]; + incomingAttacks?: AttackEventUpdate[]; + allies?: number[]; + betrayals?: number; +} + +export interface AttackEventUpdate { + troops: number; +} + +export interface WinUpdate { + /** Winner tuple: ["player", ...playerIds] or ["team"|"nation", name, ...playerIds] */ + winner?: [string, ...string[]]; +} + +export interface AllianceReplyUpdate { + accepted: boolean; + request?: { requestorID: number; recipientID: number }; +} + +export interface BrokeAllianceUpdate { + traitorID: number; + betrayedID: number; +} + +export interface AllianceExpiredUpdate { + player1ID: number; + player2ID: number; +} + +export interface EmbargoUpdate { + event: "start" | "stop"; + playerID: number; + embargoedID: number; +} + +export interface TargetPlayerUpdate { + playerID: number; + targetID: number; +} + +export interface BonusUpdate { + player: string; + tile?: number; + gold: number; + troops: number; +} + +export interface UnitIncomingUpdate { + playerID: number; +} + +export interface EmojiUpdate { + emoji?: { senderID: number; message: string }; +} + +export interface DisplayMessageUpdate { + messageType: number; + playerID: number | null; + goldAmount?: bigint | number; + params?: Record; +} + +export interface GamePausedUpdate { + paused: boolean; +} + +export interface RailroadConstructionUpdate { + id: number; + tiles: number[]; +} + +export interface RailroadDestructionUpdate { + id: number; +} + +export interface RailroadSnapUpdate { + originalId: number; + newId1: number; + newId2: number; + tiles1: number[]; + tiles2: number[]; +} diff --git a/src/client/render/types/game.ts b/src/client/render/types/game.ts new file mode 100644 index 000000000..5a6809240 --- /dev/null +++ b/src/client/render/types/game.ts @@ -0,0 +1,17 @@ +/** + * The frame data type that both the live game and encoder consume. + * This matches the GameUpdateViewData from the live game's update loop. + */ +export interface GameUpdateViewData { + tick: number; + updates: Record; + packedTileUpdates: unknown; + packedMotionPlans?: Uint32Array; + playerNameViewData: Record; +} + +/** + * Minimal GameStartInfo for the encoder's finish() call. + * The actual object is opaque JSON — we just need it to be serializable. + */ +export type GameStartInfo = Record; diff --git a/src/client/render/types/index.ts b/src/client/render/types/index.ts new file mode 100644 index 000000000..8785374f3 --- /dev/null +++ b/src/client/render/types/index.ts @@ -0,0 +1,108 @@ +// Renderer types (units, players, tiles, names, config) +export { PlayerTypeEnum, TrainType } from "./renderer"; +export type { + AllianceData, + AttackData, + AttackRingInput, + ConquestFx, + DeadUnitFx, + EmojiData, + GhostPreviewData, + NameEntry, + NukeTelegraphData, + NukeTrajectoryData, + PlayerState, + PlayerStatic, + PlayerStatusData, + RendererConfig, + TilePair, + UnitState, +} from "./renderer"; + +// Frame data — boundary contract between game integration and features +export type { FrameData } from "./frame-data"; + +// Frame events — per-frame ephemeral events (rendering FX + stats events) +export { EMPTY_FRAME_EVENTS } from "./frame-events"; +export type { + AllianceBrokenEvent, + AllianceExpiredEvent, + AllianceFormedEvent, + BonusEvent, + DisplayMessageEvent, + EmbargoEvent, + EmojiEvent, + FrameEvents, + NukeIncomingEvent, + TargetEvent, + WinEvent, +} from "./frame-events"; + +// Frame source — mode-agnostic subscription interface +export type { FrameSource, GameStartConfig } from "./frame-source"; + +// Game update types +export type { GameStartInfo, GameUpdateViewData } from "./game"; + +// Replay types (header, frames, codec helpers) +export type { + ChunkIndexEntry, + FrameSnapshot, + GridPlanRecord, + GzipFn, + InflateFn, + MotionPlanRecord, + RawDelta, + RawFrame, + RawKeyframe, + ReplayHeader, + StreamableReplayInfo, + TrainPlanRecord, +} from "./replay"; + +// Game update type constants and event payloads (shared between shim + codec) +export { GameUpdateType, MessageType } from "./game-updates"; +export type { + AllianceExpiredUpdate, + AllianceReplyUpdate, + AttackEventUpdate, + BonusUpdate, + BrokeAllianceUpdate, + DisplayMessageUpdate, + EmbargoUpdate, + EmojiUpdate, + GamePausedUpdate, + PlayerEventUpdate, + PlayerType, + RailroadConstructionUpdate, + RailroadDestructionUpdate, + RailroadSnapUpdate, + TargetPlayerUpdate, + UnitEventUpdate, + UnitIncomingUpdate, + WinUpdate, +} from "./game-updates"; + +// Unit type string constants and derived sets +export { + ALL_UNIT_TYPES, + NUKE_MAGNITUDES, + NUKE_TYPES, + STRUCTURE_TYPES, + 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_SAM_MISSILE, + UT_SHELL, + UT_TRADE_SHIP, + UT_TRAIN, + UT_TRANSPORT, + UT_WARSHIP, +} from "./unit-type"; diff --git a/src/client/render/types/renderer.ts b/src/client/render/types/renderer.ts new file mode 100644 index 000000000..14b873207 --- /dev/null +++ b/src/client/render/types/renderer.ts @@ -0,0 +1,208 @@ +/** TrainType enum — numeric values matching UnitState.trainType. */ +export enum TrainType { + Engine = 0, + TailEngine = 1, + Carriage = 2, +} + +/** Numeric player type — matching PlayerStatic.playerType. */ +export enum PlayerTypeEnum { + Human = 0, + Bot = 1, + Nation = 2, +} + +/** Static player data from the header dictionary */ +export interface PlayerStatic { + smallID: number; + id: string; + name: string; + displayName: string; + clientID: string | null; + playerType: PlayerTypeEnum; + team: string | null; + isLobbyCreator: boolean; + flag?: string; + /** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */ + color?: string; +} + +export interface AttackData { + attackerID: number; + targetID: number; + troops: number; + id: string; + retreating: boolean; +} + +export interface AllianceData { + id: number; + other: string; + createdAt: number; + expiresAt: number; + hasExtensionRequest: boolean; +} + +export interface EmojiData { + message: string; + senderID: number; + recipientID: number | "AllPlayers"; + createdAt: number; +} + +export interface PlayerState { + smallID: number; + isAlive: boolean; + isDisconnected: boolean; + tilesOwned: number; + gold: number; + troops: number; + isTraitor: boolean; + traitorRemainingTicks: number; + betrayals: number; + hasSpawned: boolean; + lastDeleteUnitTick: number; + allies: number[]; + embargoes: string[]; + targets: number[]; + outgoingAttacks: AttackData[]; + incomingAttacks: AttackData[]; + outgoingAllianceRequests: string[]; + alliances: AllianceData[]; + outgoingEmojis: EmojiData[]; +} + +export interface UnitState { + id: number; + unitType: string; + ownerID: number; + lastOwnerID: number | null; + pos: number; + lastPos: number; + isActive: boolean; + reachedTarget: boolean; + retreating: boolean; + targetable: boolean; + markedForDeletion: number | false; // -1 -> false, else tick + health: number | null; + underConstruction: boolean; + targetUnitId: number | null; + targetTile: number | null; + troops: number; + missileTimerQueue: number[]; + level: number; + hasTrainStation: boolean; + trainType: number | null; // 0=Engine, 1=TailEngine, 2=Carriage + loaded: boolean | null; + constructionStartTick: number | null; +} + +/** Minimal dead-unit data needed by the FX pass. */ +export interface DeadUnitFx { + unitType: string; + pos: number; + reachedTarget: boolean; + /** Ticks since the event occurred (0 = this frame, >0 = seeked past it). */ + tickAge?: number; +} + +/** Conquest event data for the gold popup + sword sprite FX. */ +export interface ConquestFx { + x: number; // world tile X (conquered player's name location) + y: number; // world tile Y + gold: number; // gold amount awarded + /** Ticks since the event occurred (0 = this frame, >0 = seeked past it). */ + tickAge?: number; +} + +export interface TilePair { + ref: number; + state: number; +} + +export interface NameEntry { + playerID: string; + x: number; + y: number; + size: number; +} + +/** Per-player status data for the GPU name/status-icon passes. */ +export interface PlayerStatusData { + crown: boolean; + traitor: boolean; + disconnected: boolean; + alliance: boolean; + allianceReq: boolean; + target: boolean; + embargo: boolean; + nukeActive: boolean; + nukeTargetsMe: boolean; + traitorRemainingTicks: number; + allianceFraction: number; +} + +/** Ghost structure preview data for build-mode visualization. */ +export interface GhostPreviewData { + ghostType: string; // UnitType string ("City", "Port", etc.) + tileX: number; // Hover tile X + tileY: number; // Hover tile Y + canBuild: boolean; // Valid placement? + canUpgrade: boolean; // Upgrading existing structure? + cost: number; // Gold cost + ghostRailPaths: number[][]; // TileRef paths (City/Port only) + overlappingRailroads: number[]; // Rail IDs in snap zone + ownerID: number; // Player's smallID (for color) + /** Tile position of existing structure being upgraded (null if fresh build). */ + upgradeTargetTile: number | null; + /** Range radius in tiles for the placement circle (0 = no circle). */ + rangeRadius: number; +} + +/** Nuke trajectory preview data — Bezier control points + color thresholds. */ +export interface NukeTrajectoryData { + /** Bezier control points (world-space tile coordinates). */ + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; + /** t-value (0..1) where bomb leaves source's targetable range. -1 if ranges overlap. */ + tUntargetableStart: number; + /** t-value (0..1) where bomb enters target's targetable range. -1 if ranges overlap. */ + tUntargetableEnd: number; + /** t-value (0..1) of first SAM intercept point. 1.0 = no intercept. */ + tSamIntercept: number; +} + +/** Input data for attack ring visualization. */ +export interface AttackRingInput { + x: number; + y: number; + unitId: number; +} + +/** In-flight nuke target circle data. */ +export interface NukeTelegraphData { + x: number; + y: number; + innerRadius: number; + outerRadius: number; +} + +/** Lean config for constructing the GPU renderer — no replay-specific fields. */ +export interface RendererConfig { + mapWidth: number; + mapHeight: number; + unitTypes: string[]; + players: PlayerStatic[]; + /** + * Pre-allocated player capacity for GPU textures. + * Defaults to `players.length` when omitted. Set higher when players + * arrive after construction (e.g. bots are created on tick 1). + */ + maxPlayers?: number; +} diff --git a/src/client/render/types/replay.ts b/src/client/render/types/replay.ts new file mode 100644 index 000000000..c02ccc283 --- /dev/null +++ b/src/client/render/types/replay.ts @@ -0,0 +1,144 @@ +import type { + ConquestFx, + DeadUnitFx, + NameEntry, + PlayerState, + PlayerStatic, + RendererConfig, + TilePair, + UnitState, +} from "./renderer"; + +/** Chunk index entry — one per chunk in the file */ +export interface ChunkIndexEntry { + compressedOffset: number; + compressedSize: number; + decompressedSize: number; + frameCount: number; +} + +/** Subset of header available after streaming preamble (before full file download). */ +export interface StreamableReplayInfo extends RendererConfig { + totalFrames: number; + keyframeInterval: number; + numLandTiles: number; + gameStartInfo: unknown; + chunks: ChunkIndexEntry[]; +} + +/** Parsed v6 file header + dictionaries + chunk index + trailer sections */ +export interface ReplayHeader extends StreamableReplayInfo { + magic: number; + version: number; + gameID: string; + totalFrames: number; + keyframeInterval: number; + numLandTiles: number; + processedAt: number; + processingDurationMs: number; + gameStartInfo: unknown; + players: PlayerStatic[]; + /** Chunk index — per-chunk offsets and sizes */ + chunks: ChunkIndexEntry[]; + /** Nuke detonation events — top-level index for seek-time heat reconstruction */ + nukeEvents: Array<{ tick: number; tiles: number[] }>; + /** Railroad events — top-level index for seek-time railroad reconstruction */ + railroadEvents: Array<{ tick: number; type: number; data: unknown }>; + /** Motion plan events — top-level index for plan-driven unit positions and trails */ + motionPlanEvents: MotionPlanRecord[]; + /** Construction start events — top-level index for seek-time construction progress */ + constructionStarts: Array<{ unitId: number; startTick: number }>; + /** Conquest events — top-level index for seek-time gold popup + sword sprite */ + conquestEvents: Array<{ tick: number; x: number; y: number; gold: number }>; + /** Dead unit events — top-level index for seek-time explosion/death FX */ + deadUnitEvents: Array<{ + tick: number; + unitType: string; + pos: number; + reachedTarget: boolean; + }>; + /** Player elimination events — tick when each player's isAlive transitioned to false */ + eliminationEvents: Array<{ tick: number; smallID: number }>; +} + +/** Raw decoded v4 keyframe data — tile data is a raw Uint16Array blob */ +export interface RawKeyframe { + type: 0; + tick: number; + /** Raw tile blob: Uint16Array[mapWidth x mapHeight]. Direct GPU upload. */ + tileBlob: Uint16Array; + players: Map; + units: Map; + names: Map; + miscUpdates: Record | null; +} + +/** Raw decoded delta frame data */ +export interface RawDelta { + type: 1; + tick: number; + tiles: TilePair[]; + playerDeltas: Map; // new or changed players (full state after applying delta) + playersRemoved: number[]; + unitDeltas: Map; + unitsRemoved: number[]; + nameChanges: Map; + miscUpdates: Record | null; +} + +export type RawFrame = RawKeyframe | RawDelta; + +/** Full accumulated game state at a given tick */ +export interface FrameSnapshot { + tick: number; + players: Map; + units: Map; + names: Map; + /** Tiles changed in this frame only (for incremental rendering). null = full upload needed. */ + changedTiles: TilePair[] | null; + /** Units that died this frame (FX-only data). Empty on keyframes. */ + deadUnits: DeadUnitFx[]; + /** Conquest events active at this tick (from global index). */ + conquestEvents: ConquestFx[]; + /** Per-frame misc updates (alliances, donations, trades, etc.). null = none. */ + miscUpdates: Record | null; +} + +/** + * Inflate function type — platform provides its implementation. + * Node: zlib.inflateSync, Browser: pako.inflate + */ +export type InflateFn = (data: Uint8Array) => Uint8Array; + +/** + * Gzip function type — platform provides its implementation. + * Node: zlib.gzipSync, Browser: pako.gzip + */ +export type GzipFn = (data: Uint8Array) => Uint8Array | Promise; + +// --------------------------------------------------------------------------- +// Motion plan records — stored as a file-level index for plan-driven units +// (transport ships, trade ships, trains). +// --------------------------------------------------------------------------- + +export interface GridPlanRecord { + kind: "grid"; + unitId: number; + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; +} + +export interface TrainPlanRecord { + kind: "train"; + engineUnitId: number; + carUnitIds: Uint32Array; + planId: number; + startTick: number; + speed: number; + spacing: number; + path: Uint32Array; +} + +export type MotionPlanRecord = GridPlanRecord | TrainPlanRecord; diff --git a/src/client/render/types/unit-type.ts b/src/client/render/types/unit-type.ts new file mode 100644 index 000000000..70dd17efd --- /dev/null +++ b/src/client/render/types/unit-type.ts @@ -0,0 +1,83 @@ +/** + * Canonical unit type string constants. + * + * These match the strings the upstream game sends in UnitEventUpdate.unitType. + * Use these instead of raw string literals to prevent typos and enable + * find-all-references. + */ + +// --------------------------------------------------------------------------- +// Individual unit type constants +// --------------------------------------------------------------------------- + +// Mobile units +export const UT_TRANSPORT = "Transport" as const; +export const UT_TRADE_SHIP = "Trade Ship" as const; +export const UT_WARSHIP = "Warship" as const; +export const UT_ATOM_BOMB = "Atom Bomb" as const; +export const UT_HYDROGEN_BOMB = "Hydrogen Bomb" as const; +export const UT_MIRV = "MIRV" as const; +export const UT_SAM_MISSILE = "SAMMissile" as const; +export const UT_SHELL = "Shell" as const; +export const UT_MIRV_WARHEAD = "MIRV Warhead" as const; +export const UT_TRAIN = "Train" as const; + +// Structures +export const UT_CITY = "City" as const; +export const UT_PORT = "Port" as const; +export const UT_FACTORY = "Factory" as const; +export const UT_DEFENSE_POST = "Defense Post" as const; +export const UT_SAM_LAUNCHER = "SAM Launcher" as const; +export const UT_MISSILE_SILO = "Missile Silo" as const; + +// --------------------------------------------------------------------------- +// Derived sets +// --------------------------------------------------------------------------- + +export const STRUCTURE_TYPES: ReadonlySet = new Set([ + UT_CITY, + UT_PORT, + UT_FACTORY, + UT_DEFENSE_POST, + UT_SAM_LAUNCHER, + UT_MISSILE_SILO, +]); + +export const NUKE_TYPES: ReadonlySet = new Set([ + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, +]); + +/** Blast radii (in tiles) matching upstream DefaultConfig.nukeMagnitudes(). */ +export const NUKE_MAGNITUDES: Readonly< + Record +> = { + [UT_ATOM_BOMB]: { inner: 12, outer: 30 }, + [UT_HYDROGEN_BOMB]: { inner: 80, outer: 100 }, + [UT_MIRV_WARHEAD]: { inner: 12, outer: 18 }, +}; + +// --------------------------------------------------------------------------- +// Ordered lists (atlas column order — used by GPU passes + header) +// --------------------------------------------------------------------------- + +/** All unit type strings in the canonical order used by RendererConfig.unitTypes. */ +export const ALL_UNIT_TYPES = [ + UT_TRANSPORT, + UT_TRADE_SHIP, + UT_WARSHIP, + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_SAM_MISSILE, + UT_SHELL, + UT_MIRV_WARHEAD, + UT_CITY, + UT_PORT, + UT_FACTORY, + UT_DEFENSE_POST, + UT_SAM_LAUNCHER, + UT_MISSILE_SILO, + UT_TRAIN, +] as const;