From aa3959bffece1d7af5a376209ad1935a4799090d Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 27 May 2026 21:00:07 +0100 Subject: [PATCH] feat: territory png based skins (#4006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Add image-based territory skins as a new cosmetic type, rendered alongside the existing 1-bit patterns. Skins render a single PNG centered on each player's spawn tile — opaque pixels show the skin (multiplied by team color in team games, raw colors in FFA), transparent pixels and tiles outside the image bounds fall through to the regular player palette color. **Cosmetic plumbing** - `SkinSchema` in `CosmeticSchemas.ts`, optional `skins` map on `CosmeticsSchema` - `PlayerSkin`, `PlayerCosmetics.skin`, `PlayerCosmeticRefs.skinName` in `Schemas.ts` - Server-side resolution: `PrivilegeCheckerImpl.isSkinAllowed` (gated by `skin:*` / `skin:` flares) - Client persistence: stored under `PATTERN_KEY` (`pattern:` and `skin:` share one slot — they're mutually exclusive) - `getPlayerCosmeticsRefs` only emits a `skinName` when cosmetics are loaded, the skin exists in the catalog, and the user has the right flare — otherwise drops the ref and clears storage **Renderer** - `SkinAtlasArray` — fixed `TEXTURE_2D_ARRAY`, 1024×1024 per layer, exact layer count allocated once at game start from the locked-in player set. No resize, no callbacks, no retained `HTMLImageElement`. Zero GPU cost when no players have skins (1×1 placeholder). - `skinLayerTex` (R8UI 4096×1) — per-player `layer + 1` (`0` = no skin) - `skinAnchorTex` (RG16UI 4096×1) — per-player spawn tile, so the PNG center anchors at each player's spawn (re-uploads when the player re-picks during spawn phase) - `WebGLFrameBuilder.syncPlayers` collects unique skin URLs on first sync and calls `view.initSkinAtlas(urls)` once; `clearCaches()` resets so seek/replay re-initializes - `territory.frag.glsl`: skin branch is mutually exclusive with patterns; bounds-checks UVs against `[0, 1]` so the image is a single stamp, not tiled; alpha-blends against the player palette color so transparent pixels and out-of-bounds tiles render as the regular player color **Hover highlight (global UX change, not skin-scoped)** - Existing hover highlight changed from "brighten toward white" to "saturation boost." Applies to all players regardless of skin/pattern/flat-color — looks better across the board. **UI** - `CosmeticButton` renders skins as a single `` (object-contain) - `TerritoryPatternsModal` merges patterns + skins into one grid; single "default" tile clears both - Selecting a pattern clears the skin and vice versa (mutually exclusive) - `Store` pattern tab includes skin entries (purchasable, not-yet-owned) - `PatternInput` lobby button previews the active skin when one is set **Memory** - 0 skin players → ~4 bytes (placeholder) + ~40 KB fixed per-player tables - 1 skin player → ~5.6 MB GPU - 5 skin players → ~28 MB GPU - 10 skin players → ~56 MB GPU **Tests** - `tests/Privilege.test.ts`: 13 new cases covering `isSkinAllowed` (wildcard, exact-match, missing flare, missing skin, forged refs) and `isAllowed` integration (allowed/forbidden paths, short-circuit when invalid skin is paired with valid other cosmetics) ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/Cosmetics.ts | 92 +++++++++- src/client/PatternInput.ts | 24 ++- src/client/Store.ts | 7 +- src/client/TerritoryPatternsModal.ts | 47 ++++-- src/client/WebGLFrameBuilder.ts | 47 ++++++ src/client/components/CosmeticButton.ts | 37 ++++- src/client/render/gl/GameView.ts | 9 + src/client/render/gl/Renderer.ts | 116 ++++++++++++- src/client/render/gl/passes/SkinAtlasArray.ts | 144 ++++++++++++++++ src/client/render/gl/passes/TerritoryPass.ts | 35 ++++ .../shaders/map-overlay/territory.frag.glsl | 44 ++++- src/core/CosmeticSchemas.ts | 6 + src/core/Schemas.ts | 8 + src/core/game/UserSettings.ts | 28 +++- src/server/Privilege.ts | 18 ++ tests/Privilege.test.ts | 157 ++++++++++++++++++ 16 files changed, 783 insertions(+), 36 deletions(-) create mode 100644 src/client/render/gl/passes/SkinAtlasArray.ts diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index a72fa9445..a74f09788 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -8,6 +8,7 @@ import { Pack, Pattern, Product, + Skin, Subscription, } from "../core/CosmeticSchemas"; import { @@ -30,6 +31,29 @@ export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute let __cosmetics: Promise | null = null; let __cosmeticsHash: string | null = null; +let __cosmeticsCache: Cosmetics | null = null; + +/** + * Synchronous accessor for the most recently resolved cosmetics. Returns null + * before the first successful `fetchCosmetics()` call. Useful when a code path + * cannot await (e.g. WebGL per-frame sync). + */ +export function getCachedCosmetics(): Cosmetics | null { + return __cosmeticsCache; +} + +/** + * Resolve the local player's selected skin from UserSettings + cached + * cosmetics. Returns null if no skin is selected, cosmetics aren't loaded, + * or the saved skin no longer exists. + */ +export function getLocalSelectedSkin(): { name: string; url: string } | null { + const skinName = new UserSettings().getSelectedSkinName(); + if (!skinName) return null; + const skin = __cosmeticsCache?.skins?.[skinName]; + if (!skin) return null; + return { name: skin.name, url: skin.url }; +} export type PaymentMethod = "dollar" | "hard" | "soft"; @@ -175,6 +199,7 @@ export async function fetchCosmetics(): Promise { .map((k) => k + (result.data.patterns[k].product ? "sale" : "")) .join(","); __cosmeticsHash = simpleHash(hashInput); + __cosmeticsCache = result.data; return result.data; } catch (error) { console.error("Error getting cosmetics:", error); @@ -309,12 +334,31 @@ export function flagRelationship( ); } +export function skinRelationship( + skin: Skin, + userMeResponse: UserMeResponse | false, + affiliateCode: string | null, +): "owned" | "purchasable" | "blocked" { + return cosmeticRelationship( + { + wildcardFlare: "skin:*", + requiredFlare: `skin:${skin.name}`, + product: skin.product, + priceSoft: skin.priceSoft, + priceHard: skin.priceHard, + affiliateCode, + itemAffiliateCode: skin.affiliateCode ?? null, + }, + userMeResponse, + ); +} + export type ResolvedCosmetic = { - type: "pattern" | "flag" | "pack" | "subscription"; - cosmetic: Pattern | Flag | Pack | Subscription | null; + type: "pattern" | "skin" | "flag" | "pack" | "subscription"; + cosmetic: Pattern | Skin | Flag | Pack | Subscription | null; colorPalette: ColorPalette | null; relationship: "owned" | "purchasable" | "blocked"; - /** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */ + /** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */ key: string; }; @@ -377,6 +421,19 @@ export function resolveCosmetics( }); } + // Skins (image-based territory cosmetics). No separate "default" entry — + // the pattern default doubles as "no skin": selecting it clears both. + for (const [skinKey, skin] of Object.entries(cosmetics.skins ?? {})) { + const rel = skinRelationship(skin, userMeResponse, affiliateCode); + result.push({ + type: "skin", + cosmetic: skin, + colorPalette: null, + relationship: rel, + key: `skin:${skinKey}`, + }); + } + // Packs for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) { const rel = pack.product ? "purchasable" : "blocked"; @@ -479,10 +536,32 @@ export async function getPlayerCosmeticsRefs(): Promise { userSettings.clearFlag(); } + let skinName = userSettings.getSelectedSkinName() ?? undefined; + if (skinName) { + const skin = cosmetics?.skins?.[skinName]; + if (cosmetics && !skin) { + // Cosmetics loaded but the saved skin no longer exists. + skinName = undefined; + } else if (skin) { + const userMe = await getUserMe(); + if (userMe) { + const flares = userMe.player.flares ?? []; + const hasWildcard = flares.includes("skin:*"); + if (!hasWildcard && !flares.includes(`skin:${skin.name}`)) { + skinName = undefined; + } + } + } + if (skinName === undefined) { + userSettings.setSelectedPatternName(undefined); + } + } + return { flag: flag ?? undefined, patternName: pattern?.name ?? undefined, patternColorPaletteName: pattern?.colorPalette?.name ?? undefined, + skinName, }; } @@ -520,6 +599,13 @@ export async function getPlayerCosmetics(): Promise { } } + if (refs.skinName && cosmetics) { + const skin = cosmetics.skins?.[refs.skinName]; + if (skin) { + result.skin = { name: refs.skinName, url: skin.url }; + } + } + return result; } diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index d1d6b5553..4d6cdc1e0 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -4,7 +4,7 @@ import { PATTERN_KEY, USER_SETTINGS_CHANGED_EVENT, } from "../core/game/UserSettings"; -import { PlayerPattern } from "../core/Schemas"; +import { PlayerPattern, PlayerSkin } from "../core/Schemas"; import { renderPatternPreview } from "./components/PatternPreview"; import { getPlayerCosmetics } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; @@ -13,6 +13,7 @@ import { translateText } from "./Utils"; @customElement("pattern-input") export class PatternInput extends LitElement { @state() public pattern: PlayerPattern | null = null; + @state() public skin: PlayerSkin | null = null; @state() public selectedColor: string | null = null; @state() private isLoading: boolean = true; @@ -24,10 +25,11 @@ export class PatternInput extends LitElement { private _abortController: AbortController | null = null; - private _onPatternSelected = async () => { + private _onCosmeticSelected = async () => { const cosmetics = await getPlayerCosmetics(); this.selectedColor = cosmetics.color?.color ?? null; this.pattern = cosmetics.pattern ?? null; + this.skin = cosmetics.skin ?? null; }; private onInputClick(e: Event) { @@ -48,11 +50,12 @@ export class PatternInput extends LitElement { const cosmetics = await getPlayerCosmetics(); this.selectedColor = cosmetics.color?.color ?? null; this.pattern = cosmetics.pattern ?? null; + this.skin = cosmetics.skin ?? null; if (!this.isConnected) return; this.isLoading = false; window.addEventListener( `${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`, - this._onPatternSelected, + this._onCosmeticSelected, { signal: this._abortController.signal, }, @@ -72,7 +75,9 @@ export class PatternInput extends LitElement { } private getIsDefaultPattern(): boolean { - return this.pattern === null && this.selectedColor === null; + return ( + this.pattern === null && this.skin === null && this.selectedColor === null + ); } private shouldShowSelectLabel(): boolean { @@ -121,8 +126,17 @@ export class PatternInput extends LitElement { `; } + // Skin takes precedence over pattern (mutually exclusive in-game too). let previewContent; - if (this.pattern) { + if (this.skin) { + previewContent = html`${this.skin.name}`; + } else if (this.pattern) { previewContent = renderPatternPreview(this.pattern, 128, 128); } else { previewContent = renderPatternPreview(null, 128, 128); diff --git a/src/client/Store.ts b/src/client/Store.ts index 97e403d72..a239860c4 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -71,7 +71,7 @@ export class StoreModal extends BaseModal { this.affiliateCode, ).filter( (r) => - r.type === "pattern" && + (r.type === "pattern" || r.type === "skin") && r.relationship !== "blocked" && r.relationship !== "owned", ); @@ -237,7 +237,10 @@ export class StoreModal extends BaseModal { this.affiliateCode, ).filter( (r) => - (r.type === "pattern" || r.type === "flag" || r.type === "pack") && + (r.type === "pattern" || + r.type === "skin" || + r.type === "flag" || + r.type === "pack") && r.relationship === "purchasable", ); diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 3ace0297b..728aa37a7 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -2,7 +2,7 @@ import type { TemplateResult } from "lit"; import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; -import { Cosmetics } from "../core/CosmeticSchemas"; +import { Cosmetics, Skin } from "../core/CosmeticSchemas"; import { PATTERN_KEY, USER_SETTINGS_CHANGED_EVENT, @@ -29,6 +29,7 @@ export class TerritoryPatternsModal extends BaseModal { @state() private selectedPattern: PlayerPattern | null; @state() private selectedColor: string | null = null; + @state() private selectedSkinName: string | null = null; @state() private search = ""; private cosmetics: Cosmetics | null = null; @@ -66,6 +67,7 @@ export class TerritoryPatternsModal extends BaseModal { const cosmetics = await getPlayerCosmetics(); this.selectedPattern = cosmetics.pattern ?? null; this.selectedColor = cosmetics.color?.color ?? null; + this.selectedSkinName = cosmetics.skin?.name ?? null; } async onUserMe(userMeResponse: UserMeResponse | false) { @@ -84,14 +86,15 @@ export class TerritoryPatternsModal extends BaseModal { this.search = (event.target as HTMLInputElement).value; } - private renderPatternGrid(): TemplateResult { + /** Combined patterns + skins grid. To the user they're the same: "skins". */ + private renderSkinGrid(): TemplateResult { const items = resolveCosmetics( this.cosmetics, this.userMeResponse, null, ).filter( (r) => - r.type === "pattern" && + (r.type === "pattern" || r.type === "skin") && r.relationship === "owned" && (r.cosmetic === null ? !this.search @@ -105,11 +108,19 @@ export class TerritoryPatternsModal extends BaseModal { > ${items.map((r) => { const isSelected = - (r.cosmetic === null && this.selectedPattern === null) || - (r.cosmetic !== null && - this.selectedPattern?.name === r.cosmetic.name && - (this.selectedPattern?.colorPalette?.name ?? null) === - (r.colorPalette?.name ?? null)); + r.type === "pattern" + ? (r.cosmetic === null && this.selectedPattern === null) || + (r.cosmetic !== null && + this.selectedPattern?.name === r.cosmetic.name && + (this.selectedPattern?.colorPalette?.name ?? null) === + (r.colorPalette?.name ?? null)) + : (() => { + const skinName = (r.cosmetic as Skin | null)?.name ?? null; + return ( + (skinName === null && this.selectedSkinName === null) || + (skinName !== null && this.selectedSkinName === skinName) + ); + })(); return html` -
${this.renderPatternGrid()}
+
${this.renderSkinGrid()}
`; } @@ -178,8 +189,21 @@ export class TerritoryPatternsModal extends BaseModal { } private selectCosmetic(resolved: ResolvedCosmetic) { - if (resolved.type !== "pattern") return; - this.selectPattern(resolvedToPlayerPattern(resolved)); + if (resolved.type === "pattern") { + this.selectPattern(resolvedToPlayerPattern(resolved)); + } else if (resolved.type === "skin") { + this.selectSkin((resolved.cosmetic as Skin | null)?.name ?? null); + } + } + + private selectSkin(skinName: string | null) { + this.userSettings.setSelectedPatternName( + skinName === null ? undefined : `skin:${skinName}`, + ); + this.selectedSkinName = skinName; + this.selectedPattern = null; + this.refresh(); + this.close(); } private selectPattern(pattern: PlayerPattern | null) { @@ -194,6 +218,7 @@ export class TerritoryPatternsModal extends BaseModal { this.userSettings.setSelectedPatternName(`pattern:${name}`); } this.selectedPattern = pattern; + this.selectedSkinName = null; this.refresh(); this.showSkinSelectedPopup(); this.close(); diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 4851c5fb0..e283e9f79 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -30,6 +30,14 @@ export class WebGLFrameBuilder { private readonly patternData: Uint8Array; private readonly knownSmallIDs = new Set(); + /** + * Last spawn tile pushed to the renderer per smallID. Players can re-pick + * spawn during the spawn phase, so this tracks the latest value rather than + * just first-seen — re-uploads only when the tile actually changes. + */ + private readonly lastSpawnTile = new Map(); + /** Skin atlas allocated once on first syncPlayers — player set is locked at game start. */ + private skinsInitialized = false; // The renderer needs to know which player is "me" so affiliation tint, // unit colors, and SAM-radius perspective work. Push it once the local // player's update arrives (may take several ticks during join). @@ -46,17 +54,42 @@ export class WebGLFrameBuilder { /** Drop internal caches to force a full re-upload of state on the next update(). */ clearCaches(): void { this.knownSmallIDs.clear(); + this.lastSpawnTile.clear(); this.localPlayerSmallID = 0; + this.skinsInitialized = false; } update(gameView: GameView): void { this.syncPlayers(gameView); + this.syncPlayerSpawns(gameView); this.syncLocalPlayer(gameView); this.syncSpawnOverlay(gameView); this.syncTerrainDeltas(gameView); uploadFrameData(this.view, gameView.frameData()); } + /** + * Push each player's current spawn tile to the renderer as the skin anchor + * (image center lines up with this tile). Players re-pick spawn during the + * spawn phase, so we re-upload whenever the tile changes, not just on first + * sighting. Once spawn phase ends, spawnTile is locked and this becomes a + * no-op via the cache check. + */ + private syncPlayerSpawns(gameView: GameView): void { + for (const p of gameView.players()) { + const smallID = p.smallID(); + const spawnTile = p.state.spawnTile; + if (spawnTile === undefined) continue; + if (this.lastSpawnTile.get(smallID) === spawnTile) continue; + this.lastSpawnTile.set(smallID, spawnTile); + this.view.setPlayerSpawn( + smallID, + gameView.x(spawnTile), + gameView.y(spawnTile), + ); + } + } + /** * Water-nuke conversions (land → water) mutate the underlying terrain. * Forward this tick's terrain-changed refs to the renderer so it can @@ -126,6 +159,15 @@ export class WebGLFrameBuilder { } private syncPlayers(gameView: GameView): void { + if (!this.skinsInitialized) { + this.skinsInitialized = true; + const urls = new Set(); + for (const p of gameView.players()) { + const url = p.cosmetics.skin?.url; + if (url) urls.add(assetUrl(url)); + } + this.view.initSkinAtlas([...urls]); + } const newPlayers: PlayerStatic[] = []; for (const p of gameView.players()) { const smallID = p.smallID(); @@ -140,6 +182,11 @@ export class WebGLFrameBuilder { const flagRef = p.cosmetics.flag; const flagUrl = flagRef ? assetUrl(flagRef) : undefined; + const skin = p.cosmetics.skin; + if (skin?.url) { + this.view.setPlayerSkin(smallID, assetUrl(skin.url)); + } + const pattern = p.cosmetics.pattern; if (pattern && pattern.patternData) { try { diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index 0374fa2a7..84fce3d23 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -1,6 +1,12 @@ import { html, LitElement, nothing, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { Flag, Pack, Pattern, Subscription } from "../../core/CosmeticSchemas"; +import { + Flag, + Pack, + Pattern, + Skin, + Subscription, +} from "../../core/CosmeticSchemas"; import { PlayerPattern } from "../../core/Schemas"; import { PaymentMethod, @@ -46,7 +52,7 @@ export class CosmeticButton extends LitElement { if (c === null) { return translateText("territory_patterns.pattern.default"); } - if (this.resolved.type === "pattern") { + if (this.resolved.type === "pattern" || this.resolved.type === "skin") { return translateCosmetic("territory_patterns.pattern", c.name); } if (this.resolved.type === "pack") { @@ -72,6 +78,25 @@ export class CosmeticButton extends LitElement { return renderPatternPreview(playerPattern, 150, 150); } + if (this.resolved.type === "skin") { + const c = this.resolved.cosmetic as Skin | null; + if (c === null) { + // "Default" tile — visually consistent with pattern's default tile. + return html`
+ ${translateText("territory_patterns.pattern.default")} +
`; + } + return html`${c.name}`; + } + if (this.resolved.type === "pack") { const pack = this.resolved.cosmetic as Pack; const isHard = pack.currency === "hard"; @@ -149,13 +174,14 @@ export class CosmeticButton extends LitElement { render() { const c = this.resolved.cosmetic; - const priced = c as Pattern | Flag | Pack | null; + const priced = c as Pattern | Skin | Flag | Pack | null; const priceHard = priced?.priceHard; const priceSoft = priced?.priceSoft; const artist = priced?.artist; const isPurchasable = this.resolved.relationship === "purchasable"; const type = this.resolved.type; const isPattern = type === "pattern"; + const isSkin = type === "skin"; const isOwnedSubscription = type === "subscription" && this.resolved.relationship === "owned"; const dollarLabelKey = @@ -167,7 +193,7 @@ export class CosmeticButton extends LitElement { const priceSuffix = type === "subscription" ? translateText("store.price_per_month") : ""; const sizeClass = type === "flag" ? "gap-1 p-1.5 w-36" : "gap-2 p-3 w-48"; - const crazygamesClass = isPattern ? "no-crazygames " : ""; + const crazygamesClass = isPattern || isSkin ? "no-crazygames " : ""; return html`