store & reference pattern by name (#1766)

## Description:

Store pattern by name instead of value. The worker replaces the pattern
name with it's base64 when joining. This ensures the client & server are
never out of sync after patterns are updated.

* removed resizeObserver on the territory modal, it was causing some
race conditions, and the modal is not resizable so it's unnecessary.

* Moved PatternSchema to CosmeticSchema
## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I have read and accepted the CLA agreement (only required once).

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
evanpelle
2025-08-16 18:08:16 -07:00
committed by GitHub
parent 77fd82b4b4
commit b57a409b8a
12 changed files with 160 additions and 159 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
export interface LobbyConfig {
serverConfig: ServerConfig;
pattern: string | undefined;
patternName: string | undefined;
flag: string;
playerName: string;
clientID: ClientID;
+6 -6
View File
@@ -4,14 +4,14 @@ import { getApiBase, getAuthHeader } from "./jwt";
export async function patterns(
userMe: UserMeResponse | null,
): Promise<Pattern[]> {
): Promise<Map<string, Pattern>> {
const cosmetics = await getCosmetics();
if (cosmetics === undefined) {
return [];
return new Map();
}
const patterns: Pattern[] = [];
const patterns: Map<string, Pattern> = new Map();
const playerFlares = new Set(userMe?.player.flares);
for (const name in cosmetics.patterns) {
@@ -20,10 +20,10 @@ export async function patterns(
if (hasAccess) {
// Remove product info because player already has access.
patternData.product = null;
patterns.push(patternData);
patterns.set(name, patternData);
} else if (patternData.product !== null) {
// Player doesn't have access, but product is available for purchase.
patterns.push(patternData);
patterns.set(name, patternData);
}
// If player doesn't have access and product is null, don't show it.
}
@@ -65,7 +65,7 @@ export async function handlePurchase(priceId: string) {
window.location.href = url;
}
async function getCosmetics(): Promise<Cosmetics | undefined> {
export async function getCosmetics(): Promise<Cosmetics | undefined> {
try {
const response = await fetch(`${getApiBase()}/cosmetics.json`);
if (!response.ok) {
+1 -8
View File
@@ -216,13 +216,6 @@ class Client {
throw new Error("territory-patterns-input-preview-button");
territoryModal.previewButton = patternButton;
territoryModal.updatePreview();
territoryModal.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target.classList.contains("preview-container")) {
territoryModal.buttonWidth = entry.contentRect.width;
}
}
});
patternButton.addEventListener("click", () => {
territoryModal.open();
});
@@ -464,7 +457,7 @@ class Client {
{
gameID: lobby.gameID,
serverConfig: config,
pattern: this.userSettings.getSelectedPattern(),
patternName: this.userSettings.getSelectedPatternName(),
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
+7 -2
View File
@@ -21,6 +21,7 @@ import "./components/baseComponents/Modal";
import "./components/Difficulties";
import { DifficultyDescription } from "./components/Difficulties";
import "./components/Maps";
import { getCosmetics } from "./Cosmetics";
import { FlagInput } from "./FlagInput";
import { JoinLobbyEvent } from "./Main";
import { UsernameInput } from "./UsernameInput";
@@ -401,7 +402,7 @@ export class SinglePlayerModal extends LitElement {
: this.disabledUnits.filter((u) => u !== unit);
}
private startGame() {
private async startGame() {
// If random map is selected, choose a random map now
if (this.useRandomMap) {
this.selectedMap = this.getRandomMap();
@@ -424,6 +425,10 @@ export class SinglePlayerModal extends LitElement {
if (!flagInput) {
console.warn("Flag input element not found");
}
const patternName = this.userSettings.getSelectedPatternName();
const pattern = patternName
? (await getCosmetics())?.patterns[patternName]
: undefined;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
@@ -439,7 +444,7 @@ export class SinglePlayerModal extends LitElement {
flagInput.getCurrentFlag() === "xx"
? ""
: flagInput.getCurrentFlag(),
pattern: this.userSettings.getSelectedPattern(),
pattern: pattern?.pattern,
},
],
config: {
+51 -51
View File
@@ -11,6 +11,8 @@ import "./components/Maps";
import { handlePurchase, patterns } from "./Cosmetics";
import { translateText } from "./Utils";
const BUTTON_WIDTH = 150;
@customElement("territory-patterns-modal")
export class TerritoryPatternsModal extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
@@ -19,22 +21,16 @@ export class TerritoryPatternsModal extends LitElement {
};
public previewButton: HTMLElement | null = null;
public buttonWidth: number = 150;
@state() private selectedPattern: string | undefined;
@state() private selectedPattern: Pattern | undefined;
@state() private lockedPatterns: string[] = [];
@state() private lockedReasons: Record<string, string> = {};
@state() private hoveredPattern: Pattern | null = null;
@state() private hoverPosition = { x: 0, y: 0 };
@state() private keySequence: string[] = [];
@state() private showChocoPattern = false;
private patterns: Pattern[] = [];
private me: UserMeResponse | null = null;
public resizeObserver: ResizeObserver;
private patterns: Map<string, Pattern> = new Map();
private userSettings: UserSettings = new UserSettings();
@@ -47,17 +43,11 @@ export class TerritoryPatternsModal extends LitElement {
connectedCallback() {
super.connectedCallback();
window.addEventListener("keydown", this.handleKeyDown);
this.selectedPattern = this.userSettings.getSelectedPattern();
this.updateComplete.then(() => {
const containers = this.renderRoot.querySelectorAll(".preview-container");
if (this.resizeObserver) {
containers.forEach((container) =>
this.resizeObserver.observe(container),
);
}
this.updatePreview();
this.open().then(() => {
this.updatePreview();
});
});
this.open();
}
disconnectedCallback() {
@@ -67,7 +57,10 @@ export class TerritoryPatternsModal extends LitElement {
async onUserMe(userMeResponse: UserMeResponse | null) {
this.patterns = await patterns(userMeResponse);
this.me = userMeResponse;
const storedPatternName = this.userSettings.getSelectedPatternName();
if (storedPatternName) {
this.selectedPattern = this.patterns.get(storedPatternName);
}
this.requestUpdate();
}
@@ -123,7 +116,7 @@ export class TerritoryPatternsModal extends LitElement {
}
private renderPatternButton(pattern: Pattern): TemplateResult {
const isSelected = this.selectedPattern === pattern.pattern;
const isSelected = this.selectedPattern?.name === pattern.name;
return html`
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
@@ -134,7 +127,7 @@ export class TerritoryPatternsModal extends LitElement {
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}"
@click=${() =>
pattern.product === null && this.selectPattern(pattern.pattern)}
pattern.product === null && this.selectPattern(pattern)}
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
@mouseleave=${() => this.handleMouseLeave()}
@@ -157,8 +150,8 @@ export class TerritoryPatternsModal extends LitElement {
>
${this.renderPatternPreview(
pattern.pattern,
this.buttonWidth,
this.buttonWidth,
BUTTON_WIDTH,
BUTTON_WIDTH,
)}
</div>
</button>
@@ -182,8 +175,8 @@ export class TerritoryPatternsModal extends LitElement {
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
for (const pattern of this.patterns) {
if (!this.showChocoPattern && pattern.name === "choco") continue;
for (const [name, pattern] of this.patterns) {
if (!this.showChocoPattern && name === "choco") continue;
const result = this.renderPatternButton(pattern);
buttons.push(result);
@@ -218,7 +211,7 @@ export class TerritoryPatternsModal extends LitElement {
overflow: hidden;
"
>
${this.renderBlankPreview(this.buttonWidth, this.buttonWidth)}
${this.renderBlankPreview(BUTTON_WIDTH, BUTTON_WIDTH)}
</div>
</button>
${buttons}
@@ -239,22 +232,31 @@ export class TerritoryPatternsModal extends LitElement {
`;
}
public open() {
this.modalEl?.open();
window.addEventListener("keydown", this.handleKeyDown);
public async open() {
this.isActive = true;
this.requestUpdate();
// Wait for the DOM to be updated and the o-modal element to be available
await this.updateComplete;
// Now modalEl should be available
if (this.modalEl) {
this.modalEl.open();
} else {
console.warn("modalEl is still null after updateComplete");
}
window.addEventListener("keydown", this.handleKeyDown);
}
public close() {
this.isActive = false;
this.modalEl?.close();
window.removeEventListener("keydown", this.handleKeyDown);
this.resizeObserver?.disconnect();
this.isActive = false;
}
private selectPattern(pattern: string | undefined) {
this.userSettings.setSelectedPattern(pattern);
private selectPattern(pattern: Pattern | undefined) {
this.userSettings.setSelectedPatternName(pattern?.name);
this.selectedPattern = pattern;
this.updatePreview();
this.close();
@@ -314,24 +316,14 @@ export class TerritoryPatternsModal extends LitElement {
public updatePreview() {
if (this.previewButton === null) return;
const preview = this.renderPatternPreview(this.selectedPattern, 48, 48);
const preview = this.renderPatternPreview(
this.selectedPattern?.pattern,
48,
48,
);
render(preview, this.previewButton);
}
private setLockedPatterns(lockedPatterns: string[], reason: string) {
this.lockedPatterns = [...this.lockedPatterns, ...lockedPatterns];
this.lockedReasons = {
...this.lockedReasons,
...lockedPatterns.reduce(
(acc, key) => {
acc[key] = reason;
return acc;
},
{} as Record<string, string>,
),
};
}
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
if (pattern.product !== null) {
this.hoveredPattern = pattern;
@@ -360,13 +352,21 @@ export function generatePreviewDataUrl(
height?: number,
): string {
pattern ??= DEFAULT_PATTERN_B64;
const patternLookupKey = `${pattern}-${width}-${height}`;
if (patternCache.has(pattern)) {
return patternCache.get(pattern)!;
if (patternCache.has(patternLookupKey)) {
return patternCache.get(patternLookupKey)!;
}
// Calculate canvas size
const decoder = new PatternDecoder(pattern, base64url.decode);
let decoder: PatternDecoder;
try {
decoder = new PatternDecoder(pattern, base64url.decode);
} catch (e) {
console.error("Error decoding pattern", e);
return "";
}
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
@@ -403,6 +403,6 @@ export function generatePreviewDataUrl(
// Create a data URL
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
patternCache.set(pattern, dataUrl);
patternCache.set(patternLookupKey, dataUrl);
return dataUrl;
}
+1 -6
View File
@@ -5,7 +5,6 @@ import {
GameType,
Gold,
PlayerID,
PlayerType,
Tick,
UnitType,
} from "../core/game/Game";
@@ -370,7 +369,7 @@ export class Transport {
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
flag: this.lobbyConfig.flag,
pattern: this.lobbyConfig.pattern,
patternName: this.lobbyConfig.patternName,
} satisfies ClientJoinMessage);
}
@@ -433,10 +432,6 @@ export class Transport {
this.sendIntent({
type: "spawn",
clientID: this.lobbyConfig.clientID,
flag: this.lobbyConfig.flag,
pattern: this.lobbyConfig.pattern,
name: this.lobbyConfig.playerName,
playerType: PlayerType.Human,
tile: event.tile,
});
}