mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
Show random pattern on end screen (#1930)
## Description: To advertise patterns, show a random, purchasable pattern on the end screen. * Refactored the pattern button into a reusable PatternButton lit component * Used tailwind instead of CSS for styling because the CSS affects lit components due to using the light-dom * Removed the tooltip, didn't seem necessary since there is already a big "purchase" button under the pattern <img width="383" height="556" alt="Screenshot 2025-08-25 at 1 26 26 PM" src="https://github.com/user-attachments/assets/3f109cea-2759-4a07-9322-4a1a30b43503" /> ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -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!",
|
||||
|
||||
@@ -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<Map<string, Pattern>> {
|
||||
const cosmetics = await getCosmetics();
|
||||
@@ -12,7 +12,7 @@ export async function patterns(
|
||||
}
|
||||
|
||||
const patterns: Map<string, Pattern> = 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];
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
|
||||
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
|
||||
.x + 12}px;"
|
||||
>
|
||||
${translateText("territory_patterns.blocked.purchase")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderPatternButton(pattern: Pattern): TemplateResult {
|
||||
const isSelected = this.selectedPattern?.name === pattern.name;
|
||||
|
||||
return html`
|
||||
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
|
||||
<button
|
||||
class="border p-2 rounded-lg shadow text-black dark:text-white text-left w-full
|
||||
${isSelected
|
||||
? "bg-blue-500 text-white"
|
||||
: "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)}
|
||||
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
|
||||
@mouseleave=${() => this.handleMouseLeave()}
|
||||
>
|
||||
<div class="text-sm font-bold mb-1">
|
||||
${translatePatternName("territory_patterns.pattern", pattern.name)}
|
||||
</div>
|
||||
<div
|
||||
class="preview-container"
|
||||
style="
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
${this.renderPatternPreview(
|
||||
pattern.pattern,
|
||||
BUTTON_WIDTH,
|
||||
BUTTON_WIDTH,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
${pattern.product !== null
|
||||
? html`
|
||||
<button
|
||||
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
handlePurchase(pattern.product!.priceId);
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
(${pattern.product!.price})
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(priceId: string) => handlePurchase(priceId)}
|
||||
></pattern-button>
|
||||
`);
|
||||
}
|
||||
|
||||
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;"
|
||||
>
|
||||
<button
|
||||
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
|
||||
${this.selectedPattern === undefined
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}"
|
||||
style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);"
|
||||
@click=${() => this.selectPattern(undefined)}
|
||||
>
|
||||
<div class="text-sm font-bold mb-1">
|
||||
${translateText("territory_patterns.pattern.default")}
|
||||
</div>
|
||||
<div
|
||||
class="preview-container"
|
||||
style="
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
${this.renderBlankPreview(BUTTON_WIDTH, BUTTON_WIDTH)}
|
||||
</div>
|
||||
</button>
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
${buttons}
|
||||
</div>
|
||||
`;
|
||||
@@ -216,7 +119,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
render() {
|
||||
if (!this.isActive) return html``;
|
||||
return html`
|
||||
${this.renderTooltip()}
|
||||
<o-modal
|
||||
id="territoryPatternsModal"
|
||||
title="${translateText("territory_patterns.title")}"
|
||||
@@ -238,68 +140,16 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
private selectPattern(pattern: Pattern | undefined) {
|
||||
private selectPattern(pattern: Pattern | null) {
|
||||
this.userSettings.setSelectedPatternName(pattern?.name);
|
||||
this.selectedPattern = pattern;
|
||||
this.refresh();
|
||||
this.close();
|
||||
}
|
||||
|
||||
private renderPatternPreview(
|
||||
pattern?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<img src="${generatePreviewDataUrl(pattern, width, height)}"></img>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBlankPreview(width: number, height: number): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: ${height}px;
|
||||
width: ${width}px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="display: grid; grid-template-columns: repeat(2, ${width /
|
||||
2}px); grid-template-rows: repeat(2, ${height / 2}px);"
|
||||
>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
|
||||
2}px; height: ${height / 2}px;"
|
||||
></div>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
|
||||
2}px; height: ${height / 2}px;"
|
||||
></div>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
|
||||
2}px; height: ${height / 2}px;"
|
||||
></div>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); width: ${width /
|
||||
2}px; height: ${height / 2}px;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 p-3 bg-white/10 rounded-lg max-w-[200px]"
|
||||
>
|
||||
<button
|
||||
class="bg-white/90 border-2 border-black/10 rounded-lg p-2 cursor-pointer transition-all duration-200 w-full
|
||||
hover:bg-white hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
||||
?disabled=${isPurchasable}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<div class="text-sm font-bold text-gray-800 mb-2 text-center">
|
||||
${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: this.translatePatternName(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="w-[120px] h-[120px] flex items-center justify-center bg-white rounded p-1 mx-auto"
|
||||
style="overflow: hidden;"
|
||||
>
|
||||
${renderPatternPreview(
|
||||
this.pattern?.pattern ?? null,
|
||||
BUTTON_WIDTH,
|
||||
BUTTON_WIDTH,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${isPurchasable
|
||||
? html`
|
||||
<button
|
||||
class="w-full px-4 py-2 bg-green-500 text-white border-0 rounded-md text-sm font-semibold cursor-pointer transition-colors duration-200
|
||||
hover:bg-green-600"
|
||||
@click=${this.handlePurchase}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
(${this.pattern!.product!.price})
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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`<img
|
||||
src="${dataUrl}"
|
||||
alt="Pattern preview"
|
||||
class="w-full h-full object-contain"
|
||||
style="image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges;"
|
||||
/>`;
|
||||
}
|
||||
|
||||
function renderBlankPreview(width: number, height: number): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: ${height}px;
|
||||
width: ${width}px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 0; width: calc(100% - 1px); height: calc(100% - 2px); box-sizing: border-box;"
|
||||
>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
|
||||
></div>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
|
||||
></div>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
|
||||
></div>
|
||||
<div
|
||||
style="background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); box-sizing: border-box;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const patternCache = new Map<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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`
|
||||
<div class="win-modal ${this.isVisible ? "visible" : ""}">
|
||||
<h2>${this._title || ""}</h2>
|
||||
<div
|
||||
class="${this.isVisible
|
||||
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/70 p-6 rounded-lg z-[9999] shadow-2xl backdrop-blur-sm text-white w-[350px] max-w-[90%] md:max-w-[350px] animate-fadeIn"
|
||||
: "hidden"}"
|
||||
>
|
||||
<h2 class="m-0 mb-4 text-[26px] text-center text-white">
|
||||
${this._title || ""}
|
||||
</h2>
|
||||
${this.innerHtml()}
|
||||
<div
|
||||
class="button-container ${this.showButtons ? "visible" : "hidden"}"
|
||||
class="${this.showButtons
|
||||
? "flex justify-between gap-2.5"
|
||||
: "hidden"}"
|
||||
>
|
||||
<button @click=${this._handleExit}>
|
||||
<button
|
||||
@click=${this._handleExit}
|
||||
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
|
||||
>
|
||||
${translateText("win_modal.exit")}
|
||||
</button>
|
||||
<button @click=${this.hide}>
|
||||
<button
|
||||
@click=${this.hide}
|
||||
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
|
||||
>
|
||||
${translateText("win_modal.keep")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
innerHtml() {
|
||||
return html`<p>
|
||||
return this.renderPatternButton();
|
||||
}
|
||||
|
||||
renderPatternButton() {
|
||||
return html`
|
||||
<div class="text-center mb-6 bg-black/30 p-2.5 rounded">
|
||||
<h3 class="text-xl font-semibold text-white mb-3">
|
||||
${translateText("win_modal.support_openfront")}
|
||||
</h3>
|
||||
<p class="text-white mb-3">
|
||||
${translateText("win_modal.territory_pattern")}
|
||||
</p>
|
||||
<div class="flex justify-center">${this.patternContent}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.onSelect=${(p: Pattern | null) => {}}
|
||||
.onPurchase=${(priceId: string) => handlePurchase(priceId)}
|
||||
></pattern-button>
|
||||
`;
|
||||
}
|
||||
|
||||
steamWishlist(): TemplateResult {
|
||||
return html`<p class="m-0 mb-5 text-center bg-black/30 p-2.5 rounded">
|
||||
<a
|
||||
href="https://store.steampowered.com/app/3560670"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="
|
||||
color: #4a9eff;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
font-size: 24px;
|
||||
"
|
||||
onmouseover="this.style.color='#6db3ff'"
|
||||
onmouseout="this.style.color='#4a9eff'"
|
||||
class="text-[#4a9eff] underline font-medium transition-colors duration-200 text-2xl hover:text-[#6db3ff]"
|
||||
>
|
||||
${translateText("win_modal.wishlist")}
|
||||
</a>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
show() {
|
||||
async show() {
|
||||
await this.loadPatternContent();
|
||||
this.eventBus.emit(new GutterAdModalEvent(true));
|
||||
setTimeout(() => {
|
||||
this.isVisible = true;
|
||||
|
||||
Reference in New Issue
Block a user