diff --git a/resources/lang/en.json b/resources/lang/en.json index b3bbfce22..b14562bb2 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -397,6 +397,7 @@ "search": "Search...", "title": "Effects", "type": { + "nukeTrail": "Nuke Trail", "transportShipTrail": "Boat Trail" } }, diff --git a/src/client/EffectsInput.ts b/src/client/EffectsInput.ts index f9404b73b..ea14c5c93 100644 --- a/src/client/EffectsInput.ts +++ b/src/client/EffectsInput.ts @@ -1,8 +1,9 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { + EFFECT_TYPES, findEffect, - TransportShipTrailAttributes, + TrailEffectAttributes, } from "../core/CosmeticSchemas"; import { EFFECTS_KEY, @@ -15,26 +16,26 @@ import { translateText } from "./Utils"; @customElement("effects-input") export class EffectsInput extends LitElement { - // The selected transport-ship-trail attributes, if any (one effectType today). + // The selected trail effect's attributes for the button preview, if any. // Not named `attributes` — that collides with HTMLElement.attributes. - @state() private trailAttributes: TransportShipTrailAttributes | null = null; + @state() private trailAttributes: TrailEffectAttributes | null = null; private _abortController: AbortController | null = null; // PlayerEffect is just { name, effectType }; resolve the visual style from the - // cosmetics catalog by (effectType, name). - private async resolveTrailAttributes(): Promise { + // cosmetics catalog by (effectType, name). The button shows a single swatch, + // so preview the first selected trail effect across effectTypes (boat trail + // before nuke trail, per EFFECT_TYPES order). + private async resolveTrailAttributes(): Promise { const cosmetics = await getPlayerCosmetics(); - const name = cosmetics.effects?.["transportShipTrail"]?.name; - if (!name) return null; - const effect = findEffect( - await fetchCosmetics(), - "transportShipTrail", - name, - ); - return effect?.effectType === "transportShipTrail" - ? effect.attributes - : null; + const catalog = await fetchCosmetics(); + for (const effectType of EFFECT_TYPES) { + const name = cosmetics.effects?.[effectType]?.name; + if (!name) continue; + const effect = findEffect(catalog, effectType, name); + if (effect) return effect.attributes; + } + return null; } private _onCosmeticSelected = async () => { diff --git a/src/client/EffectsModal.ts b/src/client/EffectsModal.ts index 8a8df80fd..98eb8766a 100644 --- a/src/client/EffectsModal.ts +++ b/src/client/EffectsModal.ts @@ -1,7 +1,7 @@ import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; -import { Cosmetics } from "../core/CosmeticSchemas"; +import { Cosmetics, EFFECT_TYPES } from "../core/CosmeticSchemas"; import { BaseModal } from "./components/BaseModal"; import "./components/EffectsGrid"; import "./components/NotLoggedInWarning"; @@ -17,6 +17,16 @@ export class EffectsModal extends BaseModal { @state() private userMeResponse: UserMeResponse | false = false; @state() private search = ""; + // One tab per trail effectType; BaseModal owns activeTab + renders the bar. + protected modalConfig() { + return { + tabs: EFFECT_TYPES.map((type) => ({ + key: type, + label: translateText(`effects.type.${type}`), + })), + }; + } + private handleSearch(event: Event) { this.search = (event.target as HTMLInputElement).value; } @@ -65,7 +75,7 @@ export class EffectsModal extends BaseModal { `; } - protected renderBody() { + protected renderBody(tab: string) { return html`
@@ -85,6 +95,7 @@ export class EffectsModal extends BaseModal { .cosmetics=${this.cosmetics} .userMeResponse=${this.userMeResponse} .search=${this.search} + .effectType=${tab} >
`; diff --git a/src/client/Store.ts b/src/client/Store.ts index f4f3c672a..0c73b51c7 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -145,10 +145,11 @@ export class StoreModal extends BaseModal { } private renderEffectGrid(): TemplateResult { - // Grouped by effectType with a sub-header per type (see ), - // matching the home selection modal. + // A sub-tab per effectType (Boat Trail / Nuke Trail); each tab opens that + // type's grid. Tabs are always present, even when a type has nothing to buy. return html` { + const selected = p.cosmetics.effects?.[effectType]; + if (!selected) return; + const effect = findEffect(catalog, effectType, selected.name); + if (!effect || effect.effectType !== effectType) return; + const rowBase = block * MAX_TRAIL_COLORS; + if (this.writeEffectEntry(smallID, effect.attributes, rowBase)) { + dirty = true; + } + }); } if (dirty) this.view.updateEffectPalette(this.effectPalette); } /** - * Encode a player's transport-ship-trail effect into the effect palette. - * Layout matches trail.frag.glsl: row r holds color r's rgb, and the spare - * alpha channels (rows 0–3 always exist) carry the scalar params — + * Encode a player's trail effect into one block of the effect palette. The + * block starts at row `rowBase` (0 = transportShipTrail, MAX_TRAIL_COLORS = + * nukeTrail). Within the block, row r holds color r's rgb, and the spare alpha + * channels (rows rowBase+0..3 always exist) carry the scalar params — * row 0.a = color count (0 → the shader falls back to the territory color), * row 1.a = styleId (0 = gradient, 1 = transition), * row 2.a = scalar0 (gradient: colorSize; transition: frequency), @@ -321,7 +343,8 @@ export class WebGLFrameBuilder { */ private writeEffectEntry( smallID: number, - attrs: TransportShipTrailAttributes, + attrs: TrailEffectAttributes, + rowBase: number, ): boolean { const colors = attrs.colors .map((s) => colord(s)) @@ -329,7 +352,7 @@ export class WebGLFrameBuilder { .slice(0, MAX_TRAIL_COLORS) .map((c) => c.toRgb()); for (let r = 0; r < MAX_TRAIL_COLORS; r++) { - const off = (r * PALETTE_SIZE + smallID) * 4; + const off = ((rowBase + r) * PALETTE_SIZE + smallID) * 4; const c = colors[r] ?? { r: 0, g: 0, b: 0 }; this.effectPalette[off] = c.r / 255; this.effectPalette[off + 1] = c.g / 255; @@ -340,10 +363,12 @@ export class WebGLFrameBuilder { attrs.type === "transition" ? [1, attrs.frequency, 0] : [0, attrs.colorSize, attrs.movementSpeed]; - this.effectPalette[(0 * PALETTE_SIZE + smallID) * 4 + 3] = colors.length; - this.effectPalette[(1 * PALETTE_SIZE + smallID) * 4 + 3] = styleId; - this.effectPalette[(2 * PALETTE_SIZE + smallID) * 4 + 3] = scalar0; - this.effectPalette[(3 * PALETTE_SIZE + smallID) * 4 + 3] = scalar1; + const alpha = (row: number) => + ((rowBase + row) * PALETTE_SIZE + smallID) * 4 + 3; + this.effectPalette[alpha(0)] = colors.length; + this.effectPalette[alpha(1)] = styleId; + this.effectPalette[alpha(2)] = scalar0; + this.effectPalette[alpha(3)] = scalar1; return colors.length > 0; } diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index ef17fa7da..c4288cd6e 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -186,7 +186,8 @@ export class CosmeticButton extends LitElement { ${translateText("territory_patterns.pattern.default")}
`; } - // Only effectType today is transportShipTrail; c.attributes is its style. + // Every trail effectType (transportShipTrail, nukeTrail) shares the same + // attributes shape; c.attributes is the gradient/transition style. return html` this.requestUpdate(); @@ -131,44 +140,80 @@ export class EffectsGrid extends LitElement { >`; } + // Store's sub-tab bar: one tab per effectType, always present, styled like the + // store's top-level tabs (blue active + underline). + private renderTabBar(): TemplateResult { + return html` +
+ ${EFFECT_TYPES.map((type) => { + const active = this.activeType === type; + return html``; + })} +
+ `; + } + render() { const all = resolveCosmetics( this.cosmetics, this.userMeResponse, this.affiliateCode, ); - const sections = EFFECT_TYPES.map((type) => ({ - type, - items: this.itemsForType(all, type), - })).filter((s) => s.items.length > 0); + // The active single type: the tab's selection (tabbed) or the effectType + // prop; null = all types stacked with sub-headers. + const activeType = this.tabbed ? this.activeType : this.effectType; + const types: readonly EffectType[] = activeType + ? [activeType] + : EFFECT_TYPES; + const sections = types + .map((type) => ({ type, items: this.itemsForType(all, type) })) + .filter((s) => s.items.length > 0); + let panel: TemplateResult; if (sections.length === 0) { - return html`
- ${translateText("store.no_effects")} -
`; + // A single-type view keeps its (empty) panel — the tab stays present and + // just shows nothing. Only the all-types view shows the "no effects" notice. + panel = activeType + ? html`
` + : html`
+ ${translateText("store.no_effects")} +
`; + } else { + panel = html` +
+ ${sections.map( + (s) => html` +
+ ${activeType + ? nothing + : html`

+ ${translateText(`effects.type.${s.type}`)} +

`} +
+ ${s.items.map((r) => this.renderTile(s.type, r))} +
+
+ `, + )} +
+ `; } - return html` -
- ${sections.map( - (s) => html` -
-

- ${translateText(`effects.type.${s.type}`)} -

-
- ${s.items.map((r) => this.renderTile(s.type, r))} -
-
- `, - )} -
- `; + return this.tabbed ? html`${this.renderTabBar()}${panel}` : panel; } } diff --git a/src/client/render/frame/TrailManager.ts b/src/client/render/frame/TrailManager.ts index 01e89b797..456029eac 100644 --- a/src/client/render/frame/TrailManager.ts +++ b/src/client/render/frame/TrailManager.ts @@ -2,8 +2,10 @@ * 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. + * (bresenham) with a 16-bit value: owner smallID in bits 0-11, plus a nuke bit + * (bit 12) so nuke trails can be colored by a different cosmetic effect than + * boat trails. When a unit dies its tiles are cleared, with overlapping tiles + * repainted from any surviving unit (preserving that survivor's full value). * * Simpler than the original openfront-workspace TrailManager (no MotionPlanStore * dependency). Since we run in the main thread reading GameView directly, we @@ -13,14 +15,20 @@ import type { UnitState } from "../types"; import { SMOOTHED_NUKE_TYPES } from "../types"; +// Bit 12 of the trail texel flags a nuke trail (vs a boat trail); bits 0-11 are +// the owner smallID. Must match the mask/shift in trail.frag.glsl (owner & 0xFFF, +// (val >> 12) & 1). SMOOTHED_NUKE_TYPES is exactly the nuke trail set today. +export const NUKE_TRAIL_BIT = 1 << 12; + interface UnitTrail { - ownerID: number; + // Stamped texel value: owner smallID | (isNuke ? NUKE_TRAIL_BIT : 0). + value: number; tiles: Set; lastPosStamped: number; // tile ref of the last position we stamped } export class TrailManager { - private readonly trailState: Uint8Array; + private readonly trailState: Uint16Array; private readonly unitTrails = new Map(); private readonly mapW: number; @@ -29,10 +37,10 @@ export class TrailManager { constructor(mapW: number, mapH: number) { this.mapW = mapW; - this.trailState = new Uint8Array(mapW * mapH); + this.trailState = new Uint16Array(mapW * mapH); } - getTrailState(): Uint8Array { + getTrailState(): Uint16Array { return this.trailState; } @@ -65,20 +73,20 @@ export class TrailManager { for (const id of trackedIds) { const unit = units.get(id); if (!unit) continue; + const isNuke = SMOOTHED_NUKE_TYPES.has(unit.unitType); let trail = this.unitTrails.get(id); if (!trail) { - trail = { ownerID: unit.ownerID, tiles: new Set(), lastPosStamped: -1 }; + const value = unit.ownerID | (isNuke ? NUKE_TRAIL_BIT : 0); + trail = { value, tiles: new Set(), lastPosStamped: -1 }; this.unitTrails.set(id, trail); } // Smoothed nukes render lastPos→pos interpolated per frame (UnitPass); // stamp their trail only up to lastPos so the tail never leads the // rendered missile. - const head = SMOOTHED_NUKE_TYPES.has(unit.unitType) - ? unit.lastPos - : unit.pos; + const head = isNuke ? unit.lastPos : unit.pos; if (trail.lastPosStamped === -1) { // First sighting — just stamp the current head - this.stamp(head, trail.ownerID); + this.stamp(head, trail.value); trail.tiles.add(head); trail.lastPosStamped = head; } else if (trail.lastPosStamped !== head) { @@ -94,17 +102,18 @@ export class TrailManager { const deadTiles = trail.tiles; for (const ref of deadTiles) this.stamp(ref, 0); this.unitTrails.delete(id); - // Repaint any tiles that overlap surviving trails + // Repaint any tiles that overlap surviving trails — with the survivor's + // full value so its nuke bit (and owner) is preserved, not just the owner. for (const other of this.unitTrails.values()) { for (const ref of deadTiles) { - if (other.tiles.has(ref)) this.stamp(ref, other.ownerID); + if (other.tiles.has(ref)) this.stamp(ref, other.value); } } } } - private stamp(ref: number, ownerID: number): void { - this.trailState[ref] = ownerID; + private stamp(ref: number, value: number): void { + this.trailState[ref] = value; const row = (ref / this.mapW) | 0; if (row < this._dirtyRowMin) this._dirtyRowMin = row; if (row > this._dirtyRowMax) this._dirtyRowMax = row; @@ -124,7 +133,7 @@ export class TrailManager { for (;;) { const ref = y0 * w + x0; trail.tiles.add(ref); - this.stamp(ref, trail.ownerID); + this.stamp(ref, trail.value); if (x0 === x1 && y0 === y1) break; const e2 = 2 * err; if (e2 >= dy) { diff --git a/src/client/render/frame/Upload.ts b/src/client/render/frame/Upload.ts index 2344af2f4..85367510f 100644 --- a/src/client/render/frame/Upload.ts +++ b/src/client/render/frame/Upload.ts @@ -17,10 +17,13 @@ import type { * Satisfied by GameView through TypeScript structural typing. */ export interface FrameUploadTarget { - uploadTileAndTrailState(tileState: Uint16Array, trailState: Uint8Array): void; + uploadTileAndTrailState( + tileState: Uint16Array, + trailState: Uint16Array, + ): void; uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void; uploadLiveTrailDelta( - trailState: Uint8Array, + trailState: Uint16Array, dirtyRowMin: number, dirtyRowMax: number, ): void; diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index af7fb92a3..899787116 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -116,7 +116,7 @@ export class MapRenderer { this.renderer?.uploadLiveDelta(tileState, changedTiles); } uploadLiveTrailDelta( - trailState: Uint8Array, + trailState: Uint16Array, dirtyRowMin: number, dirtyRowMax: number, ): void { @@ -125,7 +125,7 @@ export class MapRenderer { /** Upload full tile + trail state without resetting bloom (for live play). */ uploadTileAndTrailState( tileState: Uint16Array, - trailState: Uint8Array, + trailState: Uint16Array, ): void { this.renderer?.uploadTileAndTrailState(tileState, trailState); } diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index c8e2c240c..e241e00a9 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -59,7 +59,12 @@ import { UnitPass } from "./passes/UnitPass"; import { WorldTextPass } from "./passes/WorldTextPass"; import type { RenderSettings } from "./RenderSettings"; import { AffiliationPalette } from "./utils/Affiliation"; -import { getPaletteSize, hexToRgb, MAX_TRAIL_COLORS } from "./utils/ColorUtils"; +import { + getPaletteSize, + hexToRgb, + MAX_TRAIL_COLORS, + TRAIL_EFFECT_BLOCKS, +} from "./utils/ColorUtils"; import { renderDpr } from "./utils/Dpr"; import { createTexture2D, @@ -132,9 +137,11 @@ export class GPURenderer { private paletteTex: WebGLTexture; private paletteData: Float32Array; - // Per-player transport-ship-trail gradient, keyed by smallID (RGBA32F, - // 4096×MAX_TRAIL_COLORS): row r = color r's rgb; row 0's alpha = color count. - // Sampled by TrailPass. + // Per-player trail-effect palette, keyed by smallID (RGBA32F, + // 4096×(MAX_TRAIL_COLORS·TRAIL_EFFECT_BLOCKS)): one MAX_TRAIL_COLORS-row block + // per trail effectType (block 0 = transportShipTrail, block 1 = nukeTrail). + // Sampled by TrailPass; the shader picks the block from the trail tile's nuke + // bit. private effectTex: WebGLTexture; private patternMetaTex: WebGLTexture; private patternDataTex: WebGLTexture; @@ -239,15 +246,17 @@ export class GPURenderer { filter: gl.NEAREST, }); - // Per-player trail-effect texture (one row per gradient color). Starts zeroed - // (color count 0 everywhere = no effect → trail uses territory color). + // Per-player trail-effect texture: TRAIL_EFFECT_BLOCKS stacked blocks of + // MAX_TRAIL_COLORS rows (block 0 = transportShipTrail, block 1 = nukeTrail). + // Starts zeroed (color count 0 everywhere = no effect → territory color). + const effectRows = MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS; this.effectTex = createTexture2D(gl, { width: palW, - height: MAX_TRAIL_COLORS, + height: effectRows, internalFormat: gl.RGBA32F, format: gl.RGBA, type: gl.FLOAT, - data: new Float32Array(palW * MAX_TRAIL_COLORS * 4), + data: new Float32Array(palW * effectRows * 4), filter: gl.NEAREST, }); @@ -603,7 +612,7 @@ export class GPURenderer { uploadTileAndTrailState( tileState: Uint16Array, - trailState: Uint8Array, + trailState: Uint16Array, ): void { this.territoryPass.setLiveRef(tileState); this.trailPass.setLiveRef(trailState); @@ -614,7 +623,7 @@ export class GPURenderer { } uploadLiveTrailDelta( - trailState: Uint8Array, + trailState: Uint16Array, dirtyRowMin: number, dirtyRowMax: number, ): void { @@ -657,7 +666,7 @@ export class GPURenderer { 0, 0, getPaletteSize(), - MAX_TRAIL_COLORS, + MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS, gl.RGBA, gl.FLOAT, effectData, diff --git a/src/client/render/gl/passes/TrailPass.ts b/src/client/render/gl/passes/TrailPass.ts index c0cb7a383..b4d4729c7 100644 --- a/src/client/render/gl/passes/TrailPass.ts +++ b/src/client/render/gl/passes/TrailPass.ts @@ -1,13 +1,13 @@ /** - * TrailPass — boat trail lines. + * TrailPass — boat + nuke trail lines. * - * Owns the CPU-side trail state (R8UI, 0=none, 1–255=ownerID), the dirty-row - * bookkeeping for partial GPU uploads, and the trail fragment shader that - * draws the colored breadcrumb behind moving units. + * Owns the CPU-side trail state (R16UI: 0=none, bits 0-11=ownerID, bit 12=nuke + * trail), the dirty-row bookkeeping for partial GPU uploads, and the trail + * fragment shader that draws the colored breadcrumb behind moving units. */ import type { RenderSettings } from "../RenderSettings"; -import { getPaletteSize } from "../utils/ColorUtils"; +import { getPaletteSize, MAX_TRAIL_COLORS } from "../utils/ColorUtils"; import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils"; import { TILE_DEFINES } from "../utils/TileCodec"; @@ -37,12 +37,12 @@ export class TrailPass { // so the value stays small and sin()/fract() don't quantize over long sessions. private readonly startTime = performance.now(); - /** CPU-side trail state (R8UI, 0=none, 1–255=ownerID). */ - private cpuTrailState: Uint8Array; + /** CPU-side trail state (R16UI: 0=none, owner in bits 0-11, nuke bit 12). */ + private cpuTrailState: Uint16Array; private trailsDirty = false; /** Live-game reference — bypasses memcpy. Null for replay path. */ - private liveTrailRef: Uint8Array | null = null; + private liveTrailRef: Uint16Array | null = null; /** Dirty row range for partial trail upload. Infinity/-1 = full upload. */ private dirtyRowMin = Infinity; @@ -64,13 +64,14 @@ export class TrailPass { this.trailTex = trailTex; this.paletteTex = paletteTex; this.effectTex = effectTex; - this.cpuTrailState = new Uint8Array(mapW * mapH); + this.cpuTrailState = new Uint16Array(mapW * mapH); this.program = createProgram( gl, overlayVertSrc, shaderSrc(trailFragSrc, { PALETTE_SIZE: getPaletteSize(), + MAX_TRAIL_COLORS, ...TILE_DEFINES, }), ); @@ -101,14 +102,14 @@ export class TrailPass { // --------------------------------------------------------------------------- /** Live-game path: reference the game's own trail array directly. */ - setLiveRef(trailState: Uint8Array): void { + setLiveRef(trailState: Uint16Array): void { this.liveTrailRef = trailState; this.trailsDirty = true; } /** Live trail delta: update live ref + accept dirty row range from TrailManager. */ applyLiveDelta( - trailState: Uint8Array, + trailState: Uint16Array, dirtyRowMin: number, dirtyRowMax: number, ): void { @@ -145,7 +146,7 @@ export class TrailPass { this.mapW, rowCount, gl.RED_INTEGER, - gl.UNSIGNED_BYTE, + gl.UNSIGNED_SHORT, src.subarray(offset, offset + rowCount * this.mapW), ); } else { @@ -158,7 +159,7 @@ export class TrailPass { this.mapW, this.mapH, gl.RED_INTEGER, - gl.UNSIGNED_BYTE, + gl.UNSIGNED_SHORT, src, ); } diff --git a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl index cc2144d5c..5ad5413f8 100644 --- a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl +++ b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl @@ -1,75 +1,83 @@ -#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 sampler2D uEffect; // RGBA32F — trail effect, keyed by ownerID: - // row r = color r's rgb; spare alphas hold scalars: - // row 0.a = color count (0 = no effect → territory color), - // row 1.a = styleId (0 = gradient, 1 = transition), - // row 2.a = scalar0 (gradient colorSize / transition freq), - // row 3.a = scalar1 (gradient movementSpeed) -uniform vec2 uMapSize; -uniform float uTrailAlpha; -uniform float uTime; // seconds, for animated effect styles -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) { - // Alt view recolors everything by affiliation — effects stay off so the - // strategic overlay reads consistently. - color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb; - } else { - int owner = int(trailOwner); - int count = int(texelFetch(uEffect, ivec2(owner, 0), 0).a + 0.5); - if (count <= 0) { - // No effect — fall back to the player's territory color. - float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE); - color = texture(uPalette, vec2(u, 0.25)).rgb; - } else if (count == 1) { - // Single color — flat trail. - color = texelFetch(uEffect, ivec2(owner, 0), 0).rgb; - } else if (int(texelFetch(uEffect, ivec2(owner, 1), 0).a + 0.5) == 1) { - // transition — the whole trail is one color at a time, cross-fading - // through the list over time. frequency = color changes per second. - float frequency = texelFetch(uEffect, ivec2(owner, 2), 0).a; - float t = uTime * frequency; - int i = int(t) % count; - int j = (i + 1) % count; - vec3 a = texelFetch(uEffect, ivec2(owner, i), 0).rgb; - vec3 b = texelFetch(uEffect, ivec2(owner, j), 0).rgb; - color = mix(a, b, fract(t)); - } else { - // gradient — cyclic gradient banded across the map (world-space diagonal), - // scrolling over time so a moving trail shifts hue along it. colorSize - // scales the band width (colorSize = 1 ≈ 4 tiles per band); movementSpeed - // = tiles/sec the bands travel. - float colorSize = max(texelFetch(uEffect, ivec2(owner, 2), 0).a, 0.001); - float movementSpeed = texelFetch(uEffect, ivec2(owner, 3), 0).a; - // 4.0 = tiles per band at colorSize 1; tune for default band thickness. - float cycle = colorSize * 4.0 * float(count); - float phase = - fract((vWorldPos.x + vWorldPos.y - uTime * movementSpeed) / cycle); - float f = phase * float(count); - int i = int(f) % count; - int j = (i + 1) % count; - vec3 a = texelFetch(uEffect, ivec2(owner, i), 0).rgb; - vec3 b = texelFetch(uEffect, ivec2(owner, j), 0).rgb; - color = mix(a, b, fract(f)); - } - } - fragColor = vec4(color, uTrailAlpha); -} +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTrailTex; // R16UI — trail texel: owner smallID (bits 0-11) + // + nuke bit (bit 12); 0 = no trail +uniform sampler2D uPalette; // RGBA32F — player colors +uniform sampler2D uAffiliation; // RGBA8 — affiliation colors (row 0 = border, row 1 = unit) +uniform sampler2D uEffect; // RGBA32F — trail effect, keyed by ownerID. Stacked blocks + // of MAX_TRAIL_COLORS rows: block 0 = transportShipTrail, + // block 1 = nukeTrail. Within a block (rowBase = block start): + // row r = color r's rgb; spare alphas hold scalars: + // row 0.a = color count (0 = no effect → territory color), + // row 1.a = styleId (0 = gradient, 1 = transition), + // row 2.a = scalar0 (gradient colorSize / transition freq), + // row 3.a = scalar1 (gradient movementSpeed) +uniform vec2 uMapSize; +uniform float uTrailAlpha; +uniform float uTime; // seconds, for animated effect styles +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 trailVal = texelFetch(uTrailTex, tc, 0).r; + uint owner = trailVal & 0xFFFu; // bits 0-11 = owner smallID + if (owner == 0u) discard; + uint isNuke = (trailVal >> 12) & 1u; // bit 12 = nuke trail + + vec3 color; + if (uAltView != 0) { + // Alt view recolors everything by affiliation — effects stay off so the + // strategic overlay reads consistently. + color = texelFetch(uAffiliation, ivec2(int(owner), 1), 0).rgb; + } else { + int o = int(owner); + // Boat trails read block 0; nuke trails read block 1 (rows offset by + // MAX_TRAIL_COLORS). The effect attributes are otherwise identical. + int rowBase = isNuke == 1u ? MAX_TRAIL_COLORS : 0; + int count = int(texelFetch(uEffect, ivec2(o, rowBase), 0).a + 0.5); + if (count <= 0) { + // No effect — fall back to the player's territory color. + float u = (float(owner) + 0.5) / float(PALETTE_SIZE); + color = texture(uPalette, vec2(u, 0.25)).rgb; + } else if (count == 1) { + // Single color — flat trail. + color = texelFetch(uEffect, ivec2(o, rowBase), 0).rgb; + } else if (int(texelFetch(uEffect, ivec2(o, rowBase + 1), 0).a + 0.5) == 1) { + // transition — the whole trail is one color at a time, cross-fading + // through the list over time. frequency = color changes per second. + float frequency = texelFetch(uEffect, ivec2(o, rowBase + 2), 0).a; + float t = uTime * frequency; + int i = int(t) % count; + int j = (i + 1) % count; + vec3 a = texelFetch(uEffect, ivec2(o, rowBase + i), 0).rgb; + vec3 b = texelFetch(uEffect, ivec2(o, rowBase + j), 0).rgb; + color = mix(a, b, fract(t)); + } else { + // gradient — cyclic gradient banded across the map (world-space diagonal), + // scrolling over time so a moving trail shifts hue along it. colorSize + // scales the band width (colorSize = 1 ≈ 4 tiles per band); movementSpeed + // = tiles/sec the bands travel. + float colorSize = max(texelFetch(uEffect, ivec2(o, rowBase + 2), 0).a, 0.001); + float movementSpeed = texelFetch(uEffect, ivec2(o, rowBase + 3), 0).a; + // 4.0 = tiles per band at colorSize 1; tune for default band thickness. + float cycle = colorSize * 4.0 * float(count); + float phase = + fract((vWorldPos.x + vWorldPos.y - uTime * movementSpeed) / cycle); + float f = phase * float(count); + int i = int(f) % count; + int j = (i + 1) % count; + vec3 a = texelFetch(uEffect, ivec2(o, rowBase + i), 0).rgb; + vec3 b = texelFetch(uEffect, ivec2(o, rowBase + j), 0).rgb; + color = mix(a, b, fract(f)); + } + } + fragColor = vec4(color, uTrailAlpha); +} diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts index 8da6ac2df..1f3938eb4 100644 --- a/src/client/render/gl/utils/ColorUtils.ts +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -18,12 +18,20 @@ export function getPaletteSize(): number { } /** - * Max colors per transport-ship-trail gradient = rows in the trail-effect - * texture. Longer catalog color lists are truncated. Shared so the CPU side - * that fills the texture and the GPU side that allocates it can't drift. + * Max colors per trail gradient = rows per block in the trail-effect texture. + * Longer catalog color lists are truncated. Shared so the CPU side that fills + * the texture and the GPU side that allocates it can't drift. */ export const MAX_TRAIL_COLORS = 8; +/** + * The trail-effect texture stacks one MAX_TRAIL_COLORS-row block per trail + * effectType: block 0 = transportShipTrail, block 1 = nukeTrail (matching the + * nuke bit in trail.frag.glsl). Bump this if another trailed effectType is added + * (and add its rowBase branch to the shader). + */ +export const TRAIL_EFFECT_BLOCKS = 2; + // ---------- Terrain ---------- /** Parse a "#rrggbb" (or "rrggbb") hex string into an RGB tuple, or null. */ diff --git a/src/client/render/gl/utils/GpuResources.ts b/src/client/render/gl/utils/GpuResources.ts index d9dc7c352..84a59c9b7 100644 --- a/src/client/render/gl/utils/GpuResources.ts +++ b/src/client/render/gl/utils/GpuResources.ts @@ -9,7 +9,7 @@ import { createTexture2D } from "./GlUtils"; export interface GPUResources { tileTex: WebGLTexture; // R16UI — tile ownership + flags - trailTex: WebGLTexture; // R8UI — trail owner per tile + trailTex: WebGLTexture; // R16UI — trail owner (bits 0-11) + nuke bit (12) paletteTex: WebGLTexture; // RGBA32F — player colors borderTex: WebGLTexture; // RGBA8 — border type + defense + relation (G unused) heatTexA: WebGLTexture; // R8 — fallout heat ping-pong A @@ -36,9 +36,9 @@ export function createGPUResources( const trailTex = createTexture2D(gl, { width: mapW, height: mapH, - internalFormat: gl.R8UI, + internalFormat: gl.R16UI, format: gl.RED_INTEGER, - type: gl.UNSIGNED_BYTE, + type: gl.UNSIGNED_SHORT, data: null, filter: gl.NEAREST, }); diff --git a/src/client/render/types/FrameData.ts b/src/client/render/types/FrameData.ts index abc10a48a..583838191 100644 --- a/src/client/render/types/FrameData.ts +++ b/src/client/render/types/FrameData.ts @@ -23,7 +23,7 @@ export interface FrameData { /** True during spawn phase (before gameplay begins). */ readonly inSpawnPhase: boolean; readonly tileState: Uint16Array; - readonly trailState: Uint8Array; + readonly trailState: Uint16Array; readonly railroadState: Uint8Array; readonly units: ReadonlyMap; readonly players: ReadonlyMap; diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index 781920976..e1105a016 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -9,13 +9,12 @@ export type Flag = z.infer; export type Skin = z.infer; export type Pack = z.infer; export type Subscription = z.infer; -// An effect cosmetic of any type — discriminated on effectType (today only -// transportShipTrail; gains a member per effectType). +// An effect cosmetic of any type — discriminated on effectType (today +// transportShipTrail + nukeTrail; gains a member per effectType). export type Effect = z.infer; export type EffectType = z.infer; -export type TransportShipTrailAttributes = z.infer< - typeof TransportShipTrailAttributesSchema ->; +// Shared by every trail effectType (transportShipTrail, nukeTrail, …). +export type TrailEffectAttributes = z.infer; export type PatternName = z.infer; export type Product = z.infer; export type ColorPalette = z.infer; @@ -99,10 +98,12 @@ export const SkinSchema = CosmeticSchema.extend({ // stay precisely typed; an effectType the client doesn't list is dropped at parse // (the UI only handles EFFECT_TYPES), so a new server-side effectType never fails // the whole cosmetics parse. -export const EFFECT_TYPES = ["transportShipTrail"] as const; +export const EFFECT_TYPES = ["transportShipTrail", "nukeTrail"] as const; export const EffectTypeSchema = z.enum(EFFECT_TYPES); -// A boat trail effect, discriminated on `type`: +// A trail effect, discriminated on `type`. Shared by every trail effectType +// (transport-ship trails, nuke trails, …) — the attributes are the same; only +// the unit whose trail they color differs. // - "gradient": the colors form a spatial gradient banded along the trail. // `colorSize` = band width in tiles (larger = bigger bands); `movementSpeed` // = how fast the bands scroll, in tiles/sec (0 = static). @@ -111,7 +112,7 @@ export const EffectTypeSchema = z.enum(EFFECT_TYPES); // solid = a single-color list; rainbow = the spectrum as a gradient. Colors are // unvalidated strings here; the renderer drops any it can't parse (and an empty // list falls back to the player's territory color). -export const TransportShipTrailAttributesSchema = z.discriminatedUnion("type", [ +export const TrailEffectAttributesSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("gradient"), colors: z.array(z.string()), @@ -127,13 +128,20 @@ export const TransportShipTrailAttributesSchema = z.discriminatedUnion("type", [ const TransportShipTrailEffectSchema = CosmeticSchema.extend({ effectType: z.literal("transportShipTrail"), - attributes: TransportShipTrailAttributesSchema, + attributes: TrailEffectAttributesSchema, + url: z.string().optional(), +}); + +const NukeTrailEffectSchema = CosmeticSchema.extend({ + effectType: z.literal("nukeTrail"), + attributes: TrailEffectAttributesSchema, url: z.string().optional(), }); // Any catalog effect, discriminated on effectType. Add a member per effectType. export const EffectSchema = z.discriminatedUnion("effectType", [ TransportShipTrailEffectSchema, + NukeTrailEffectSchema, ]); /** @@ -184,6 +192,7 @@ export const CosmeticsSchema = z.object({ transportShipTrail: lenientRecord( TransportShipTrailEffectSchema, ).optional(), + nukeTrail: lenientRecord(NukeTrailEffectSchema).optional(), }) .optional(), currencyPacks: z.record(z.string(), PackSchema).optional(), diff --git a/tests/CosmeticSchemas.test.ts b/tests/CosmeticSchemas.test.ts index 1cdb905ae..42ee1bdc5 100644 --- a/tests/CosmeticSchemas.test.ts +++ b/tests/CosmeticSchemas.test.ts @@ -3,7 +3,7 @@ import { CosmeticsSchema, EffectSchema, findEffect, - TransportShipTrailAttributesSchema, + TrailEffectAttributesSchema, } from "../src/core/CosmeticSchemas"; import { PlayerEffectSchema } from "../src/core/Schemas"; @@ -15,9 +15,9 @@ describe("Effect cosmetic schemas", () => { rarity: "common", }; - describe("TransportShipTrailAttributesSchema", () => { + describe("TrailEffectAttributesSchema", () => { it("parses a gradient with a color list, colorSize, and movementSpeed", () => { - const parsed = TransportShipTrailAttributesSchema.parse({ + const parsed = TrailEffectAttributesSchema.parse({ type: "gradient", colors: ["#f00", "#00f"], colorSize: 16, @@ -33,7 +33,7 @@ describe("Effect cosmetic schemas", () => { it("accepts a single-color list (solid) and an empty list", () => { expect( - TransportShipTrailAttributesSchema.safeParse({ + TrailEffectAttributesSchema.safeParse({ type: "gradient", colors: ["#f00"], colorSize: 16, @@ -41,7 +41,7 @@ describe("Effect cosmetic schemas", () => { }).success, ).toBe(true); expect( - TransportShipTrailAttributesSchema.safeParse({ + TrailEffectAttributesSchema.safeParse({ type: "gradient", colors: [], colorSize: 16, @@ -53,22 +53,20 @@ describe("Effect cosmetic schemas", () => { it("requires the gradient type, colors, colorSize, and movementSpeed", () => { // Unrecognized styles (no discriminated-union member) are rejected. expect( - TransportShipTrailAttributesSchema.safeParse({ type: "solid" }).success, + TrailEffectAttributesSchema.safeParse({ type: "solid" }).success, ).toBe(false); // colors, colorSize, and movementSpeed are all required. expect( - TransportShipTrailAttributesSchema.safeParse({ + TrailEffectAttributesSchema.safeParse({ type: "gradient", colors: ["#f00"], }).success, ).toBe(false); - expect(TransportShipTrailAttributesSchema.safeParse({}).success).toBe( - false, - ); + expect(TrailEffectAttributesSchema.safeParse({}).success).toBe(false); }); it("parses a transition with a color list and frequency", () => { - const parsed = TransportShipTrailAttributesSchema.parse({ + const parsed = TrailEffectAttributesSchema.parse({ type: "transition", colors: ["#002aff", "#4805ff"], frequency: 1, @@ -82,7 +80,7 @@ describe("Effect cosmetic schemas", () => { it("requires frequency for a transition", () => { expect( - TransportShipTrailAttributesSchema.safeParse({ + TrailEffectAttributesSchema.safeParse({ type: "transition", colors: ["#002aff", "#4805ff"], }).success, @@ -105,6 +103,22 @@ describe("Effect cosmetic schemas", () => { ).toBe(true); }); + it("parses a nukeTrail effect (same attributes, different effectType)", () => { + expect( + EffectSchema.safeParse({ + ...base, + name: "tiel_red_gradient_nuke_trail", + effectType: "nukeTrail", + attributes: { + type: "gradient", + colors: ["#ff0000", "#00ffb3"], + colorSize: 0.5, + movementSpeed: 2, + }, + }).success, + ).toBe(true); + }); + it("rejects an effect with no attributes", () => { expect(EffectSchema.safeParse({ ...base }).success).toBe(false); }); @@ -176,6 +190,22 @@ describe("Effect cosmetic schemas", () => { rarity: "common", }, }, + nukeTrail: { + tiel_red_gradient_nuke_trail: { + name: "tiel_red_gradient_nuke_trail", + effectType: "nukeTrail", + attributes: { + type: "gradient", + colors: ["#ff0000", "#00ffb3"], + colorSize: 0.5, + movementSpeed: 2, + }, + affiliateCode: null, + product: null, + priceHard: 1, + rarity: "common", + }, + }, }, }); expect(result.success).toBe(true); @@ -184,6 +214,10 @@ describe("Effect cosmetic schemas", () => { result.data.effects?.transportShipTrail?.rainbow_ship?.attributes ?.colors, ).toEqual(["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"]); + expect( + result.data.effects?.nukeTrail?.tiel_red_gradient_nuke_trail + ?.effectType, + ).toBe("nukeTrail"); } }); diff --git a/tests/client/render/frame/TrailManager.test.ts b/tests/client/render/frame/TrailManager.test.ts index 356d39960..212c38da1 100644 --- a/tests/client/render/frame/TrailManager.test.ts +++ b/tests/client/render/frame/TrailManager.test.ts @@ -7,7 +7,10 @@ */ import { describe, expect, it } from "vitest"; -import { TrailManager } from "../../../../src/client/render/frame/TrailManager"; +import { + NUKE_TRAIL_BIT, + TrailManager, +} from "../../../../src/client/render/frame/TrailManager"; import type { UnitState } from "../../../../src/client/render/types"; import { UT_ATOM_BOMB, @@ -56,15 +59,18 @@ describe("TrailManager", () => { const tm = new TrailManager(MAP_W, MAP_H); const trail = tm.getTrailState(); + // A nuke's texel carries the owner smallID plus the nuke bit (bit 12). + const nukeTexel = 7 | NUKE_TRAIL_BIT; + // First sighting: lastPos === pos at spawn. tm.update(units(unit({ pos: ref(2, 2), lastPos: ref(2, 2) })), [1]); - expect(trail[ref(2, 2)]).toBe(7); + expect(trail[ref(2, 2)]).toBe(nukeTexel); // Move: lastPos trails pos by a tile. The trail head must reach lastPos // (3,2) but NOT the current pos (4,2) — the smoothed sprite occupies the // lastPos→pos span this frame. tm.update(units(unit({ pos: ref(4, 2), lastPos: ref(3, 2) })), [1]); - expect(trail[ref(3, 2)]).toBe(7); + expect(trail[ref(3, 2)]).toBe(nukeTexel); expect(trail[ref(4, 2)]).toBe(0); }); @@ -93,10 +99,11 @@ describe("TrailManager", () => { const tm = new TrailManager(MAP_W, MAP_H); const trail = tm.getTrailState(); + const nukeTexel = 7 | NUKE_TRAIL_BIT; tm.update(units(unit({ pos: ref(5, 5), lastPos: ref(5, 5) })), [1]); tm.update(units(unit({ pos: ref(7, 5), lastPos: ref(6, 5) })), [1]); - expect(trail[ref(5, 5)]).toBe(7); - expect(trail[ref(6, 5)]).toBe(7); + expect(trail[ref(5, 5)]).toBe(nukeTexel); + expect(trail[ref(6, 5)]).toBe(nukeTexel); // Unit gone from the map → its tiles are cleared. tm.update(new Map(), []);