mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:00:15 +00:00
Patterns (#1318)
## Description: Replace the HTML element pattern grid with a PNG image.   ## 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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: ${height}px;
|
||||
width: ${width}px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${cellCountX}, ${cellSize}px);
|
||||
grid-template-rows: repeat(${cellCountY}, ${cellSize}px);
|
||||
background-color: #ccc;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
"
|
||||
>
|
||||
${(() => {
|
||||
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`
|
||||
<div
|
||||
style="
|
||||
background-color: ${bit ? "#000" : "transparent"};
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
width: ${cellSize}px;
|
||||
height: ${cellSize}px;
|
||||
border-radius: 1px;
|
||||
"
|
||||
></div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<img src="${generatePreviewDataUrl(pattern, width, height)}"></img>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user