Files
OpenFrontIO/src/client/PatternInput.ts
T
Evan aa3959bffe feat: territory png based skins (#4006)
## 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:<name>` 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 `<img>` (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
2026-05-27 13:00:07 -07:00

172 lines
5.2 KiB
TypeScript

import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
} from "../core/game/UserSettings";
import { PlayerPattern, PlayerSkin } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternPreview";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
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;
@property({ type: Boolean, attribute: "show-select-label" })
public showSelectLabel: boolean = false;
@property({ type: Boolean, attribute: "adaptive-size" })
public adaptiveSize: boolean = false;
private _abortController: AbortController | null = null;
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) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent("pattern-input-click", {
bubbles: true,
composed: true,
}),
);
}
async connectedCallback() {
super.connectedCallback();
this._abortController = new AbortController();
this.isLoading = true;
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._onCosmeticSelected,
{
signal: this._abortController.signal,
},
);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
}
createRenderRoot() {
return this;
}
private getIsDefaultPattern(): boolean {
return (
this.pattern === null && this.skin === null && this.selectedColor === null
);
}
private shouldShowSelectLabel(): boolean {
return this.showSelectLabel && this.getIsDefaultPattern();
}
private applyAdaptiveSize(): void {
if (!this.adaptiveSize) {
this.style.removeProperty("width");
this.style.removeProperty("height");
return;
}
const showSelect = this.showSelectLabel && this.getIsDefaultPattern();
this.style.setProperty("height", "2.5rem");
this.style.setProperty(
"width",
showSelect ? "clamp(3.25rem, 14vw, 4.75rem)" : "2.5rem",
);
}
protected updated(): void {
this.applyAdaptiveSize();
}
render() {
if (crazyGamesSDK.isOnCrazyGames()) {
return html``;
}
const showSelect = this.shouldShowSelectLabel();
const buttonTitle = translateText("territory_patterns.title");
// Show loading state
if (this.isLoading) {
return html`
<button
id="pattern-input"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-surface rounded-lg overflow-hidden"
disabled
>
<span
class="w-6 h-6 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin"
></span>
</button>
`;
}
// Skin takes precedence over pattern (mutually exclusive in-game too).
let previewContent;
if (this.skin) {
previewContent = html`<img
src=${this.skin.url}
alt=${this.skin.name}
class="pointer-events-none"
draggable="false"
loading="lazy"
/>`;
} else if (this.pattern) {
previewContent = renderPatternPreview(this.pattern, 128, 128);
} else {
previewContent = renderPatternPreview(null, 128, 128);
}
return html`
<button
id="pattern-input"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
<span
class=${showSelect
? "hidden"
: "w-full h-full overflow-hidden flex items-center justify-center [&>img]:object-cover [&>img]:w-full [&>img]:h-full [&>img]:pointer-events-none"}
>
${!showSelect ? previewContent : null}
</span>
${showSelect
? html`<span
class="${this.adaptiveSize
? "text-[7px] leading-tight px-0.5"
: "text-[10px] leading-none break-words px-1"} font-black text-white uppercase w-full text-center"
>
${translateText("territory_patterns.select_skin")}
</span>`
: null}
</button>
`;
}
}