mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 19:07:43 +00:00
feat: render transport-ship trail cosmetic as a gradient (#4454)
## What
Renders the `transportShipTrail` cosmetic effect in-game. Transport
ships already left a trail, but it was always drawn in the player's
**territory color** — this wires the selected effect through to the
renderer so the trail shows the player's chosen **gradient**.
## How
- **Per-player effect texture** (`RGBA32F`, mirrors the palette texture)
keyed by `smallID`, sampled by the trail fragment shader. Each row holds
a gradient color; spare alpha channels carry the color count,
`colorSize`, and `movementSpeed`.
- **Shader**
([trail.frag.glsl](src/client/render/gl/shaders/map-overlay/trail.frag.glsl))
cycles a flowing gradient through the color list: 1 color → flat, 2+ →
animated bands scrolling along the trail. No effect (count 0) falls back
to the territory color; alt-view keeps affiliation colors.
- **WebGLFrameBuilder** resolves each player's catalog attributes (the
in-game cosmetic is only `{ name, effectType }`; the style/colors live
in the catalog) and encodes them. Resolution is decoupled from the
first-seen palette path so it retries until the catalog loads, and
unparseable colors are dropped so bad catalog data degrades to the
territory color rather than rendering black.
## Schema
Collapses the trail attributes to a single gradient shape:
```ts
{ type: "gradient", colors: string[], colorSize: number, movementSpeed: number }
```
- `colors` — solid = one color, rainbow = the spectrum, gradient = two
or more.
- `colorSize` — band width (tiles per color band; `1` is the default, ~4
tiles).
- `movementSpeed` — scroll rate along the trail (tiles/sec; `0` =
static).
## Notes
- Animation is render-only (local time), no simulation/determinism
impact.
- The catalog (`cosmetics.json`, served by the closed-source API) must
ship effects in this `{ type: "gradient", colors, colorSize,
movementSpeed }` shape.
- Band thickness (`4.0` base in the shader) and the gradient frequency
are visual constants picked without in-game verification — easy to tune.
## Testing
- `tsc --noEmit`, ESLint, Prettier, `build-prod` all clean.
- Schema + Privilege test suites updated for the gradient shape (92
tests pass).
- Not yet visually verified in a running game (effect selection is
flare-gated).
🤖 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:
+113
-75
@@ -15,94 +15,70 @@ describe("Effect cosmetic schemas", () => {
|
||||
rarity: "common",
|
||||
};
|
||||
|
||||
describe("TransportShipTrailAttributesSchema (lenient)", () => {
|
||||
it("parses the known attribute variants", () => {
|
||||
describe("TransportShipTrailAttributesSchema", () => {
|
||||
it("parses a gradient with a color list, colorSize, and movementSpeed", () => {
|
||||
const parsed = TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
colors: ["#f00", "#00f"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
type: "gradient",
|
||||
colors: ["#f00", "#00f"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a single-color list (solid) and an empty list", () => {
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "solid",
|
||||
color: "#f00",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "rainbow" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "pulse",
|
||||
color: "#0f0",
|
||||
type: "gradient",
|
||||
colors: ["#f00"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
colors: [],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates an unknown attribute type (ignored at render time)", () => {
|
||||
it("requires the gradient type, colors, colorSize, and movementSpeed", () => {
|
||||
// The old solid/rainbow/pulse styles are gone — only gradient remains.
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "sparkle" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a `type`", () => {
|
||||
TransportShipTrailAttributesSchema.safeParse({ type: "solid" }).success,
|
||||
).toBe(false);
|
||||
// colors, colorSize, and movementSpeed are all required.
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.safeParse({
|
||||
type: "gradient",
|
||||
colors: ["#f00"],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(TransportShipTrailAttributesSchema.safeParse({}).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TransportShipTrailAttributesSchema (discriminated styles)", () => {
|
||||
it("keeps the fields of a known style", () => {
|
||||
const solid = TransportShipTrailAttributesSchema.parse({
|
||||
type: "solid",
|
||||
color: "#f00",
|
||||
});
|
||||
expect(solid).toEqual({ type: "solid", color: "#f00" });
|
||||
const gradient = TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
});
|
||||
expect(gradient).toEqual({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
color2: "#00f",
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes an unrecognized style to { type: "unknown" }', () => {
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({ type: "sparkle" }),
|
||||
).toEqual({ type: "unknown" });
|
||||
});
|
||||
|
||||
it("normalizes a known style missing required fields to unknown", () => {
|
||||
// solid without color / gradient without color2 don't match their strict
|
||||
// variant, so they degrade to the neutral unknown swatch rather than
|
||||
// failing the parse.
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({ type: "solid" }),
|
||||
).toEqual({ type: "unknown" });
|
||||
expect(
|
||||
TransportShipTrailAttributesSchema.parse({
|
||||
type: "gradient",
|
||||
color: "#f00",
|
||||
}),
|
||||
).toEqual({ type: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("EffectSchema", () => {
|
||||
it("parses an effect (discriminated on effectType)", () => {
|
||||
expect(
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
attributes: { type: "rainbow" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#f00", "#0f0", "#00f"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -116,18 +92,23 @@ describe("Effect cosmetic schemas", () => {
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
effectType: "glow",
|
||||
attributes: { type: "rainbow" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#f00"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates an effect with an unknown attribute type", () => {
|
||||
it("rejects an effect with a non-gradient attribute type", () => {
|
||||
expect(
|
||||
EffectSchema.safeParse({
|
||||
...base,
|
||||
attributes: { type: "sparkle" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +124,12 @@ describe("Effect cosmetic schemas", () => {
|
||||
rainbow_ship: {
|
||||
name: "rainbow_ship",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "rainbow" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"],
|
||||
colorSize: 24,
|
||||
movementSpeed: 0.2,
|
||||
},
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
priceHard: 123,
|
||||
@@ -154,8 +140,9 @@ describe("Effect cosmetic schemas", () => {
|
||||
effectType: "transportShipTrail",
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
color: "#aea2a2",
|
||||
color2: "#a80000",
|
||||
colors: ["#aea2a2", "#a80000"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
affiliateCode: null,
|
||||
product: {
|
||||
@@ -172,8 +159,9 @@ describe("Effect cosmetic schemas", () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(
|
||||
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes?.type,
|
||||
).toBe("rainbow");
|
||||
result.data.effects?.transportShipTrail?.rainbow_ship?.attributes
|
||||
?.colors,
|
||||
).toEqual(["#ff0000", "#ffe600", "#00a8ff", "#7d5fff"]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,7 +174,12 @@ describe("Effect cosmetic schemas", () => {
|
||||
ship: {
|
||||
name: "ship",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "solid", color: "#fff" },
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#fff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
@@ -203,12 +196,57 @@ describe("Effect cosmetic schemas", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a newer-shaped effect within a known effectType without failing the catalog", () => {
|
||||
const result = CosmeticsSchema.safeParse({
|
||||
patterns: {},
|
||||
flags: {},
|
||||
effects: {
|
||||
transportShipTrail: {
|
||||
good: {
|
||||
name: "good",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#fff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
// A newer effect shape this client doesn't understand yet — must be
|
||||
// dropped, not fail the whole catalog parse.
|
||||
future: {
|
||||
name: "future",
|
||||
effectType: "transportShipTrail",
|
||||
attributes: { type: "hologram", intensity: 3 },
|
||||
product: null,
|
||||
rarity: "common",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const trails = result.data.effects?.transportShipTrail;
|
||||
// The good effect survives...
|
||||
expect(trails?.good?.name).toBe("good");
|
||||
// ...and only the unparseable newer one is dropped.
|
||||
expect(trails?.future).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findEffect", () => {
|
||||
const effect = (name: string) => ({
|
||||
name,
|
||||
attributes: { type: "solid", color: "#fff" } as const,
|
||||
attributes: {
|
||||
type: "gradient",
|
||||
colors: ["#fff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
} as const,
|
||||
product: null,
|
||||
rarity: "common" as const,
|
||||
});
|
||||
|
||||
+18
-3
@@ -99,7 +99,12 @@ const effectCosmetics = {
|
||||
spectrum: {
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "rainbow" } as const,
|
||||
attributes: {
|
||||
type: "gradient" as const,
|
||||
colors: ["#ff0000", "#00ff00", "#0000ff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
@@ -110,7 +115,12 @@ const effectCosmetics = {
|
||||
crimson: {
|
||||
name: "crimson",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "solid", color: "#e01b24" } as const,
|
||||
attributes: {
|
||||
type: "gradient" as const,
|
||||
colors: ["#e01b24"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
|
||||
@@ -638,7 +648,12 @@ describe("Effect validation in isAllowed", () => {
|
||||
trail_01: {
|
||||
name: "spectrum",
|
||||
effectType: "transportShipTrail" as const,
|
||||
attributes: { type: "rainbow" } as const,
|
||||
attributes: {
|
||||
type: "gradient" as const,
|
||||
colors: ["#ff0000", "#00ff00", "#0000ff"],
|
||||
colorSize: 16,
|
||||
movementSpeed: 0.15,
|
||||
},
|
||||
url: "",
|
||||
affiliateCode: null,
|
||||
product: null,
|
||||
|
||||
Reference in New Issue
Block a user