mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 02:38:09 +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:
@@ -397,6 +397,7 @@
|
||||
"search": "Search...",
|
||||
"title": "Effects",
|
||||
"type": {
|
||||
"nukeTrail": "Nuke Trail",
|
||||
"transportShipTrail": "Boat Trail"
|
||||
}
|
||||
},
|
||||
|
||||
+16
-15
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 0–7) =
|
||||
// transportShipTrail, block 1 (rows 8–15) = 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 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(), []);
|
||||
|
||||
Reference in New Issue
Block a user