mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 10:22:03 +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:
+38
-25
@@ -102,27 +102,20 @@ export const SkinSchema = CosmeticSchema.extend({
|
||||
export const EFFECT_TYPES = ["transportShipTrail"] as const;
|
||||
export const EffectTypeSchema = z.enum(EFFECT_TYPES);
|
||||
|
||||
// Boat-trail styles, discriminated on `type`: each known style carries exactly
|
||||
// the fields it uses (rainbow has none; solid/pulse need a color; gradient needs
|
||||
// both). A `type` we don't recognize — a style shipped to cosmetics.json before
|
||||
// this client updated — normalizes to { type: "unknown" } instead of failing the
|
||||
// catalog parse, so one new style never wipes the whole catalog; the renderer
|
||||
// shows a neutral swatch. `type` itself stays required.
|
||||
export const TransportShipTrailAttributesSchema = z.union([
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("solid"), color: z.string() }),
|
||||
z.object({ type: z.literal("rainbow") }),
|
||||
z.object({ type: z.literal("pulse"), color: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("gradient"),
|
||||
color: z.string(),
|
||||
color2: z.string(),
|
||||
}),
|
||||
]),
|
||||
z
|
||||
.object({ type: z.string() })
|
||||
.transform(() => ({ type: "unknown" as const })),
|
||||
]);
|
||||
// A boat trail is a gradient of one or more colors, cycled along the trail. The
|
||||
// old solid/rainbow styles are just color lists now: solid = a single color,
|
||||
// rainbow = the spectrum, gradient = two or more. The server only ships this
|
||||
// "gradient" shape. 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).
|
||||
// `colorSize` is how wide each color band is, in tiles (larger = bigger bands);
|
||||
// `movementSpeed` is how fast the bands scroll along the trail, in tiles per
|
||||
// second (0 = static).
|
||||
export const TransportShipTrailAttributesSchema = z.object({
|
||||
type: z.literal("gradient"),
|
||||
colors: z.array(z.string()),
|
||||
colorSize: z.number(),
|
||||
movementSpeed: z.number(),
|
||||
});
|
||||
|
||||
const TransportShipTrailEffectSchema = CosmeticSchema.extend({
|
||||
effectType: z.literal("transportShipTrail"),
|
||||
@@ -135,6 +128,23 @@ export const EffectSchema = z.discriminatedUnion("effectType", [
|
||||
TransportShipTrailEffectSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* A record that drops entries failing `schema` instead of failing the whole
|
||||
* parse. Used for the effect catalog: a newer effect the server ships before
|
||||
* this client is updated to understand it is skipped rather than taking patterns,
|
||||
* flags, and skins down with it.
|
||||
*/
|
||||
function lenientRecord<T extends z.ZodType>(schema: T) {
|
||||
return z.record(z.string(), z.unknown()).transform((rec) => {
|
||||
const out: Record<string, z.infer<T>> = {};
|
||||
for (const [key, value] of Object.entries(rec)) {
|
||||
const parsed = schema.safeParse(value);
|
||||
if (parsed.success) out[key] = parsed.data;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
export const PackSchema = CosmeticSchema.extend({
|
||||
displayName: z.string(),
|
||||
currency: z.enum(["hard", "soft"]),
|
||||
@@ -157,12 +167,15 @@ export const CosmeticsSchema = z.object({
|
||||
skins: z.record(z.string(), SkinSchema).optional(),
|
||||
// Grouped by effectType. Each effect also carries its own effectType (matching
|
||||
// this outer key) so an Effect stands alone and EffectSchema can discriminate
|
||||
// on it. Add a key per new effectType.
|
||||
// on it. Add a key per new effectType. Forward-compat: a brand-new effectType
|
||||
// key is ignored (z.object strips keys it doesn't list), and lenientRecord
|
||||
// extends that to new entries under a known effectType (a dropped effect just
|
||||
// degrades to "no effect" — the trail keeps its territory color).
|
||||
effects: z
|
||||
.object({
|
||||
transportShipTrail: z
|
||||
.record(z.string(), TransportShipTrailEffectSchema)
|
||||
.optional(),
|
||||
transportShipTrail: lenientRecord(
|
||||
TransportShipTrailEffectSchema,
|
||||
).optional(),
|
||||
})
|
||||
.optional(),
|
||||
currencyPacks: z.record(z.string(), PackSchema).optional(),
|
||||
|
||||
Reference in New Issue
Block a user