diff --git a/resources/lang/en.json b/resources/lang/en.json index df37a229fc..6e78650a5e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -430,6 +430,8 @@ "not_enough_money": "Not enough money" }, "win_modal": { + "support_openfront": "Support OpenFront!", + "territory_pattern": "Purchase a territory pattern to support OpenFront!", "died": "You died", "your_team": "Your team won!", "other_team": "{team} team has won!", diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index f388e24e27..c6363bc340 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -2,7 +2,7 @@ import { UserMeResponse } from "../core/ApiSchemas"; import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas"; import { getApiBase, getAuthHeader } from "./jwt"; -export async function patterns( +export async function fetchPatterns( userMe: UserMeResponse | null, ): Promise> { const cosmetics = await getCosmetics(); @@ -12,7 +12,7 @@ export async function patterns( } const patterns: Map = new Map(); - const playerFlares = new Set(userMe?.player.flares); + const playerFlares = new Set(userMe?.player?.flares ?? []); for (const name in cosmetics.patterns) { const patternData = cosmetics.patterns[name]; diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 43130deaad..51fdfd93a8 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -1,18 +1,15 @@ -import { base64url } from "jose"; import type { TemplateResult } from "lit"; import { html, LitElement, render } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; import { Pattern } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; -import { PatternDecoder } from "../core/PatternDecoder"; import "./components/Difficulties"; -import "./components/Maps"; -import { handlePurchase, patterns } from "./Cosmetics"; +import "./components/PatternButton"; +import { renderPatternPreview } from "./components/PatternButton"; +import { fetchPatterns, handlePurchase } 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 & { @@ -22,10 +19,7 @@ export class TerritoryPatternsModal extends LitElement { public previewButton: HTMLElement | null = null; - @state() private selectedPattern: Pattern | undefined; - - @state() private hoveredPattern: Pattern | null = null; - @state() private hoverPosition = { x: 0, y: 0 }; + @state() private selectedPattern: Pattern | null; @state() private keySequence: string[] = []; @state() private showChocoPattern = false; @@ -48,12 +42,12 @@ export class TerritoryPatternsModal extends LitElement { async onUserMe(userMeResponse: UserMeResponse | null) { if (userMeResponse === null) { this.userSettings.setSelectedPatternName(undefined); - this.selectedPattern = undefined; + this.selectedPattern = null; } - this.patterns = await patterns(userMeResponse); + this.patterns = await fetchPatterns(userMeResponse); const storedPatternName = this.userSettings.getSelectedPatternName(); if (storedPatternName) { - this.selectedPattern = this.patterns.get(storedPatternName); + this.selectedPattern = this.patterns.get(storedPatternName) ?? null; } this.refresh(); } @@ -94,86 +88,18 @@ export class TerritoryPatternsModal extends LitElement { return this; } - private renderTooltip(): TemplateResult | null { - if (this.hoveredPattern && this.hoveredPattern.product !== undefined) { - return html` -
- ${translateText("territory_patterns.blocked.purchase")} -
- `; - } - return null; - } - - private renderPatternButton(pattern: Pattern): TemplateResult { - const isSelected = this.selectedPattern?.name === pattern.name; - - return html` -
- - ${pattern.product !== null - ? html` - - ` - : null} -
- `; - } - private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; for (const [name, pattern] of this.patterns) { if (!this.showChocoPattern && name === "choco") continue; - const result = this.renderPatternButton(pattern); - buttons.push(result); + buttons.push(html` + this.selectPattern(p)} + .onPurchase=${(priceId: string) => handlePurchase(priceId)} + > + `); } return html` @@ -181,33 +107,10 @@ export class TerritoryPatternsModal extends LitElement { class="flex flex-wrap gap-4 p-2" style="justify-content: center; align-items: flex-start;" > - + this.selectPattern(null)} + > ${buttons} `; @@ -216,7 +119,6 @@ export class TerritoryPatternsModal extends LitElement { render() { if (!this.isActive) return html``; return html` - ${this.renderTooltip()} - `; - } - - private renderBlankPreview(width: number, height: number): TemplateResult { - return html` -
-
-
-
-
-
-
-
- `; - } - public async refresh() { - const preview = this.renderPatternPreview( - this.selectedPattern?.pattern, + const preview = renderPatternPreview( + this.selectedPattern?.pattern ?? null, 48, 48, ); @@ -318,95 +168,4 @@ export class TerritoryPatternsModal extends LitElement { render(preview, this.previewButton); this.requestUpdate(); } - - private handleMouseEnter(pattern: Pattern, event: MouseEvent) { - if (pattern.product !== null) { - this.hoveredPattern = pattern; - this.hoverPosition = { x: event.clientX, y: event.clientY }; - } - } - - private handleMouseMove(event: MouseEvent) { - if (this.hoveredPattern) { - this.hoverPosition = { x: event.clientX, y: event.clientY }; - } - } - - private handleMouseLeave() { - this.hoveredPattern = null; - } -} - -const patternCache = new Map(); -const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern -const COLOR_SET = [0, 0, 0, 255]; // Black -const COLOR_UNSET = [255, 255, 255, 255]; // White -export function generatePreviewDataUrl( - pattern?: string, - width?: number, - height?: number, -): string { - pattern ??= DEFAULT_PATTERN_B64; - const patternLookupKey = `${pattern}-${width}-${height}`; - - if (patternCache.has(patternLookupKey)) { - return patternCache.get(patternLookupKey)!; - } - - // Calculate canvas size - 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(); - - width = - width === undefined - ? scaledWidth - : Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth; - height = - height === undefined - ? scaledHeight - : Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight; - - // Create the canvas - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("2D context not supported"); - - // Create an image - const imageData = ctx.createImageData(width, height); - const data = imageData.data; - let i = 0; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const rgba = decoder.isSet(x, y) ? COLOR_SET : COLOR_UNSET; - data[i++] = rgba[0]; // Red - data[i++] = rgba[1]; // Green - data[i++] = rgba[2]; // Blue - data[i++] = rgba[3]; // Alpha - } - } - - // Create a data URL - ctx.putImageData(imageData, 0, 0); - const dataUrl = canvas.toDataURL("image/png"); - patternCache.set(patternLookupKey, dataUrl); - return dataUrl; -} - -function translatePatternName(prefix: string, patternName: string): string { - const translation = translateText(`${prefix}.${patternName}`); - if (translation.startsWith(prefix)) { - // Translation was not found, fallback to pattern name - return patternName[0].toUpperCase() + patternName.substring(1); - } - return translation; } diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts new file mode 100644 index 0000000000..4a6508169c --- /dev/null +++ b/src/client/components/PatternButton.ts @@ -0,0 +1,216 @@ +import { base64url } from "jose"; +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { Pattern } from "../../core/CosmeticSchemas"; +import { PatternDecoder } from "../../core/PatternDecoder"; +import { translateText } from "../Utils"; + +export const BUTTON_WIDTH = 150; + +@customElement("pattern-button") +export class PatternButton extends LitElement { + @property({ type: Object }) + pattern: Pattern | null = null; + + @property({ type: Function }) + onSelect?: (pattern: Pattern | null) => void; + + @property({ type: Function }) + onPurchase?: (priceId: string) => void; + + createRenderRoot() { + return this; + } + + private translatePatternName(prefix: string, patternName: string): string { + const translation = translateText(`${prefix}.${patternName}`); + if (translation.startsWith(prefix)) { + return patternName[0].toUpperCase() + patternName.substring(1); + } + return translation; + } + + private handleClick() { + const isDefaultPattern = this.pattern === null; + if (isDefaultPattern || this.pattern?.product === null) { + this.onSelect?.(this.pattern); + } + } + + private handlePurchase(e: Event) { + e.stopPropagation(); + if (this.pattern?.product) { + this.onPurchase?.(this.pattern.product.priceId); + } + } + + render() { + const isDefaultPattern = this.pattern === null; + const isPurchasable = !isDefaultPattern && this.pattern?.product !== null; + + return html` +
+ + + ${isPurchasable + ? html` + + ` + : null} +
+ `; + } +} + +export function renderPatternPreview( + pattern: string | null, + width: number, + height: number, +): TemplateResult { + if (pattern === null) { + return renderBlankPreview(width, height); + } + const dataUrl = generatePreviewDataUrl(pattern, width, height); + return html`Pattern preview`; +} + +function renderBlankPreview(width: number, height: number): TemplateResult { + return html` +
+
+
+
+
+
+
+
+ `; +} + +const patternCache = new Map(); +const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern +const COLOR_SET = [0, 0, 0, 255]; // Black +const COLOR_UNSET = [255, 255, 255, 255]; // White +function generatePreviewDataUrl( + pattern?: string, + width?: number, + height?: number, +): string { + pattern ??= DEFAULT_PATTERN_B64; + const patternLookupKey = `${pattern}-${width}-${height}`; + + if (patternCache.has(patternLookupKey)) { + return patternCache.get(patternLookupKey)!; + } + + // Calculate canvas size + 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(); + + width = + width === undefined + ? scaledWidth + : Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth; + height = + height === undefined + ? scaledHeight + : Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight; + + // Create the canvas + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D context not supported"); + + // Create an image + const imageData = ctx.createImageData(width, height); + const data = imageData.data; + let i = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const rgba = decoder.isSet(x, y) ? COLOR_SET : COLOR_UNSET; + data[i++] = rgba[0]; // Red + data[i++] = rgba[1]; // Green + data[i++] = rgba[2]; // Blue + data[i++] = rgba[3]; // Alpha + } + } + + // Create a data URL + ctx.putImageData(imageData, 0, 0); + const dataUrl = canvas.toDataURL("image/png"); + patternCache.set(patternLookupKey, dataUrl); + return dataUrl; +} diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 25b4ff42c1..bd8b03bda5 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -1,9 +1,13 @@ -import { LitElement, css, html } from "lit"; +import { LitElement, TemplateResult, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../../../client/Utils"; +import { Pattern } from "../../../core/CosmeticSchemas"; import { EventBus } from "../../../core/EventBus"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; +import "../../components/PatternButton"; +import { fetchPatterns, handlePurchase } from "../../Cosmetics"; +import { getUserMe } from "../../jwt"; import { SendWinnerEvent } from "../../Transport"; import { GutterAdModalEvent } from "./GutterAdModal"; import { Layer } from "./Layer"; @@ -21,6 +25,9 @@ export class WinModal extends LitElement implements Layer { @state() showButtons = false; + @state() + private patternContent: TemplateResult | null = null; + private _title: string; // Override to prevent shadow DOM creation @@ -28,153 +35,117 @@ export class WinModal extends LitElement implements Layer { return this; } - static styles = css` - .win-modal { - display: none; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background-color: rgba(30, 30, 30, 0.7); - padding: 25px; - border-radius: 10px; - z-index: 9999; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); - backdrop-filter: blur(5px); - color: white; - width: 350px; - transition: - opacity 0.3s ease-in-out, - visibility 0.3s ease-in-out; - } - - .win-modal.visible { - display: block; - animation: fadeIn 0.3s ease-out; - } - - @keyframes fadeIn { - from { - opacity: 0; - transform: translate(-50%, -48%); - } - to { - opacity: 1; - transform: translate(-50%, -50%); - } - } - - .win-modal h2 { - margin: 0 0 15px 0; - font-size: 26px; - text-align: center; - color: white; - } - - .win-modal p { - margin: 0 0 20px 0; - text-align: center; - background-color: rgba(0, 0, 0, 0.3); - padding: 10px; - border-radius: 5px; - } - - .button-container { - display: flex; - justify-content: space-between; - gap: 10px; - } - - .win-modal button { - flex: 1; - padding: 12px; - font-size: 16px; - cursor: pointer; - background: rgba(0, 150, 255, 0.6); - color: white; - border: none; - border-radius: 5px; - transition: - background-color 0.2s ease, - transform 0.1s ease; - } - - .win-modal button:hover { - background: rgba(0, 150, 255, 0.8); - transform: translateY(-1px); - } - - .win-modal button:active { - transform: translateY(1px); - } - - @media (max-width: 768px) { - .win-modal { - width: 90%; - max-width: 300px; - padding: 20px; - } - - .win-modal h2 { - font-size: 26px; - } - - .win-modal button { - padding: 10px; - font-size: 14px; - } - } - `; - constructor() { super(); - // Add styles to document - const styleEl = document.createElement("style"); - styleEl.textContent = WinModal.styles.toString(); - document.head.appendChild(styleEl); } render() { return html` -
-

${this._title || ""}

+
+

+ ${this._title || ""} +

${this.innerHtml()}
- -
+ + `; } innerHtml() { - return html`

+ return this.renderPatternButton(); + } + + renderPatternButton() { + return html` +

+

+ ${translateText("win_modal.support_openfront")} +

+

+ ${translateText("win_modal.territory_pattern")} +

+
${this.patternContent}
+
+ `; + } + + async loadPatternContent() { + const me = await getUserMe(); + const patterns = await fetchPatterns(me !== false ? me : null); + + const purchasable = Array.from(patterns.values()).filter( + (p) => p.product !== null, + ); + + if (purchasable.length === 0) { + this.patternContent = html``; + return; + } + + const pattern = purchasable[Math.floor(Math.random() * purchasable.length)]; + + this.patternContent = html` + {}} + .onPurchase=${(priceId: string) => handlePurchase(priceId)} + > + `; + } + + steamWishlist(): TemplateResult { + return html`

${translateText("win_modal.wishlist")}

`; } - show() { + async show() { + await this.loadPatternContent(); this.eventBus.emit(new GutterAdModalEvent(true)); setTimeout(() => { this.isVisible = true;