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>
This commit is contained in:
Evan
2026-06-30 20:13:41 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 3833bfb496
commit 2794ab1270
20 changed files with 402 additions and 229 deletions
+2 -1
View File
@@ -186,7 +186,8 @@ export class CosmeticButton extends LitElement {
${translateText("territory_patterns.pattern.default")}
</div>`;
}
// 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`<trail-swatch
class="block w-full h-full"
.trail=${c.attributes}
+2 -2
View File
@@ -1,6 +1,6 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { TransportShipTrailAttributes } from "../../core/CosmeticSchemas";
import { TrailEffectAttributes } from "../../core/CosmeticSchemas";
// Neutral fallback when a trail has no usable colors.
const EMPTY_BG = "#444";
@@ -17,7 +17,7 @@ const EMPTY_BG = "#444";
export class TrailSwatch extends LitElement {
// Named `trail` (not `attributes`) to avoid clashing with Element.attributes.
@property({ attribute: false })
trail: TransportShipTrailAttributes | null = null;
trail: TrailEffectAttributes | null = null;
private animation: Animation | null = null;
+76 -31
View File
@@ -1,5 +1,5 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { UserMeResponse } from "../../core/ApiSchemas";
import {
Cosmetics,
@@ -40,6 +40,10 @@ function noneTile(effectType: EffectType): ResolvedCosmetic {
* - mode="select": owned effects + a Default tile per type; clicking persists
* the selection to UserSettings and re-renders.
* - mode="purchase": purchasable effects per type with the buy flow.
* - effectType (optional): render only that one effectType and drop the
* sub-header (an outer tab already labels it). Unset = all types stacked.
* - tabbed: render an internal tab bar (one tab per effectType) and show one
* type at a time. Used by the Store, whose own top-level tabs can't nest.
*/
@customElement("effects-grid")
export class EffectsGrid extends LitElement {
@@ -49,6 +53,11 @@ export class EffectsGrid extends LitElement {
@property({ type: String }) mode: "select" | "purchase" = "select";
@property({ attribute: false }) affiliateCode: string | null = null;
@property({ type: String }) search = "";
// When set, render only this effectType and drop the sub-header.
@property({ type: String }) effectType: EffectType | null = null;
// Render an internal tab bar (one tab per effectType), one type at a time.
@property({ type: Boolean }) tabbed = false;
@state() private activeType: EffectType = EFFECT_TYPES[0];
private userSettings = new UserSettings();
private _onChange = () => this.requestUpdate();
@@ -131,44 +140,80 @@ export class EffectsGrid extends LitElement {
></cosmetic-button>`;
}
// 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`
<div
class="flex items-center justify-center gap-6 border-b border-white/10 px-4"
>
${EFFECT_TYPES.map((type) => {
const active = this.activeType === type;
return html`<button
class="-mb-px border-b-2 px-2 py-3 text-sm font-black uppercase tracking-wider transition-colors ${active
? "border-blue-500 text-blue-400"
: "border-transparent text-white/50 hover:text-white/80"}"
@click=${() => (this.activeType = type)}
>
${translateText(`effects.type.${type}`)}
</button>`;
})}
</div>
`;
}
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`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_effects")}
</div>`;
// 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`<div class="p-4"></div>`
: html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_effects")}
</div>`;
} else {
panel = html`
<div class="flex flex-col gap-4 p-4">
${sections.map(
(s) => html`
<div class="flex flex-col">
${activeType
? nothing
: html`<h3
class="text-white/70 text-sm font-black uppercase tracking-wider px-2 pb-2 mb-2 border-b border-white/10"
>
${translateText(`effects.type.${s.type}`)}
</h3>`}
<div
class="flex flex-wrap gap-4 justify-center items-stretch content-start"
>
${s.items.map((r) => this.renderTile(s.type, r))}
</div>
</div>
`,
)}
</div>
`;
}
return html`
<div class="flex flex-col gap-4 p-4">
${sections.map(
(s) => html`
<div class="flex flex-col">
<h3
class="text-white/70 text-sm font-black uppercase tracking-wider px-2 pb-2 mb-2 border-b border-white/10"
>
${translateText(`effects.type.${s.type}`)}
</h3>
<div
class="flex flex-wrap gap-4 justify-center items-stretch content-start"
>
${s.items.map((r) => this.renderTile(s.type, r))}
</div>
</div>
`,
)}
</div>
`;
return this.tabbed ? html`${this.renderTabBar()}${panel}` : panel;
}
}