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
parent 3833bfb496
commit 2794ab1270
20 changed files with 402 additions and 229 deletions
+1
View File
@@ -397,6 +397,7 @@
"search": "Search...",
"title": "Effects",
"type": {
"nukeTrail": "Nuke Trail",
"transportShipTrail": "Boat Trail"
}
},
+16 -15
View File
@@ -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<TransportShipTrailAttributes | null> {
// 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<TrailEffectAttributes | null> {
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 () => {
+13 -2
View File
@@ -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`
<div class="flex flex-col">
<div class="flex justify-center py-3 shrink-0">
@@ -85,6 +95,7 @@ export class EffectsModal extends BaseModal {
.cosmetics=${this.cosmetics}
.userMeResponse=${this.userMeResponse}
.search=${this.search}
.effectType=${tab}
></effects-grid>
</div>
`;
+3 -2
View File
@@ -145,10 +145,11 @@ export class StoreModal extends BaseModal {
}
private renderEffectGrid(): TemplateResult {
// Grouped by effectType with a sub-header per type (see <effects-grid>),
// 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`<effects-grid
mode="purchase"
tabbed
.cosmetics=${this.cosmetics}
.userMeResponse=${this.userMeResponse}
.affiliateCode=${this.affiliateCode}
+49 -24
View File
@@ -2,8 +2,9 @@ import { Colord, colord } from "colord";
import { base64url } from "jose";
import { assetUrl } from "../core/AssetUrls";
import {
EFFECT_TYPES,
findEffect,
type TransportShipTrailAttributes,
type TrailEffectAttributes,
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
@@ -14,11 +15,24 @@ import { uploadFrameData } from "./render/frame/Upload";
import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl";
// Value import from the leaf module (not the ./render/gl barrel) so non-Vite
// consumers don't pull in GPURenderer and its shaders — see note above.
import { MAX_TRAIL_COLORS } from "./render/gl/utils/ColorUtils";
import {
MAX_TRAIL_COLORS,
TRAIL_EFFECT_BLOCKS,
} from "./render/gl/utils/ColorUtils";
import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
// EFFECT_TYPES order IS the effect-palette block order: index = block (rows
// block·MAX_TRAIL_COLORS …). The shader (trail.frag.glsl) picks the block from
// the trail tile's nuke bit — block 0 = transportShipTrail (nuke bit 0), block 1
// = nukeTrail (nuke bit 1, set by NUKE_TRAIL_BIT in TrailManager). Reordering
// EFFECT_TYPES in CosmeticSchemas would silently swap the two trails' colors, so
// this guard fails the build if the shader-coupled prefix ever drifts.
const _EFFECT_BLOCK_ORDER: readonly ["transportShipTrail", "nukeTrail"] =
EFFECT_TYPES;
void _EFFECT_BLOCK_ORDER;
/**
* The renderer-side glue between GameView (which already builds the full
* FrameData each tick) and the WebGL view. Two responsibilities:
@@ -32,9 +46,10 @@ const PALETTE_SIZE = 4096;
*/
export class WebGLFrameBuilder {
private readonly palette: Float32Array;
// Per-player transport-ship-trail gradient, keyed by smallID. Layout is
// 4096×MAX_TRAIL_COLORS: row 0 = (color0.rgb, colorCount), row r = (colorR.rgb).
// Consumed by TrailPass's effect texture.
// Per-player trail-effect palette, keyed by smallID. Layout is
// 4096×(MAX_TRAIL_COLORS·TRAIL_EFFECT_BLOCKS): block 0 (rows 07) =
// transportShipTrail, block 1 (rows 815) = nukeTrail. Consumed by TrailPass's
// effect texture; the shader picks the block from the trail tile's nuke bit.
private readonly effectPalette: Float32Array;
private readonly patternMeta: Float32Array;
private readonly patternData: Uint8Array;
@@ -64,7 +79,9 @@ export class WebGLFrameBuilder {
constructor(private readonly view: MapRenderer) {
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
this.effectPalette = new Float32Array(PALETTE_SIZE * MAX_TRAIL_COLORS * 4);
this.effectPalette = new Float32Array(
PALETTE_SIZE * MAX_TRAIL_COLORS * TRAIL_EFFECT_BLOCKS * 4,
);
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
}
@@ -294,23 +311,28 @@ export class WebGLFrameBuilder {
if (this.effectResolved.has(smallID)) continue;
this.effectResolved.add(smallID);
const trailEffect = p.cosmetics.effects?.["transportShipTrail"];
if (!trailEffect) continue; // No effect — nothing to write or upload.
const effect = findEffect(
catalog,
"transportShipTrail",
trailEffect.name,
);
if (effect?.effectType !== "transportShipTrail") continue;
if (this.writeEffectEntry(smallID, effect.attributes)) dirty = true;
// Resolve each trail effectType into its own block of the effect palette.
// rowBase block*MAX_TRAIL_COLORS must match the shader's block layout
// (ship=0, nuke=1) — see _EFFECT_BLOCK_ORDER above and trail.frag.glsl.
EFFECT_TYPES.forEach((effectType, block) => {
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 03 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;
}
+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;
}
}
+25 -16
View File
@@ -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<number>;
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<number, UnitTrail>();
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) {
+5 -2
View File
@@ -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;
+2 -2
View File
@@ -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);
}
+20 -11
View File
@@ -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,
+14 -13
View File
@@ -1,13 +1,13 @@
/**
* TrailPass — boat trail lines.
* TrailPass — boat + nuke trail lines.
*
* Owns the CPU-side trail state (R8UI, 0=none, 1255=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, 1255=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,
);
}
@@ -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);
}
+11 -3
View File
@@ -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. */
+3 -3
View File
@@ -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,
});
+1 -1
View File
@@ -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<number, UnitState>;
readonly players: ReadonlyMap<number, PlayerState>;
+18 -9
View File
@@ -9,13 +9,12 @@ export type Flag = z.infer<typeof FlagSchema>;
export type Skin = z.infer<typeof SkinSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type Subscription = z.infer<typeof SubscriptionSchema>;
// 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<typeof EffectSchema>;
export type EffectType = z.infer<typeof EffectTypeSchema>;
export type TransportShipTrailAttributes = z.infer<
typeof TransportShipTrailAttributesSchema
>;
// Shared by every trail effectType (transportShipTrail, nukeTrail, …).
export type TrailEffectAttributes = z.infer<typeof TrailEffectAttributesSchema>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
export type Product = z.infer<typeof ProductSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
@@ -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(),
+46 -12
View File
@@ -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");
}
});
+12 -5
View File
@@ -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(), []);