From 47ccbc047351ce9566d2db249c2d659f538c4c7b Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:45:18 -0400 Subject: [PATCH] Patterns (#1318) ## Description: Replace the HTML element pattern grid with a PNG image. ![image](https://github.com/user-attachments/assets/557d1f35-1a5e-4bb2-bd6e-4ba4ad1593f9) ![image](https://github.com/user-attachments/assets/426194ef-237d-42d7-a775-d91a7cc161f4) ## 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 understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --- resources/cosmetics/cosmetics.json | 16 +-- resources/lang/en.json | 8 +- src/client/TerritoryPatternsModal.ts | 119 +++++++++---------- src/client/graphics/layers/TerritoryLayer.ts | 9 +- src/core/PatternDecoder.ts | 39 +++--- 5 files changed, 86 insertions(+), 105 deletions(-) diff --git a/resources/cosmetics/cosmetics.json b/resources/cosmetics/cosmetics.json index cd6023c60..0efe2818b 100644 --- a/resources/cosmetics/cosmetics.json +++ b/resources/cosmetics/cosmetics.json @@ -10,6 +10,14 @@ "ABMIDw8": { "name": "stripes_h" }, + "AAEYAwA": { + "name": "horizontal_stripes", + "role_group": "donor" + }, + "AAoACQ": { + "name": "vertical_bars", + "role_group": "donor" + }, "ABMIpaU": { "name": "checkerboard" }, @@ -28,10 +36,6 @@ "name": "mini_cross", "role_group": "donor" }, - "AHE4__8AAAAAAAAAAAAAAAAAAP__AAAAAAAAAAAAAAAAAAA": { - "name": "horizontal_stripes", - "role_group": "donor" - }, "AHI4AOAAkACIAEQAIgARjAhUBCgCKAHQACgB1AILAwUABwA": { "name": "sword", "role_group": "donor" @@ -60,10 +64,6 @@ "name": "circuit_board", "role_group": "donor" }, - "AHEYSZJJkkmSSZJJkkmSSZJJkg": { - "name": "vertical_bars", - "role_group": "donor" - }, "AHEgzGfznzu43XPoL2fMn_O4O3fdL-g": { "name": "shells", "role_group": "donor" diff --git a/resources/lang/en.json b/resources/lang/en.json index 244d1f426..cd9f6aa41 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -496,14 +496,15 @@ "pattern": { "default": "Default", "custom": "Custom", - "stripes_v": "Vertical Stripes", - "stripes_h": "Horizontal Stripes", + "stripes_v": "Vertical", + "stripes_h": "Horizontal", + "horizontal_stripes": "Horizontal (Alt)", + "vertical_bars": "Vertical (Alt)", "checkerboard": "Checkerboard", "choco": "Choco", "diagonal": "Diagonal", "cross": "Cross", "mini_cross": "Mini Cross", - "horizontal_stripes": "Horizontal Stripes (Alt)", "sword": "Sword", "sparse_dots": "Sparse Dots", "evan": "Evan", @@ -511,7 +512,6 @@ "mountain_ridge": "Mountain Ridge", "scattered_dots": "Scattered Dots", "circuit_board": "Circuit Board", - "vertical_bars": "Vertical Bars", "shells": "Shells", "-w-": ".w.", "white_rabbit": "White Rabbit", diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index e2788d5fb..226242967 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -17,7 +17,7 @@ export class TerritoryPatternsModal extends LitElement { }; public previewButton: HTMLElement | null = null; - public buttonWidth: number = 100; + public buttonWidth: number = 150; @state() private selectedPattern: string | undefined; @@ -267,68 +267,12 @@ export class TerritoryPatternsModal extends LitElement { } private renderPatternPreview( - pattern: string, - width: number, - height: number, + pattern?: string, + width?: number, + height?: number, ): TemplateResult { - const decoder = new PatternDecoder(pattern); - const cellCountX = decoder.getTileWidth(); - const cellCountY = decoder.getTileHeight(); - - const cellSize = - cellCountX > 0 && cellCountY > 0 - ? Math.min(height / cellCountY, width / cellCountX) - : 1; - return html` -
-
- ${(() => { - const tiles: TemplateResult[] = []; - for (let py = 0; py < cellCountY; py++) { - for (let px = 0; px < cellCountX; px++) { - const x = px << decoder.getScale(); - const y = py << decoder.getScale(); - const bit = decoder.isSet(x, y); - tiles.push(html` -
- `); - } - } - return tiles; - })()} -
-
+ `; } @@ -376,10 +320,7 @@ export class TerritoryPatternsModal extends LitElement { public updatePreview() { if (this.previewButton === null) return; - const preview = - this.selectedPattern === undefined - ? this.renderBlankPreview(48, 48) - : this.renderPatternPreview(this.selectedPattern, 48, 48); + const preview = this.renderPatternPreview(this.selectedPattern, 48, 48); render(preview, this.previewButton); } @@ -418,3 +359,51 @@ export class TerritoryPatternsModal extends LitElement { this.hoveredPattern = null; } } + +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 { + // Calculate canvas size + const decoder = new PatternDecoder(pattern ?? DEFAULT_PATTERN_B64); + 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); + return canvas.toDataURL("image/png"); +} diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index 6fdd7a48b..21fd3d150 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -317,13 +317,8 @@ export class TerritoryLayer implements Layer { const baseColor = this.theme.territoryColor(owner); const decoder = owner.patternDecoder(); - if (decoder !== undefined) { - const bit = decoder.isSet(x, y) ? 1 : 0; - const colorToUse = bit ? baseColor.darken(0.2) : baseColor; - this.paintTile(tile, colorToUse, 150); - } else { - this.paintTile(tile, baseColor, 150); - } + const color = decoder?.isSet(x, y) ? baseColor.darken(0.2) : baseColor; + this.paintTile(tile, color, 150); } } } diff --git a/src/core/PatternDecoder.ts b/src/core/PatternDecoder.ts index e489bceef..167732de3 100644 --- a/src/core/PatternDecoder.ts +++ b/src/core/PatternDecoder.ts @@ -2,9 +2,10 @@ import { base64url } from "jose"; export class PatternDecoder { private bytes: Uint8Array; - private tileWidth: number; - private tileHeight: number; - private scale: number; + + readonly height: number; + readonly width: number; + readonly scale: number; constructor(base64: string) { this.bytes = base64url.decode(base64); @@ -24,10 +25,10 @@ export class PatternDecoder { const byte2 = this.bytes[2]; this.scale = byte1 & 0x07; - this.tileWidth = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2; - this.tileHeight = ((byte2 >> 2) & 0x3f) + 2; + this.width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2; + this.height = ((byte2 >> 2) & 0x3f) + 2; - const expectedBits = this.tileWidth * this.tileHeight; + const expectedBits = this.width * this.height; const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8); if (this.bytes.length - 3 < expectedBytes) { throw new Error( @@ -36,26 +37,22 @@ export class PatternDecoder { } } - getTileWidth(): number { - return this.tileWidth; - } - - getTileHeight(): number { - return this.tileHeight; - } - - getScale(): number { - return this.scale; - } - isSet(x: number, y: number): boolean { - const px = (x >> this.scale) % this.tileWidth; - const py = (y >> this.scale) % this.tileHeight; - const idx = py * this.tileWidth + px; + const px = (x >> this.scale) % this.width; + const py = (y >> this.scale) % this.height; + const idx = py * this.width + px; const byteIndex = idx >> 3; const bitIndex = idx & 7; const byte = this.bytes[3 + byteIndex]; if (byte === undefined) throw new Error("Invalid pattern"); return (byte & (1 << bitIndex)) !== 0; } + + scaledHeight(): number { + return this.height << this.scale; + } + + scaledWidth(): number { + return this.width << this.scale; + } }