Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
98d17c9
Add preview modal to store using stripped down webgl game. Add guards…
JB940 Aug 15, 2026
86b963c
Fix rabbit feedback. add features mentioned by wonder/flex
JB940 Aug 15, 2026
c2882be
fix feedback, prettier and gen-maps
JB940 Aug 15, 2026
af0af4e
Add proper png skin preview, add default palette option
JB940 Aug 15, 2026
9ce2fd7
Fix boattrail preview
JB940 Aug 15, 2026
15c6020
Address review: drop unused PreviewExplosionPass, simplify preview hi…
evanpelle Aug 28, 2026
1025d85
Render skin previews with the in-game TerritoryPass; fix trail, flick…
evanpelle Aug 28, 2026
9089591
Preview train and railroad cosmetic effects
evanpelle Aug 28, 2026
c58ede1
Share the effect-palette packing between the game and the cosmetic pr…
evanpelle Aug 28, 2026
de57b87
Preview on the Australia map; rework the preview modal per review
evanpelle Aug 28, 2026
41f01dc
fix(i18n): sort store preview keys in en.json
evanpelle Aug 28, 2026
49ee612
fix(client): exclude bundles from preview; release preview WebGL cont…
evanpelle Aug 28, 2026
f380964
fix(store): open the in-game preview when an effect card is clicked
evanpelle Aug 28, 2026
10650f9
feat(store): preview bundle items from the pack contents dialog
evanpelle Aug 28, 2026
5020ed6
feat(store): bundle items are store cards; preview modal drops the bu…
evanpelle Aug 28, 2026
88e1f88
fix(client): preview color guard, reset preview on modal close, shock…
evanpelle Aug 28, 2026
805a46b
fix(preview): dispose the current skin atlas before loading a new one
evanpelle Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,12 @@
"packs": "Packs",
"patterns": "Skins",
"plutonium_amount": "Plutonium amount",
"preview_cosmetic": "In-Game Preview",
"preview_error": "Failed to load in-game preview.",
"preview_salvo_count": "{count, plural, one {Single (x#)} other {Salvo (x#)}}",
"preview_salvo_toggle": "Toggle Salvo Mode",
"preview_skin_colors": "Skin colors",
"preview_team_colors": "Team colors",
"price_per_month": "/mo",
"purchase_currency": "Purchase {currency}",
"purchase_failed": "Purchase failed. Please try again.",
Expand Down
23 changes: 22 additions & 1 deletion src/client/InventoryModal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { html } from "lit";
import { html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import Countries from "resources/countries.json" with { type: "json" };
import { UserMeResponse } from "../core/ApiSchemas";
Expand Down Expand Up @@ -30,6 +30,7 @@ import {
cosmeticDisplayName,
cosmeticSelectionLabel,
} from "./components/CosmeticPresentation";
import "./components/CosmeticPreviewModal";
import "./components/EffectsGrid";
import "./components/InventoryLoadoutBar";
import type {
Expand Down Expand Up @@ -70,6 +71,7 @@ export class InventoryModal extends BaseModal {
@state() private isLoading = false;
@state() private loadFailed = false;
@state() private ownershipState: OwnershipState = "loading";
@state() private previewingCosmetic: ResolvedCosmetic | null = null;

private cosmetics: Cosmetics | null = null;
private userSettings: UserSettings = new UserSettings();
Expand Down Expand Up @@ -111,9 +113,15 @@ export class InventoryModal extends BaseModal {
void this.onUserMe((event as CustomEvent<UserMeResponse | false>).detail);
};

private onOpenCosmeticPreview = (event: Event) => {
const customEvent = event as CustomEvent<ResolvedCosmetic>;
this.previewingCosmetic = customEvent.detail;
};

connectedCallback() {
super.connectedCallback();
document.addEventListener("userMeResponse", this._onUserMe);
this.addEventListener("open-cosmetic-preview", this.onOpenCosmeticPreview);
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onCosmeticSelected,
Expand All @@ -133,6 +141,10 @@ export class InventoryModal extends BaseModal {
}

disconnectedCallback() {
this.removeEventListener(
"open-cosmetic-preview",
this.onOpenCosmeticPreview,
);
super.disconnectedCallback();
document.removeEventListener("userMeResponse", this._onUserMe);
window.removeEventListener(
Expand Down Expand Up @@ -628,6 +640,14 @@ export class InventoryModal extends BaseModal {
/>
</div>
</div>
${this.previewingCosmetic
? html`<cosmetic-preview-modal
.resolved=${this.previewingCosmetic}
@close-preview=${() => {
this.previewingCosmetic = null;
}}
></cosmetic-preview-modal>`
: nothing}
`;
}

Expand Down Expand Up @@ -730,6 +750,7 @@ export class InventoryModal extends BaseModal {

protected onClose(): void {
this.search = "";
this.previewingCosmetic = null;
}

// A query typed for skins rarely matches anything in flags, so a stale
Expand Down
49 changes: 39 additions & 10 deletions src/client/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { CosmeticPack, Cosmetics, Product } from "../core/CosmeticSchemas";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticCard";
import { cosmeticSelectionLabel } from "./components/CosmeticPresentation";
import { isPreviewableCosmetic } from "./components/CosmeticPreviewBubble";
import "./components/CosmeticPreviewModal";
import "./components/CurrencyDisplay";
import "./components/CustomCurrencyCard";
import "./components/EffectsGrid";
Expand Down Expand Up @@ -55,6 +57,7 @@ export class StoreModal extends BaseModal {
private userMeResponse: UserMeResponse | false = false;
private cosmeticsSubTab: CosmeticsSubTab = "patterns";
private inspected: ResolvedCosmetic | null = null;
private previewingCosmetic: ResolvedCosmetic | null = null;
private visibleGroups: readonly (readonly ResolvedCosmetic[])[] = [];
/** The bundle whose contents dialog is open, if any. */
private openedPack: ResolvedCosmetic | null = null;
Expand All @@ -79,12 +82,8 @@ export class StoreModal extends BaseModal {

connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
document.addEventListener("userMeResponse", this.onUserMeEvent);
this.addEventListener("open-cosmetic-preview", this.onOpenCosmeticPreview);
// Rows re-wrap on resize, so which currencies share a row changes with it.
if (typeof ResizeObserver !== "undefined") {
this.rowObserver ??= new ResizeObserver(() => alignPurchaseRows(this));
Expand All @@ -93,6 +92,11 @@ export class StoreModal extends BaseModal {
}

disconnectedCallback() {
document.removeEventListener("userMeResponse", this.onUserMeEvent);
this.removeEventListener(
"open-cosmetic-preview",
this.onOpenCosmeticPreview,
);
this.rowObserver?.disconnect();
if (this.alignFrame !== null) cancelAnimationFrame(this.alignFrame);
this.alignFrame = null;
Expand All @@ -111,6 +115,17 @@ export class StoreModal extends BaseModal {
});
}

private onUserMeEvent = (event: Event) => {
const customEvent = event as CustomEvent<UserMeResponse | false>;
this.onUserMe(customEvent.detail);
};

private onOpenCosmeticPreview = (event: Event) => {
const customEvent = event as CustomEvent<ResolvedCosmetic>;
this.previewingCosmetic = customEvent.detail;
this.requestUpdate();
};

private rowObserver: ResizeObserver | null = null;
private alignFrame: number | null = null;

Expand Down Expand Up @@ -239,6 +254,7 @@ export class StoreModal extends BaseModal {
// tile a few items, so the dialog is where each one is shown with its name.
private activate(resolved: ResolvedCosmetic): void {
if (resolved.type === "cosmeticPack") this.openedPack = resolved;
if (isPreviewableCosmetic(resolved)) this.previewingCosmetic = resolved;
this.inspect(resolved);
}

Expand Down Expand Up @@ -506,7 +522,7 @@ export class StoreModal extends BaseModal {
.userMeResponse=${this.userMeResponse}
.affiliateCode=${this.affiliateCode}
.focusedKey=${this.inspected?.key ?? null}
.onPurchaseFocus=${(item: ResolvedCosmetic) => this.inspect(item)}
.onPurchaseFocus=${(item: ResolvedCosmetic) => this.activate(item)}
.renderPurchaseAction=${(item: ResolvedCosmetic) =>
this.renderPurchaseAction(item, false)}
.onVisiblePurchaseItemsChange=${(items: readonly ResolvedCosmetic[]) => {
Expand Down Expand Up @@ -535,8 +551,11 @@ export class StoreModal extends BaseModal {
private renderBundleGrid(): TemplateResult {
return html`${this.renderBrowser(this.visibleGroups, {
emptyTranslationKey: "store.no_bundles",
})}${this.openedPack
? html`<pack-contents-dialog
})}${this.openedPack && !this.previewingCosmetic
? // The dialog is portaled above the modal's stacking context, so a
// preview opened from one of its items would render underneath it.
// Yield to the preview; the dialog comes back when it closes.
html`<pack-contents-dialog
.pack=${this.openedPack}
.actionContent=${this.renderCardAction(this.openedPack, false)}
@close=${() => this.closePack()}
Expand All @@ -560,7 +579,16 @@ export class StoreModal extends BaseModal {
}

protected renderHeaderSlot() {
return this.renderHeader();
return html`${this.renderHeader()}
${this.previewingCosmetic
? html`<cosmetic-preview-modal
.resolved=${this.previewingCosmetic}
@close-preview=${() => {
this.previewingCosmetic = null;
this.requestUpdate();
}}
></cosmetic-preview-modal>`
: ""}`;
}

protected renderBody(key: string): TemplateResult {
Expand Down Expand Up @@ -610,6 +638,7 @@ export class StoreModal extends BaseModal {
protected onClose(): void {
this.affiliateCode = null;
this.openedPack = null;
this.previewingCosmetic = null;
this.selectVisible(this.groupsForTab(this.activeTab));
}

Expand Down
66 changes: 16 additions & 50 deletions src/client/WebGLFrameBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ import {
isTrailEffect,
type NukeExplosionAttributes,
type NukeExplosionType,
type StructuresEffectAttributes,
TRAIL_EFFECT_TYPES,
type TrailEffectAttributes,
} from "../core/CosmeticSchemas";
import { PlayerType } from "../core/game/Game";
import { decodePatternData } from "../core/PatternDecoder";
Expand All @@ -38,6 +36,12 @@ import {
TRAIN_EFFECT_BLOCK,
WARSHIP_EFFECT_BLOCK,
} from "./render/gl/utils/ColorUtils";
import {
EFFECT_ENTRY_FLOATS,
packEffectEntry,
type PaletteEffectAttributes,
parseEffectColors,
} from "./render/gl/utils/EffectPalette";
import {
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
Expand All @@ -47,10 +51,6 @@ import type { GameView, PlayerView } from "./view";

const PALETTE_SIZE = 4096;

type PaletteEffectAttributes =
| TrailEffectAttributes
| StructuresEffectAttributes;

// A human player counts as "small" (and glows) at or below this fraction of the
// map; the glow is suppressed for a grace window after the game starts.
const SMALL_PLAYER_MAX_MAP_FRACTION = 0.002; // 0.2%
Expand Down Expand Up @@ -119,7 +119,7 @@ export function attributesToExplosionParams(
maxRadius: attrs.size / 2,
speed: attrs.speed,
thickness: attrs.thickness,
transitionSpeed: attrs.transitionSpeed,
transitionSpeed: attrs.transitionSpeed ?? 0,
};
return attrs.type === "sparkles"
? { ...base, type: "sparkles", density: attrs.density }
Expand Down Expand Up @@ -183,6 +183,8 @@ export class WebGLFrameBuilder {
// the trail tile's nuke bit), StructurePass (block 2), UnitPass (blocks 3
// and 4), and RailroadPass (block 5).
private readonly effectPalette: Float32Array;
/** One packed effect entry, reused by writeEffectEntry. */
private readonly effectEntryScratch = new Float32Array(EFFECT_ENTRY_FLOATS);
private readonly patternMeta: Float32Array;
private readonly patternData: Uint8Array;

Expand Down Expand Up @@ -652,20 +654,7 @@ export class WebGLFrameBuilder {
// parsed here so a fully unparseable list degrades to the plain
// stamped trail instead of an uncolored vortex.
const colors =
attrs.type === "spiral"
? attrs.colors
.map((s) => colord(s))
.filter((c) => c.isValid())
.slice(0, MAX_TRAIL_COLORS)
.map((c) => {
const { r, g, b } = c.toRgb();
return [r / 255, g / 255, b / 255] as [
number,
number,
number,
];
})
: [];
attrs.type === "spiral" ? parseEffectColors(attrs.colors) : [];
if (attrs.type === "spiral" && colors.length > 0) {
gameView.setNukeTrailSpiral(smallID, {
radius: attrs.radius,
Expand Down Expand Up @@ -724,40 +713,17 @@ export class WebGLFrameBuilder {
*/
private writeEffectEntry(
smallID: number,
attrs: TrailEffectAttributes | StructuresEffectAttributes,
attrs: PaletteEffectAttributes,
rowBase: number,
): boolean {
const colors = attrs.colors
.map((s) => colord(s))
.filter((c) => c.isValid())
.slice(0, MAX_TRAIL_COLORS)
.map((c) => c.toRgb());
let styleId: number;
let scalar0: number;
let scalar1: number;
if (attrs.type === "transition") {
styleId = 1;
scalar0 = attrs.frequency;
scalar1 = 0;
} else if (attrs.type === "spiral") {
styleId = 2;
scalar0 = attrs.rotationSpeed;
scalar1 = 0;
} else {
styleId = 0;
scalar0 = attrs.colorSize;
scalar1 = attrs.movementSpeed;
}
// Rows 0..3 carry the scalars in their alpha channel; the rest are 0.
const alphas = [colors.length, styleId, scalar0, scalar1];
const entry = this.effectEntryScratch;
packEffectEntry(attrs, entry);
let changed = false;
for (let r = 0; r < MAX_TRAIL_COLORS; r++) {
const off = ((rowBase + r) * PALETTE_SIZE + smallID) * 4;
const c = colors[r] ?? { r: 0, g: 0, b: 0 };
if (this.setEffectFloat(off, c.r / 255)) changed = true;
if (this.setEffectFloat(off + 1, c.g / 255)) changed = true;
if (this.setEffectFloat(off + 2, c.b / 255)) changed = true;
if (this.setEffectFloat(off + 3, alphas[r] ?? 0)) changed = true;
for (let i = 0; i < 4; i++) {
if (this.setEffectFloat(off + i, entry[r * 4 + i])) changed = true;
}
}
return changed;
}
Expand Down
20 changes: 12 additions & 8 deletions src/client/components/CosmeticCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
cosmeticSelectionLabel,
} from "./CosmeticPresentation";
import "./CosmeticPreview";
import "./CosmeticPreviewBubble";

const COSMETIC_CARD_STYLE_ID = "cosmetic-card-styles";
if (!document.getElementById(COSMETIC_CARD_STYLE_ID)) {
Expand Down Expand Up @@ -483,14 +484,17 @@ export class CosmeticCard extends LitElement {
</div>`}
${this.interactive && active.cosmetic !== null
? html`<cosmetic-info
.artist=${priced?.artist}
.rarity=${rarity}
.colorPalette=${active.colorPalette?.name}
.showAdFree=${active.relationship === "purchasable"}
.usdValue=${usdValue}
.perks=${this.subscriptionPerks()}
.items=${(active.packItems ?? []).map(cosmeticSelectionLabel)}
></cosmetic-info>`
.artist=${priced?.artist}
.rarity=${rarity}
.colorPalette=${active.colorPalette?.name}
.showAdFree=${active.relationship === "purchasable"}
.usdValue=${usdValue}
.perks=${this.subscriptionPerks()}
.items=${(active.packItems ?? []).map(cosmeticSelectionLabel)}
></cosmetic-info>
<cosmetic-preview-bubble
.resolved=${active}
></cosmetic-preview-bubble>`
: nothing}
</div>
${this.renderSwatches()}
Expand Down
16 changes: 16 additions & 0 deletions src/client/components/CosmeticPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,19 @@ export function cosmeticRarityLabel(resolved: ResolvedCosmetic): string {
return translateText("cosmetics.common");
}
}

const RARITY_BADGE_CLASSES: Record<string, string> = {
common: "bg-white/10 text-white/80 border-white/20",
uncommon: "bg-green-500/15 text-green-300 border-green-400/40",
rare: "bg-blue-500/15 text-blue-300 border-blue-400/40",
epic: "bg-purple-500/15 text-purple-300 border-purple-400/40",
legendary: "bg-orange-500/15 text-orange-300 border-orange-400/40",
};

/** Tailwind classes for a rarity pill, tinted by tier (matches CosmeticInfo's text colors). */
export function cosmeticRarityBadgeClass(resolved: ResolvedCosmetic): string {
return (
RARITY_BADGE_CLASSES[cosmeticRarity(resolved)] ??
RARITY_BADGE_CLASSES.common
);
}
Loading
Loading