mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-10 18:34:37 +00:00
cosmetic refactor (#3628)
## Description: The motivation is to have a single "cosmetic-button" element, so we can abstract out the cosmetic types. This will make it much easier to add new cosmetic types in the future. Unifies PatternButton and FlagButton into a single CosmeticButton component. Extracts a resolveCosmetics() function that flattens patterns × color palettes + flags into a ResolvedCosmetic[] with relationship status pre-computed, replacing duplicated resolution logic across four callers. * New CosmeticButton — renders patterns or flags based on ResolvedCosmetic.type * New resolveCosmetics() — centralizes ownership/purchase/blocked resolution * Extracted PatternPreview — canvas rendering split into its own module * Added type: "pattern" | "flag" discriminator to Zod cosmetic schemas * Deleted FlagButton.ts and PatternButton.ts * Added 320-line test suite for resolveCosmetics ## 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:
@@ -0,0 +1,115 @@
|
||||
import { html, LitElement, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Flag, Pattern } from "../../core/CosmeticSchemas";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
import { renderPatternPreview } from "./PatternPreview";
|
||||
|
||||
@customElement("cosmetic-button")
|
||||
export class CosmeticButton extends LitElement {
|
||||
@property({ type: Object })
|
||||
resolved!: ResolvedCosmetic;
|
||||
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (resolved: ResolvedCosmetic) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: (resolved: ResolvedCosmetic) => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.onSelect?.(this.resolved);
|
||||
}
|
||||
|
||||
private get displayName(): string {
|
||||
const c = this.resolved.cosmetic;
|
||||
if (c === null) {
|
||||
return translateText("territory_patterns.pattern.default");
|
||||
}
|
||||
if (this.resolved.type === "pattern") {
|
||||
return translateCosmetic("territory_patterns.pattern", c.name);
|
||||
}
|
||||
return translateCosmetic("flags", c.name);
|
||||
}
|
||||
|
||||
private renderPreview(): TemplateResult {
|
||||
if (this.resolved.type === "pattern") {
|
||||
const c = this.resolved.cosmetic;
|
||||
const playerPattern: PlayerPattern | null =
|
||||
c === null
|
||||
? null
|
||||
: {
|
||||
name: c.name,
|
||||
patternData: (c as Pattern).pattern,
|
||||
colorPalette: this.resolved.colorPalette ?? undefined,
|
||||
};
|
||||
return renderPatternPreview(playerPattern, 150, 150);
|
||||
}
|
||||
|
||||
const c = this.resolved.cosmetic as Flag;
|
||||
return html`<img
|
||||
src=${c.url}
|
||||
alt=${c.name}
|
||||
class="w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const c = this.resolved.cosmetic;
|
||||
const isPurchasable = this.resolved.relationship === "purchasable";
|
||||
const isPattern = this.resolved.type === "pattern";
|
||||
const sizeClass = isPattern ? "gap-2 p-3 w-48" : "gap-1 p-1.5 w-36";
|
||||
const crazygamesClass = isPattern ? "no-crazygames " : "";
|
||||
|
||||
return html`
|
||||
<cosmetic-container
|
||||
class="${crazygamesClass}flex flex-col items-center justify-between ${sizeClass} h-full"
|
||||
.rarity=${c?.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${isPurchasable && c?.product ? c.product : null}
|
||||
.onPurchase=${() => this.onPurchase?.(this.resolved)}
|
||||
.name=${this.displayName}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full ${isPattern
|
||||
? "gap-2"
|
||||
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${() => this.handleClick()}
|
||||
>
|
||||
${c?.product
|
||||
? html`<cosmetic-info
|
||||
.artist=${c.artist}
|
||||
.rarity=${c.rarity}
|
||||
.colorPalette=${this.resolved.colorPalette?.name}
|
||||
.showAdFree=${isPurchasable}
|
||||
></cosmetic-info>`
|
||||
: nothing}
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
${this.renderPreview()}
|
||||
</div>
|
||||
</button>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export class CosmeticInfo extends LitElement {
|
||||
@property({ type: String })
|
||||
colorPalette?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
showAdFree: boolean = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -53,9 +56,11 @@ export class CosmeticInfo extends LitElement {
|
||||
${translateText(`cosmetics.${this.rarity}`) || this.rarity}
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="text-green-400 font-bold">
|
||||
${translateText("cosmetics.adfree")}
|
||||
</div>
|
||||
${this.showAdFree
|
||||
? html`<div class="text-green-400 font-bold">
|
||||
${translateText("cosmetics.adfree")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${this.colorPalette
|
||||
? html`<div>
|
||||
${translateText("cosmetics.color_label")}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Flag } from "../../core/CosmeticSchemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
|
||||
export type FlagItem = Flag & { key: string };
|
||||
|
||||
@customElement("flag-button")
|
||||
export class FlagButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
flag!: FlagItem;
|
||||
|
||||
@property({ type: Boolean })
|
||||
requiresPurchase: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (flagKey: string) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: () => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.requiresPurchase) {
|
||||
this.onPurchase?.();
|
||||
return;
|
||||
}
|
||||
this.onSelect?.(this.flag.key);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<cosmetic-container
|
||||
class="flex flex-col items-center justify-between gap-1 p-1.5 w-36 h-full"
|
||||
.rarity=${this.flag.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${this.requiresPurchase && this.flag.product
|
||||
? this.flag.product
|
||||
: null}
|
||||
.onPurchase=${() => this.onPurchase?.()}
|
||||
.name=${translateCosmetic("flags", this.flag.name)}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<cosmetic-info
|
||||
.artist=${this.flag.artist}
|
||||
.rarity=${this.flag.rarity}
|
||||
></cosmetic-info>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src=${this.flag.url}
|
||||
alt=${this.flag.name}
|
||||
class="w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import {
|
||||
ColorPalette,
|
||||
DefaultPattern,
|
||||
Pattern,
|
||||
} from "../../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { translateCosmetic } from "../Cosmetics";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CosmeticContainer";
|
||||
import "./CosmeticInfo";
|
||||
|
||||
export const BUTTON_WIDTH = 150;
|
||||
|
||||
@customElement("pattern-button")
|
||||
export class PatternButton extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
selected: boolean = false;
|
||||
@property({ type: Object })
|
||||
pattern: Pattern | null = null;
|
||||
|
||||
@property({ type: Object })
|
||||
colorPalette: ColorPalette | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
requiresPurchase: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (pattern: PlayerPattern | null) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.requiresPurchase) {
|
||||
this.handlePurchase();
|
||||
return;
|
||||
}
|
||||
if (this.pattern === null) {
|
||||
this.onSelect?.(null);
|
||||
return;
|
||||
}
|
||||
this.onSelect?.({
|
||||
name: this.pattern!.name,
|
||||
patternData: this.pattern!.pattern,
|
||||
colorPalette: this.colorPalette ?? undefined,
|
||||
} satisfies PlayerPattern);
|
||||
}
|
||||
|
||||
private handlePurchase() {
|
||||
if (this.pattern?.product) {
|
||||
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isDefaultPattern = this.pattern === null;
|
||||
|
||||
return html`
|
||||
<cosmetic-container
|
||||
class="no-crazygames flex flex-col items-center justify-between gap-2 p-3 w-48 h-full"
|
||||
.rarity=${this.pattern?.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${this.requiresPurchase && this.pattern?.product
|
||||
? this.pattern.product
|
||||
: null}
|
||||
.onPurchase=${() => this.handlePurchase()}
|
||||
.name=${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: translateCosmetic("territory_patterns.pattern", this.pattern!.name)}
|
||||
>
|
||||
<button
|
||||
class="group relative flex flex-col items-center w-full gap-2 rounded-lg cursor-pointer transition-all duration-200 flex-1"
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<cosmetic-info
|
||||
.artist=${this.pattern?.artist}
|
||||
.rarity=${this.pattern?.rarity}
|
||||
.colorPalette=${this.colorPalette?.name ?? undefined}
|
||||
></cosmetic-info>
|
||||
|
||||
<div
|
||||
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
|
||||
>
|
||||
${renderPatternPreview(
|
||||
this.pattern !== null
|
||||
? ({
|
||||
name: this.pattern!.name,
|
||||
patternData: this.pattern!.pattern,
|
||||
colorPalette: this.colorPalette ?? undefined,
|
||||
} satisfies PlayerPattern)
|
||||
: DefaultPattern,
|
||||
BUTTON_WIDTH,
|
||||
BUTTON_WIDTH,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</cosmetic-container>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPatternPreview(
|
||||
pattern: PlayerPattern | null,
|
||||
width: number,
|
||||
height: number,
|
||||
): TemplateResult {
|
||||
if (pattern === null) {
|
||||
return renderBlankPreview(width, height);
|
||||
}
|
||||
return html`<img
|
||||
src="${generatePreviewDataUrl(pattern, width, height)}"
|
||||
alt="Pattern preview"
|
||||
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
|
||||
draggable="false"
|
||||
/>`;
|
||||
}
|
||||
|
||||
function renderBlankPreview(width: number, height: number): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
|
||||
>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
|
||||
>
|
||||
${translateText("territory_patterns.select_skin")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const patternCache = new Map<string, string>();
|
||||
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb(); // White
|
||||
const DEFAULT_SECONDARY = new Colord("#000000").toRgb(); // Black
|
||||
function generatePreviewDataUrl(
|
||||
pattern?: PlayerPattern,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): string {
|
||||
pattern ??= DefaultPattern;
|
||||
const patternLookupKey = [
|
||||
pattern.name,
|
||||
pattern.colorPalette?.primaryColor ?? "undefined",
|
||||
pattern.colorPalette?.secondaryColor ?? "undefined",
|
||||
width,
|
||||
height,
|
||||
].join("-");
|
||||
|
||||
if (patternCache.has(patternLookupKey)) {
|
||||
return patternCache.get(patternLookupKey)!;
|
||||
}
|
||||
|
||||
// Calculate canvas size
|
||||
let decoder: PatternDecoder;
|
||||
try {
|
||||
decoder = new PatternDecoder(
|
||||
{
|
||||
name: pattern.name,
|
||||
patternData: pattern.patternData,
|
||||
colorPalette: pattern.colorPalette,
|
||||
},
|
||||
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;
|
||||
const primary = pattern.colorPalette?.primaryColor
|
||||
? new Colord(pattern.colorPalette.primaryColor).toRgb()
|
||||
: DEFAULT_PRIMARY;
|
||||
const secondary = pattern.colorPalette?.secondaryColor
|
||||
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
|
||||
: DEFAULT_SECONDARY;
|
||||
let i = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
|
||||
data[i++] = rgba.r;
|
||||
data[i++] = rgba.g;
|
||||
data[i++] = rgba.b;
|
||||
data[i++] = 255; // Alpha
|
||||
}
|
||||
}
|
||||
|
||||
// Create a data URL
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
patternCache.set(patternLookupKey, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { DefaultPattern } from "../../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export function renderPatternPreview(
|
||||
pattern: PlayerPattern | null,
|
||||
width: number,
|
||||
height: number,
|
||||
): TemplateResult {
|
||||
if (pattern === null) {
|
||||
return renderBlankPreview();
|
||||
}
|
||||
return html`<img
|
||||
src="${generatePreviewDataUrl(pattern, width, height)}"
|
||||
alt="Pattern preview"
|
||||
class="w-full h-full object-contain [image-rendering:pixelated] pointer-events-none"
|
||||
draggable="false"
|
||||
/>`;
|
||||
}
|
||||
|
||||
function renderBlankPreview(): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="md:hidden flex items-center justify-center h-full w-full bg-white rounded overflow-hidden relative border border-[#ccc] box-border"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 grid-rows-2 gap-0 w-[calc(100%-1px)] h-[calc(100%-2px)] box-border"
|
||||
>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
<div class="bg-white border border-black/10 box-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden md:flex items-center justify-center h-full w-full rounded overflow-hidden relative text-center p-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black text-white/40 uppercase leading-none break-words w-full"
|
||||
>
|
||||
${translateText("territory_patterns.select_skin")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const patternCache = new Map<string, string>();
|
||||
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb();
|
||||
const DEFAULT_SECONDARY = new Colord("#000000").toRgb();
|
||||
|
||||
export function generatePreviewDataUrl(
|
||||
pattern?: PlayerPattern,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): string {
|
||||
pattern ??= DefaultPattern;
|
||||
const patternLookupKey = [
|
||||
pattern.name,
|
||||
pattern.colorPalette?.primaryColor ?? "undefined",
|
||||
pattern.colorPalette?.secondaryColor ?? "undefined",
|
||||
width,
|
||||
height,
|
||||
].join("-");
|
||||
|
||||
if (patternCache.has(patternLookupKey)) {
|
||||
return patternCache.get(patternLookupKey)!;
|
||||
}
|
||||
|
||||
let decoder: PatternDecoder;
|
||||
try {
|
||||
decoder = new PatternDecoder(
|
||||
{
|
||||
name: pattern.name,
|
||||
patternData: pattern.patternData,
|
||||
colorPalette: pattern.colorPalette,
|
||||
},
|
||||
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;
|
||||
|
||||
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");
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
const primary = pattern.colorPalette?.primaryColor
|
||||
? new Colord(pattern.colorPalette.primaryColor).toRgb()
|
||||
: DEFAULT_PRIMARY;
|
||||
const secondary = pattern.colorPalette?.secondaryColor
|
||||
? new Colord(pattern.colorPalette.secondaryColor).toRgb()
|
||||
: DEFAULT_SECONDARY;
|
||||
let i = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
|
||||
data[i++] = rgba.r;
|
||||
data[i++] = rgba.g;
|
||||
data[i++] = rgba.b;
|
||||
data[i++] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
patternCache.set(patternLookupKey, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
Reference in New Issue
Block a user