Files
OpenFrontIO/src/client/render/frame/Upload.ts
T
Evan 2794ab1270 feat: nuke-trail cosmetic effect + tabbed effects picker (#4466)
## What

Adds a **`nukeTrail`** cosmetic effectType alongside
`transportShipTrail`, so nukes leave a trail colored by their own
gradient/transition effect — independent of the boat-trail effect (a
player can run both). Also reorganizes the effects picker and store into
per-effectType **tabs**.

## Rendering

Boat and nuke trails are stamped into **one** trail texture keyed only
by owner, so independent coloring needs a per-tile unit-class signal:

- **Trail texture** `R8UI` → `R16UI`: texel = `ownerID(bits 0-11) |
nukeBit(bit 12)`. `TrailManager` stamps the bit (and preserves it when
repainting on unit death); the `Uint8Array`→`Uint16Array` ripple +
`UNSIGNED_SHORT` uploads flow through `GpuResources`, `TrailPass`,
`Upload`, `MapRenderer`, `Renderer`, `FrameData`.
- **Effect texture** widened to two stacked blocks
(`TRAIL_EFFECT_BLOCKS`): rows 0–7 = transportShipTrail, rows 8–15 =
nukeTrail. `writeEffectEntry(…, rowBase)`; `syncPlayerEffects` resolves
both effectTypes.
- **Shader** masks the owner, derives `rowBase` from the nuke bit,
offsets every row, and reuses the gradient/transition decode.
- Bonus: the 12-bit owner mask lifts the old `R8UI` >255-player
truncation.

## Schema / server / UI

- Shared attributes schema renamed `TransportShipTrail…` →
**`TrailEffectAttributesSchema`** (it's no longer ship-specific);
`NukeTrailEffectSchema` added to `EffectSchema` +
`CosmeticsSchema.effects`. `EFFECT_TYPES = [transportShipTrail,
nukeTrail]`.
- Server `Privilege`, selection, and the picker grid all iterate
`EFFECT_TYPES`, so they handle the new type with **no per-type code**.
- **Tabs:** the selection modal uses one tab per effectType
(`BaseModal`'s native tabs); the **store's** EFFECTS panel gets an
internal sub-tab bar (its top-level PACKS/EFFECTS tabs can't nest). Tabs
are always present, so a type you own entirely still appears as an empty
tab (previously the boat-trail section vanished from the store when you
owned everything).

## Review

A 3-angle adversarial review (bit-packing, type-ripple, GLSL/data-flow)
**refuted** the correctness concerns — the R16UI format, masking, and
block layout agree across `TrailManager` / shader / builder. The minor
survivors (a preview that only resolved boat trails, stale comments)
were fixed.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` — all clean.
- Schema/`Privilege` tests updated for `nukeTrail` (96 tests pass).
- The GL trail + tab UI are visual — not yet verified in a running game.
- The catalog (`cosmetics.json`, closed-source API) must ship the
`effects.nukeTrail` block for the effect to appear in production.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:13:41 -07:00

120 lines
3.7 KiB
TypeScript

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: Uint16Array,
): void;
uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void;
uploadLiveTrailDelta(
trailState: Uint16Array,
dirtyRowMin: number,
dirtyRowMax: number,
): void;
uploadRailroadState(data: Uint8Array): void;
applyRailroadDust(tileRefs: number[]): void;
updateUnits(units: ReadonlyMap<number, UnitState>, gameTick: number): void;
updateStructures(units: ReadonlyMap<number, UnitState>): void;
applyDeadUnits(deadUnits: DeadUnitFx[]): void;
applyConquestEvents(events: ConquestFx[]): void;
applyBonusEvents(events: BonusEvent[]): void;
updateAttackRings(rings: AttackRingInput[]): void;
updateNukeTelegraphs(data: NukeTelegraphData[]): void;
updateNames(
names: ReadonlyMap<string, NameEntry>,
players: ReadonlyMap<number, PlayerState>,
snap: boolean,
statusData?: ReadonlyMap<number, PlayerStatusData>,
): void;
updateRelations(data: Uint8Array, size: number): void;
setSAMAllianceClusters(clusters: ReadonlyMap<number, number>): void;
}
/**
* Upload a FrameData snapshot to the GPU view.
*
* A straightforward dispatch loop: pushes tile/trail deltas, then all the
* conditional railroad/ephemeral uploads, to the view's update*() methods.
*/
export function uploadFrameData(
view: FrameUploadTarget,
frame: FrameData,
): void {
// --- Tiles + Trails ---
// changedTiles[] means "only these tiles changed" (empty = nothing changed,
// skip upload). null means "no delta info" (first tick — full upload needed).
if (frame.changedTiles) {
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 {
view.uploadTileAndTrailState(frame.tileState, 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, false, frame.playerStatus);
// --- Relations ---
// Gated: updateRelations triggers a full-map border recompute downstream,
// so only push when the matrix was actually rebuilt this tick.
if (frame.relationsDirty) {
view.updateRelations(frame.relationMatrix, frame.relationSize);
}
// --- Alliance clusters (SAM pass) ---
view.setSAMAllianceClusters(frame.allianceClusters);
}