mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-15 11:12:51 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user