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`