mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:40:42 +00:00
Add support for colored patterns (#2062)
## Description: Add support for colored territory patterns/skins * Refactored & updated territory pattern rendering to render colored skins * rename public from pattern to skin (keep pattern name internally, too difficult to rename) * Moved all territory color logic to PlayerView * Updated WinModal to show colored skins * Refactored decode logic into a separate function: decodePatternData * Refactored/updated how cosmetics are sent to server. Players now send a PlayerCosmeticRefsSchema in the ClientJoinMessage. PlayerCosmeticRefsSchema just contains names of the cosmetics, and the server replaces the names/references with actual cosmetic data * Refactored PastelThemeDark: have it extend Pastel theme so duplicate logic can be removed. * ## 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:
@@ -558,7 +558,7 @@
|
||||
"choose_spawn": "Choose a starting location"
|
||||
},
|
||||
"territory_patterns": {
|
||||
"title": "Select Territory Pattern",
|
||||
"title": "Select Territory Skin",
|
||||
"purchase": "Purchase",
|
||||
"blocked": {
|
||||
"login": "You must be logged in to access this pattern.",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GameID,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
PlayerPattern,
|
||||
PlayerRecord,
|
||||
ServerMessage,
|
||||
} from "../core/Schemas";
|
||||
@@ -47,7 +48,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
|
||||
|
||||
export interface LobbyConfig {
|
||||
serverConfig: ServerConfig;
|
||||
patternName: string | undefined;
|
||||
pattern: PlayerPattern | undefined;
|
||||
flag: string;
|
||||
playerName: string;
|
||||
clientID: ClientID;
|
||||
|
||||
+59
-34
@@ -1,38 +1,17 @@
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
|
||||
import {
|
||||
ColorPalette,
|
||||
Cosmetics,
|
||||
CosmeticsSchema,
|
||||
Pattern,
|
||||
} from "../core/CosmeticSchemas";
|
||||
import { getApiBase, getAuthHeader } from "./jwt";
|
||||
import { getPersistentID } from "./Main";
|
||||
|
||||
export async function fetchPatterns(
|
||||
userMe: UserMeResponse | null,
|
||||
): Promise<Map<string, Pattern>> {
|
||||
const cosmetics = await getCosmetics();
|
||||
|
||||
if (cosmetics === undefined) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const patterns: Map<string, Pattern> = new Map();
|
||||
const playerFlares = new Set(userMe?.player?.flares ?? []);
|
||||
const hasAllPatterns = playerFlares.has("pattern:*");
|
||||
|
||||
for (const name in cosmetics.patterns) {
|
||||
const patternData = cosmetics.patterns[name];
|
||||
const hasAccess = hasAllPatterns || playerFlares.has(`pattern:${name}`);
|
||||
if (hasAccess) {
|
||||
// Remove product info because player already has access.
|
||||
patternData.product = null;
|
||||
patterns.set(name, patternData);
|
||||
} else if (patternData.product !== null) {
|
||||
// Player doesn't have access, but product is available for purchase.
|
||||
patterns.set(name, patternData);
|
||||
}
|
||||
// If player doesn't have access and product is null, don't show it.
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
export async function handlePurchase(pattern: Pattern) {
|
||||
export async function handlePurchase(
|
||||
pattern: Pattern,
|
||||
colorPalette: ColorPalette | null,
|
||||
) {
|
||||
if (pattern.product === null) {
|
||||
alert("This pattern is not available for purchase.");
|
||||
return;
|
||||
@@ -50,6 +29,7 @@ export async function handlePurchase(pattern: Pattern) {
|
||||
body: JSON.stringify({
|
||||
priceId: pattern.product.priceId,
|
||||
hostname: window.location.origin,
|
||||
colorPaletteName: colorPalette?.name,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -72,20 +52,65 @@ export async function handlePurchase(pattern: Pattern) {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
export async function getCosmetics(): Promise<Cosmetics | undefined> {
|
||||
export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/cosmetics.json`);
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP error! status: ${response.status}`);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const result = CosmeticsSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
console.error(`Invalid cosmetics: ${result.error.message}`);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Error getting cosmetics:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function patternRelationship(
|
||||
pattern: Pattern,
|
||||
colorPalette: { name: string; isArchived?: boolean } | null,
|
||||
userMeResponse: UserMeResponse | null,
|
||||
affiliateCode: string | null,
|
||||
): "owned" | "purchasable" | "blocked" {
|
||||
const flares = userMeResponse?.player.flares ?? [];
|
||||
if (flares.includes("pattern:*")) {
|
||||
return "owned";
|
||||
}
|
||||
|
||||
if (colorPalette === null) {
|
||||
// For backwards compatibility only show non-colored patterns if they are owned.
|
||||
if (flares.includes(`pattern:${pattern.name}`)) {
|
||||
return "owned";
|
||||
}
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`;
|
||||
|
||||
if (flares.includes(requiredFlare)) {
|
||||
return "owned";
|
||||
}
|
||||
|
||||
if (pattern.product === null) {
|
||||
// We don't own it and it's not for sale, so don't show it.
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
if (colorPalette?.isArchived) {
|
||||
// We don't own the color palette, and it's archived, so don't show it.
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
if (affiliateCode !== pattern.affiliateCode) {
|
||||
// Pattern is for sale, but it's not the right store to show it on.
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
// Patterns is for sale, and it's the right store to show it on.
|
||||
return "purchasable";
|
||||
}
|
||||
|
||||
+4
-1
@@ -7,6 +7,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./AccountModal";
|
||||
import { joinLobby } from "./ClientGameRunner";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import "./DarkModeButton";
|
||||
import { DarkModeButton } from "./DarkModeButton";
|
||||
import "./FlagInput";
|
||||
@@ -508,7 +509,9 @@ class Client {
|
||||
{
|
||||
gameID: lobby.gameID,
|
||||
serverConfig: config,
|
||||
patternName: this.userSettings.getSelectedPatternName(),
|
||||
pattern:
|
||||
this.userSettings.getSelectedPatternName(await fetchCosmetics()) ??
|
||||
undefined,
|
||||
flag:
|
||||
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
|
||||
@@ -21,7 +21,7 @@ import "./components/baseComponents/Modal";
|
||||
import "./components/Difficulties";
|
||||
import { DifficultyDescription } from "./components/Difficulties";
|
||||
import "./components/Maps";
|
||||
import { getCosmetics } from "./Cosmetics";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
@@ -425,13 +425,12 @@ export class SinglePlayerModal extends LitElement {
|
||||
if (!flagInput) {
|
||||
console.warn("Flag input element not found");
|
||||
}
|
||||
const patternName = this.userSettings.getSelectedPatternName();
|
||||
let pattern: string | undefined = undefined;
|
||||
if (this.userSettings.getDevOnlyPattern()) {
|
||||
pattern = this.userSettings.getDevOnlyPattern();
|
||||
} else if (patternName) {
|
||||
pattern = (await getCosmetics())?.patterns[patternName]?.pattern;
|
||||
}
|
||||
const cosmetics = await fetchCosmetics();
|
||||
let selectedPattern = this.userSettings.getSelectedPatternName(cosmetics);
|
||||
selectedPattern ??= cosmetics
|
||||
? (this.userSettings.getDevOnlyPattern() ?? null)
|
||||
: null;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
@@ -443,11 +442,13 @@ export class SinglePlayerModal extends LitElement {
|
||||
{
|
||||
clientID,
|
||||
username: usernameInput.getCurrentUsername(),
|
||||
flag:
|
||||
flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
: flagInput.getCurrentFlag(),
|
||||
pattern: pattern,
|
||||
cosmetics: {
|
||||
flag:
|
||||
flagInput.getCurrentFlag() === "xx"
|
||||
? ""
|
||||
: flagInput.getCurrentFlag(),
|
||||
pattern: selectedPattern ?? undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
config: {
|
||||
|
||||
@@ -2,12 +2,17 @@ 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 { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
import "./components/Difficulties";
|
||||
import "./components/PatternButton";
|
||||
import { renderPatternPreview } from "./components/PatternButton";
|
||||
import { fetchPatterns, handlePurchase } from "./Cosmetics";
|
||||
import {
|
||||
fetchCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
} from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("territory-patterns-modal")
|
||||
@@ -19,9 +24,9 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
public previewButton: HTMLElement | null = null;
|
||||
|
||||
@state() private selectedPattern: Pattern | null;
|
||||
@state() private selectedPattern: PlayerPattern | null;
|
||||
|
||||
private patterns: Map<string, Pattern> = new Map();
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@@ -29,6 +34,8 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
private affiliateCode: string | null = null;
|
||||
|
||||
private userMeResponse: UserMeResponse | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
@@ -38,11 +45,12 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
this.selectedPattern = null;
|
||||
}
|
||||
this.patterns = await fetchPatterns(userMeResponse);
|
||||
const storedPatternName = this.userSettings.getSelectedPatternName();
|
||||
if (storedPatternName) {
|
||||
this.selectedPattern = this.patterns.get(storedPatternName) ?? null;
|
||||
}
|
||||
this.userMeResponse = userMeResponse;
|
||||
this.cosmetics = await fetchCosmetics();
|
||||
this.selectedPattern =
|
||||
this.cosmetics !== null
|
||||
? this.userSettings.getSelectedPatternName(this.cosmetics)
|
||||
: null;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -52,25 +60,31 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
const buttons: TemplateResult[] = [];
|
||||
for (const [name, pattern] of this.patterns) {
|
||||
if (this.affiliateCode === null) {
|
||||
if (pattern.affiliateCode !== null && pattern.product !== null) {
|
||||
// Patterns with affiliate code are not for sale by default.
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (pattern.affiliateCode !== this.affiliateCode) {
|
||||
for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) {
|
||||
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
|
||||
for (const colorPalette of colorPalettes) {
|
||||
const rel = patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
);
|
||||
if (rel === "blocked") {
|
||||
continue;
|
||||
}
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
||||
colorPalette?.name ?? ""
|
||||
] ?? null}
|
||||
.requiresPurchase=${rel === "purchasable"}
|
||||
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
handlePurchase(p, colorPalette)}
|
||||
></pattern-button>
|
||||
`);
|
||||
}
|
||||
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(p)}
|
||||
.onPurchase=${(p: Pattern) => handlePurchase(p)}
|
||||
></pattern-button>
|
||||
`);
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -115,19 +129,24 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
private selectPattern(pattern: Pattern | null) {
|
||||
this.userSettings.setSelectedPatternName(pattern?.name);
|
||||
private selectPattern(pattern: PlayerPattern | null) {
|
||||
if (pattern === null) {
|
||||
this.userSettings.setSelectedPatternName(undefined);
|
||||
} else {
|
||||
const name =
|
||||
pattern.colorPalette?.name === undefined
|
||||
? pattern.name
|
||||
: `${pattern.name}:${pattern.colorPalette.name}`;
|
||||
|
||||
this.userSettings.setSelectedPatternName(`pattern:${name}`);
|
||||
}
|
||||
this.selectedPattern = pattern;
|
||||
this.refresh();
|
||||
this.close();
|
||||
}
|
||||
|
||||
public async refresh() {
|
||||
const preview = renderPatternPreview(
|
||||
this.selectedPattern?.pattern ?? null,
|
||||
48,
|
||||
48,
|
||||
);
|
||||
const preview = renderPatternPreview(this.selectedPattern ?? null, 48, 48);
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for the DOM to be updated and the o-modal element to be available
|
||||
|
||||
@@ -368,8 +368,11 @@ export class Transport {
|
||||
lastTurn: numTurns,
|
||||
token: this.lobbyConfig.token,
|
||||
username: this.lobbyConfig.playerName,
|
||||
flag: this.lobbyConfig.flag,
|
||||
patternName: this.lobbyConfig.patternName,
|
||||
cosmetics: {
|
||||
flag: this.lobbyConfig.flag,
|
||||
patternName: this.lobbyConfig.pattern?.name,
|
||||
patternColorPaletteName: this.lobbyConfig.pattern?.colorPalette?.name,
|
||||
},
|
||||
} satisfies ClientJoinMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { Pattern } from "../../core/CosmeticSchemas";
|
||||
import {
|
||||
ColorPalette,
|
||||
DefaultPattern,
|
||||
Pattern,
|
||||
} from "../../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../../core/Schemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export const BUTTON_WIDTH = 150;
|
||||
@@ -12,17 +18,23 @@ export class PatternButton extends LitElement {
|
||||
@property({ type: Object })
|
||||
pattern: Pattern | null = null;
|
||||
|
||||
@property({ type: Function })
|
||||
onSelect?: (pattern: Pattern | null) => void;
|
||||
@property({ type: Object })
|
||||
colorPalette: ColorPalette | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
requiresPurchase: boolean = false;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: (pattern: Pattern) => void;
|
||||
onSelect?: (pattern: PlayerPattern | null) => void;
|
||||
|
||||
@property({ type: Function })
|
||||
onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private translatePatternName(prefix: string, patternName: string): string {
|
||||
private translateCosmetic(prefix: string, patternName: string): string {
|
||||
const translation = translateText(`${prefix}.${patternName}`);
|
||||
if (translation.startsWith(prefix)) {
|
||||
return patternName
|
||||
@@ -35,55 +47,75 @@ export class PatternButton extends LitElement {
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
const isDefaultPattern = this.pattern === null;
|
||||
if (isDefaultPattern || this.pattern?.product === null) {
|
||||
this.onSelect?.(this.pattern);
|
||||
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(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (this.pattern?.product) {
|
||||
this.onPurchase?.(this.pattern);
|
||||
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
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]"
|
||||
class="flex flex-col items-center gap-1 p-1 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
|
||||
class="bg-white/90 border-2 border-black/10 rounded-lg 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}
|
||||
?disabled=${this.requiresPurchase}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<div class="text-sm font-bold text-gray-800 mb-2 text-center">
|
||||
<div class="text-sm font-bold text-gray-800 mb-1 text-center">
|
||||
${isDefaultPattern
|
||||
? translateText("territory_patterns.pattern.default")
|
||||
: this.translatePatternName(
|
||||
: this.translateCosmetic(
|
||||
"territory_patterns.pattern",
|
||||
this.pattern!.name,
|
||||
)}
|
||||
</div>
|
||||
${this.colorPalette !== null
|
||||
? html`
|
||||
<div class="text-xs font-bold text-gray-800 mb-1 text-center">
|
||||
${this.translateCosmetic(
|
||||
"territory_patterns.color_palette",
|
||||
this.colorPalette!.name,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<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,
|
||||
this.pattern !== null
|
||||
? ({
|
||||
name: this.pattern!.name,
|
||||
patternData: this.pattern!.pattern,
|
||||
colorPalette: this.colorPalette ?? undefined,
|
||||
} satisfies PlayerPattern)
|
||||
: DefaultPattern,
|
||||
BUTTON_WIDTH,
|
||||
BUTTON_WIDTH,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
${isPurchasable
|
||||
${this.requiresPurchase
|
||||
? 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
|
||||
@@ -101,16 +133,16 @@ export class PatternButton extends LitElement {
|
||||
}
|
||||
|
||||
export function renderPatternPreview(
|
||||
pattern: string | null,
|
||||
pattern: PlayerPattern | null,
|
||||
width: number,
|
||||
height: number,
|
||||
): TemplateResult {
|
||||
console.log("renderPatternPreview", pattern);
|
||||
if (pattern === null) {
|
||||
return renderBlankPreview(width, height);
|
||||
}
|
||||
const dataUrl = generatePreviewDataUrl(pattern, width, height);
|
||||
return html`<img
|
||||
src="${dataUrl}"
|
||||
src="${generatePreviewDataUrl(pattern, width, height)}"
|
||||
alt="Pattern preview"
|
||||
class="w-full h-full object-contain"
|
||||
style="image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges;"
|
||||
@@ -155,16 +187,21 @@ function renderBlankPreview(width: number, height: number): TemplateResult {
|
||||
}
|
||||
|
||||
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
|
||||
const DEFAULT_PRIMARY = new Colord("#ffffff").toRgb(); // White
|
||||
const DEFAULT_SECONDARY = new Colord("#000000").toRgb(); // Black
|
||||
function generatePreviewDataUrl(
|
||||
pattern?: string,
|
||||
pattern?: PlayerPattern,
|
||||
width?: number,
|
||||
height?: number,
|
||||
): string {
|
||||
pattern ??= DEFAULT_PATTERN_B64;
|
||||
const patternLookupKey = `${pattern}-${width}-${height}`;
|
||||
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)!;
|
||||
@@ -173,7 +210,14 @@ function generatePreviewDataUrl(
|
||||
// Calculate canvas size
|
||||
let decoder: PatternDecoder;
|
||||
try {
|
||||
decoder = new PatternDecoder(pattern, base64url.decode);
|
||||
decoder = new PatternDecoder(
|
||||
{
|
||||
name: pattern.name,
|
||||
patternData: pattern.patternData,
|
||||
colorPalette: pattern.colorPalette,
|
||||
},
|
||||
base64url.decode,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error decoding pattern", e);
|
||||
return "";
|
||||
@@ -201,14 +245,20 @@ function generatePreviewDataUrl(
|
||||
// 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.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
|
||||
const rgba = decoder.isPrimary(x, y) ? primary : secondary;
|
||||
data[i++] = rgba.r;
|
||||
data[i++] = rgba.g;
|
||||
data[i++] = rgba.b;
|
||||
data[i++] = 255; // Alpha
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,8 +188,8 @@ export class AnimatedSpriteLoader {
|
||||
const baseImage = this.animatedSpriteImageMap.get(fxType);
|
||||
const config = ANIMATED_SPRITE_CONFIG[fxType];
|
||||
if (!baseImage || !config) return null;
|
||||
const territoryColor = theme.territoryColor(owner);
|
||||
const borderColor = theme.borderColor(owner);
|
||||
const territoryColor = owner.territoryColor();
|
||||
const borderColor = owner.borderColor();
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const key = `${fxType}-${owner.id()}`;
|
||||
let coloredCanvas: HTMLCanvasElement;
|
||||
|
||||
@@ -171,10 +171,9 @@ export const getColoredSprite = (
|
||||
customTerritoryColor?: Colord,
|
||||
customBorderColor?: Colord,
|
||||
): HTMLCanvasElement => {
|
||||
const owner = unit.owner();
|
||||
const territoryColor: Colord =
|
||||
customTerritoryColor ?? theme.territoryColor(owner);
|
||||
const borderColor: Colord = customBorderColor ?? theme.borderColor(owner);
|
||||
customTerritoryColor ?? unit.owner().territoryColor();
|
||||
const borderColor: Colord = customBorderColor ?? unit.owner().borderColor();
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const key = computeSpriteKey(unit, territoryColor, borderColor);
|
||||
if (coloredSpriteCache.has(key)) {
|
||||
|
||||
@@ -153,7 +153,7 @@ export class RailroadLayer implements Layer {
|
||||
const owner = this.game.owner(railRoad.tile);
|
||||
const recipient = owner.isPlayer() ? (owner as PlayerView) : null;
|
||||
const color = recipient
|
||||
? this.theme.railroadColor(recipient)
|
||||
? recipient.borderColor()
|
||||
: new Colord({ r: 255, g: 255, b: 255, a: 1 });
|
||||
this.context.fillStyle = color.toRgbString();
|
||||
this.paintRailRects(x, y, railRoad.railType);
|
||||
|
||||
@@ -344,7 +344,7 @@ export class StructureIconsLayer implements Layer {
|
||||
const structureType = isConstruction ? constructionType! : unit.type();
|
||||
const cacheKey = isConstruction
|
||||
? `construction-${structureType}` + (renderIcon ? "-icon" : "")
|
||||
: `${this.theme.territoryColor(unit.owner()).toRgbString()}-${structureType}` +
|
||||
: `${unit.owner().territoryColor().toRgbString()}-${structureType}` +
|
||||
(renderIcon ? "-icon" : "");
|
||||
if (this.textureCache.has(cacheKey)) {
|
||||
return this.textureCache.get(cacheKey)!;
|
||||
@@ -386,13 +386,13 @@ export class StructureIconsLayer implements Layer {
|
||||
context.fillStyle = "rgb(198, 198, 198)";
|
||||
borderColor = "rgb(128, 127, 127)";
|
||||
} else {
|
||||
context.fillStyle = this.theme
|
||||
.territoryColor(owner)
|
||||
context.fillStyle = owner
|
||||
.territoryColor()
|
||||
.lighten(0.13)
|
||||
.alpha(renderIcon ? 0.65 : 1)
|
||||
.toRgbString();
|
||||
const darken = this.theme.borderColor(owner).isLight() ? 0.17 : 0.15;
|
||||
borderColor = this.theme.borderColor(owner).darken(darken).toRgbString();
|
||||
const darken = owner.borderColor().isLight() ? 0.17 : 0.15;
|
||||
borderColor = owner.borderColor().darken(darken).toRgbString();
|
||||
}
|
||||
|
||||
context.strokeStyle = borderColor;
|
||||
|
||||
@@ -190,7 +190,7 @@ export class StructureLayer implements Layer {
|
||||
new Cell(this.game.x(tile), this.game.y(tile)),
|
||||
unit.type() === UnitType.Construction
|
||||
? underConstructionColor
|
||||
: this.theme.territoryColor(unit.owner()),
|
||||
: unit.owner().territoryColor(),
|
||||
130,
|
||||
);
|
||||
}
|
||||
@@ -203,7 +203,7 @@ export class StructureLayer implements Layer {
|
||||
|
||||
const config = this.unitConfigs[unitType];
|
||||
let icon: HTMLImageElement | undefined;
|
||||
let borderColor = this.theme.borderColor(unit.owner());
|
||||
let borderColor = unit.owner().borderColor();
|
||||
|
||||
// Handle cooldown states and special icons
|
||||
if (unit.type() === UnitType.Construction) {
|
||||
@@ -244,7 +244,7 @@ export class StructureLayer implements Layer {
|
||||
height: number,
|
||||
unit: UnitView,
|
||||
) {
|
||||
let color = this.theme.borderColor(unit.owner());
|
||||
let color = unit.owner().borderColor();
|
||||
if (unit.type() === UnitType.Construction) {
|
||||
color = underConstructionColor;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
AlternateViewEvent,
|
||||
DragEvent,
|
||||
MouseOverEvent,
|
||||
RefreshGraphicsEvent,
|
||||
} from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -74,11 +73,6 @@ export class TerritoryLayer implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
const prev = this.cachedTerritoryPatternsEnabled;
|
||||
this.cachedTerritoryPatternsEnabled = this.userSettings.territoryPatterns();
|
||||
if (prev !== undefined && prev !== this.cachedTerritoryPatternsEnabled) {
|
||||
this.eventBus.emit(new RefreshGraphicsEvent());
|
||||
}
|
||||
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
|
||||
const updates = this.game.updatesSinceLastTick();
|
||||
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
|
||||
@@ -432,53 +426,24 @@ export class TerritoryLayer implements Layer {
|
||||
const alternativeColor = this.alternateViewColor(owner);
|
||||
this.paintTile(this.alternativeImageData, tile, alternativeColor, 255);
|
||||
}
|
||||
if (
|
||||
this.game.hasUnitNearby(
|
||||
tile,
|
||||
this.game.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
)
|
||||
) {
|
||||
const borderColors = this.theme.defendedBorderColors(owner);
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
const lightTile =
|
||||
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
|
||||
const borderColor = lightTile ? borderColors.light : borderColors.dark;
|
||||
this.paintTile(this.imageData, tile, borderColor, 255);
|
||||
} else {
|
||||
const useBorderColor = playerIsFocused
|
||||
? this.theme.focusedBorderColor()
|
||||
: this.theme.borderColor(owner);
|
||||
this.paintTile(this.imageData, tile, useBorderColor, 255);
|
||||
}
|
||||
} else {
|
||||
// Interior tiles
|
||||
const pattern = owner.cosmetics.pattern;
|
||||
const patternsEnabled = this.cachedTerritoryPatternsEnabled ?? false;
|
||||
const isDefended = this.game.hasUnitNearby(
|
||||
tile,
|
||||
this.game.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
owner.id(),
|
||||
);
|
||||
|
||||
this.paintTile(
|
||||
this.imageData,
|
||||
tile,
|
||||
owner.borderColor(tile, isDefended),
|
||||
255,
|
||||
);
|
||||
} else {
|
||||
// Alternative view only shows borders.
|
||||
this.clearAlternativeTile(tile);
|
||||
|
||||
if (pattern === undefined || patternsEnabled === false) {
|
||||
this.paintTile(
|
||||
this.imageData,
|
||||
tile,
|
||||
this.theme.territoryColor(owner),
|
||||
150,
|
||||
);
|
||||
} else {
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
const baseColor = this.theme.territoryColor(owner);
|
||||
|
||||
const decoder = owner.patternDecoder();
|
||||
const color = decoder?.isSet(x, y)
|
||||
? baseColor.darken(0.125)
|
||||
: baseColor;
|
||||
this.paintTile(this.imageData, tile, color, 150);
|
||||
}
|
||||
this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export class UILayer implements Layer {
|
||||
if (this.context === null || this.theme === null) {
|
||||
return;
|
||||
}
|
||||
const color = this.theme.borderColor(unit.owner());
|
||||
const color = unit.owner().borderColor();
|
||||
this.context.fillStyle = color.toRgbString();
|
||||
this.context.fillRect(startX, startY, icon.width, icon.height);
|
||||
this.context.drawImage(icon, startX, startY);
|
||||
@@ -208,7 +208,7 @@ export class UILayer implements Layer {
|
||||
|
||||
// Get the unit's owner color for the box
|
||||
if (this.theme === null) throw new Error("missing theme");
|
||||
const ownerColor = this.theme.territoryColor(unit.owner());
|
||||
const ownerColor = unit.owner().territoryColor();
|
||||
|
||||
// Create a brighter version of the owner color for the selection
|
||||
const selectionColor = ownerColor.lighten(0.2);
|
||||
|
||||
@@ -201,7 +201,7 @@ export class UnitLayer implements Layer {
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
this.relationship(unit),
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
unit.owner().territoryColor(),
|
||||
150,
|
||||
this.unitTrailContext,
|
||||
);
|
||||
@@ -321,14 +321,14 @@ export class UnitLayer implements Layer {
|
||||
this.game.x(unit.tile()),
|
||||
this.game.y(unit.tile()),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
unit.owner().borderColor(),
|
||||
255,
|
||||
);
|
||||
this.paintCell(
|
||||
this.game.x(unit.lastTile()),
|
||||
this.game.y(unit.lastTile()),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
unit.owner().borderColor(),
|
||||
255,
|
||||
);
|
||||
}
|
||||
@@ -369,7 +369,7 @@ export class UnitLayer implements Layer {
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(other.owner()),
|
||||
other.owner().territoryColor(),
|
||||
150,
|
||||
this.unitTrailContext,
|
||||
);
|
||||
@@ -410,7 +410,7 @@ export class UnitLayer implements Layer {
|
||||
|
||||
this.drawTrail(
|
||||
trail.slice(-newTrailSize),
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
unit.owner().territoryColor(),
|
||||
rel,
|
||||
);
|
||||
this.drawSprite(unit);
|
||||
@@ -430,7 +430,7 @@ export class UnitLayer implements Layer {
|
||||
this.game.x(unit.tile()),
|
||||
this.game.y(unit.tile()),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
unit.owner().borderColor(),
|
||||
255,
|
||||
);
|
||||
}
|
||||
@@ -454,11 +454,7 @@ export class UnitLayer implements Layer {
|
||||
trail.push(unit.lastTile());
|
||||
|
||||
// Paint trail
|
||||
this.drawTrail(
|
||||
trail.slice(-1),
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
rel,
|
||||
);
|
||||
this.drawTrail(trail.slice(-1), unit.owner().territoryColor(), rel);
|
||||
this.drawSprite(unit);
|
||||
|
||||
if (!unit.isActive()) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { Pattern } from "../../../core/CosmeticSchemas";
|
||||
import { ColorPalette, 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 {
|
||||
fetchCosmetics,
|
||||
handlePurchase,
|
||||
patternRelationship,
|
||||
} from "../../Cosmetics";
|
||||
import { getUserMe } from "../../jwt";
|
||||
import { SendWinnerEvent } from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -108,19 +112,41 @@ export class WinModal extends LitElement implements Layer {
|
||||
|
||||
async loadPatternContent() {
|
||||
const me = await getUserMe();
|
||||
const patterns = await fetchPatterns(me !== false ? me : null);
|
||||
const patterns = await fetchCosmetics();
|
||||
|
||||
const purchasable = Array.from(patterns.values()).filter(
|
||||
(p) => p.product !== null,
|
||||
);
|
||||
const purchasablePatterns: {
|
||||
pattern: Pattern;
|
||||
colorPalette: ColorPalette;
|
||||
}[] = [];
|
||||
|
||||
if (purchasable.length === 0) {
|
||||
for (const pattern of Object.values(patterns?.patterns ?? {})) {
|
||||
for (const colorPalette of pattern.colorPalettes ?? []) {
|
||||
if (
|
||||
patternRelationship(
|
||||
pattern,
|
||||
colorPalette,
|
||||
me !== false ? me : null,
|
||||
null,
|
||||
) === "purchasable"
|
||||
) {
|
||||
const palette = patterns?.colorPalettes?.[colorPalette.name];
|
||||
if (palette) {
|
||||
purchasablePatterns.push({
|
||||
pattern,
|
||||
colorPalette: palette,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (purchasablePatterns.length === 0) {
|
||||
this.patternContent = html``;
|
||||
return;
|
||||
}
|
||||
|
||||
// Shuffle the array and take patterns based on screen size
|
||||
const shuffled = [...purchasable].sort(() => Math.random() - 0.5);
|
||||
const shuffled = [...purchasablePatterns].sort(() => Math.random() - 0.5);
|
||||
const isMobile = window.innerWidth < 768; // md breakpoint
|
||||
const maxPatterns = isMobile ? 1 : 3;
|
||||
const selectedPatterns = shuffled.slice(
|
||||
@@ -131,11 +157,14 @@ export class WinModal extends LitElement implements Layer {
|
||||
this.patternContent = html`
|
||||
<div class="flex gap-4 flex-wrap justify-start">
|
||||
${selectedPatterns.map(
|
||||
(pattern) => html`
|
||||
({ pattern, colorPalette }) => html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
.colorPalette=${colorPalette}
|
||||
.requiresPurchase=${true}
|
||||
.onSelect=${(p: Pattern | null) => {}}
|
||||
.onPurchase=${(p: Pattern) => handlePurchase(p)}
|
||||
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
|
||||
handlePurchase(p, colorPalette)}
|
||||
></pattern-button>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { base64url } from "jose";
|
||||
import { z } from "zod/v4";
|
||||
import { PatternDecoder } from "./PatternDecoder";
|
||||
import { decodePatternData } from "./PatternDecoder";
|
||||
import { PlayerPattern } from "./Schemas";
|
||||
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternInfoSchema>;
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type PatternName = z.infer<typeof PatternNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
export type PatternData = z.infer<typeof PatternDataSchema>;
|
||||
|
||||
export const ProductSchema = z.object({
|
||||
productId: z.string(),
|
||||
@@ -18,14 +21,14 @@ export const PatternNameSchema = z
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.max(32);
|
||||
|
||||
export const PatternSchema = z
|
||||
export const PatternDataSchema = z
|
||||
.string()
|
||||
.max(1403)
|
||||
.base64url()
|
||||
.refine(
|
||||
(val) => {
|
||||
try {
|
||||
new PatternDecoder(val, base64url.decode);
|
||||
decodePatternData(val, base64url.decode);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
@@ -41,16 +44,30 @@ export const PatternSchema = z
|
||||
},
|
||||
);
|
||||
|
||||
export const PatternInfoSchema = z.object({
|
||||
export const ColorPaletteSchema = z.object({
|
||||
name: z.string(),
|
||||
primaryColor: z.string(),
|
||||
secondaryColor: z.string(),
|
||||
});
|
||||
|
||||
export const PatternSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
pattern: PatternSchema,
|
||||
pattern: PatternDataSchema,
|
||||
colorPalettes: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
});
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
export const CosmeticsSchema = z.object({
|
||||
patterns: z.record(z.string(), PatternInfoSchema),
|
||||
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
flag: z
|
||||
.object({
|
||||
layers: z.record(
|
||||
@@ -71,3 +88,9 @@ export const CosmeticsSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DefaultPattern = {
|
||||
name: "default",
|
||||
patternData: "AAAAAA",
|
||||
colorPalette: undefined,
|
||||
} satisfies PlayerPattern;
|
||||
|
||||
+46
-30
@@ -1,3 +1,5 @@
|
||||
import { PlayerPattern } from "./Schemas";
|
||||
|
||||
export class PatternDecoder {
|
||||
private bytes: Uint8Array;
|
||||
|
||||
@@ -5,37 +7,19 @@ export class PatternDecoder {
|
||||
readonly width: number;
|
||||
readonly scale: number;
|
||||
|
||||
constructor(base64: string, base64urlDecode: (input: string) => Uint8Array) {
|
||||
this.bytes = base64urlDecode(base64);
|
||||
|
||||
if (this.bytes.length < 3) {
|
||||
throw new Error(
|
||||
"Pattern data is too short to contain required metadata.",
|
||||
);
|
||||
}
|
||||
|
||||
const version = this.bytes[0];
|
||||
if (version !== 0) {
|
||||
throw new Error(`Unrecognized pattern version ${version}.`);
|
||||
}
|
||||
|
||||
const byte1 = this.bytes[1];
|
||||
const byte2 = this.bytes[2];
|
||||
this.scale = byte1 & 0x07;
|
||||
|
||||
this.width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
|
||||
this.height = ((byte2 >> 2) & 0x3f) + 2;
|
||||
|
||||
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(
|
||||
"Pattern data is too short for the specified dimensions.",
|
||||
);
|
||||
}
|
||||
constructor(
|
||||
pattern: PlayerPattern,
|
||||
base64urlDecode: (input: string) => Uint8Array,
|
||||
) {
|
||||
({
|
||||
height: this.height,
|
||||
width: this.width,
|
||||
scale: this.scale,
|
||||
bytes: this.bytes,
|
||||
} = decodePatternData(pattern.patternData, base64urlDecode));
|
||||
}
|
||||
|
||||
isSet(x: number, y: number): boolean {
|
||||
isPrimary(x: number, y: number): boolean {
|
||||
const px = (x >> this.scale) % this.width;
|
||||
const py = (y >> this.scale) % this.height;
|
||||
const idx = py * this.width + px;
|
||||
@@ -43,7 +27,8 @@ export class PatternDecoder {
|
||||
const bitIndex = idx & 7;
|
||||
const byte = this.bytes[3 + byteIndex];
|
||||
if (byte === undefined) throw new Error("Invalid pattern");
|
||||
return (byte & (1 << bitIndex)) !== 0;
|
||||
|
||||
return (byte & (1 << bitIndex)) === 0;
|
||||
}
|
||||
|
||||
scaledHeight(): number {
|
||||
@@ -54,3 +39,34 @@ export class PatternDecoder {
|
||||
return this.width << this.scale;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePatternData(
|
||||
b64: string,
|
||||
base64urlDecode: (input: string) => Uint8Array,
|
||||
): { height: number; width: number; scale: number; bytes: Uint8Array } {
|
||||
const bytes = base64urlDecode(b64);
|
||||
|
||||
if (bytes.length < 3) {
|
||||
throw new Error("Pattern data is too short to contain required metadata.");
|
||||
}
|
||||
|
||||
const version = bytes[0];
|
||||
if (version !== 0) {
|
||||
throw new Error(`Unrecognized pattern version ${version}.`);
|
||||
}
|
||||
|
||||
const byte1 = bytes[1];
|
||||
const byte2 = bytes[2];
|
||||
const scale = byte1 & 0x07;
|
||||
|
||||
const width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
|
||||
const height = ((byte2 >> 2) & 0x3f) + 2;
|
||||
|
||||
const expectedBits = width * height;
|
||||
const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8);
|
||||
if (bytes.length - 3 < expectedBytes) {
|
||||
throw new Error("Pattern data is too short for the specified dimensions.");
|
||||
}
|
||||
|
||||
return { height, width, scale, bytes };
|
||||
}
|
||||
|
||||
+40
-17
@@ -1,7 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
|
||||
import countries from "../client/data/countries.json" with { type: "json" };
|
||||
import { PatternSchema } from "./CosmeticSchemas";
|
||||
import {
|
||||
ColorPaletteSchema,
|
||||
PatternDataSchema,
|
||||
PatternNameSchema,
|
||||
} from "./CosmeticSchemas";
|
||||
import {
|
||||
AllPlayers,
|
||||
Difficulty,
|
||||
@@ -106,6 +110,10 @@ export type ClientHashMessage = z.infer<typeof ClientHashSchema>;
|
||||
|
||||
export type AllPlayersStats = z.infer<typeof AllPlayersStatsSchema>;
|
||||
export type Player = z.infer<typeof PlayerSchema>;
|
||||
export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
|
||||
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
|
||||
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
|
||||
const PlayerTypeSchema = z.enum(PlayerType);
|
||||
|
||||
@@ -190,18 +198,6 @@ export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
|
||||
|
||||
export const UsernameSchema = SafeString;
|
||||
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
|
||||
export const FlagSchema = z
|
||||
.string()
|
||||
.max(128)
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === undefined || val === "") return true;
|
||||
if (val.startsWith("!")) return true;
|
||||
return countryCodes.includes(val);
|
||||
},
|
||||
{ message: "Invalid flag: must be a valid country code or start with !" },
|
||||
);
|
||||
|
||||
export const QuickChatKeySchema = z.enum(
|
||||
Object.entries(quickChatData).flatMap(([category, entries]) =>
|
||||
@@ -365,11 +361,38 @@ export const TurnSchema = z.object({
|
||||
hash: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const FlagSchema = z
|
||||
.string()
|
||||
.max(128)
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === undefined || val === "") return true;
|
||||
if (val.startsWith("!")) return true;
|
||||
return countryCodes.includes(val);
|
||||
},
|
||||
{ message: "Invalid flag: must be a valid country code or start with !" },
|
||||
);
|
||||
|
||||
export const PlayerCosmeticRefsSchema = z.object({
|
||||
flag: FlagSchema.optional(),
|
||||
patternName: PatternNameSchema.optional(),
|
||||
patternColorPaletteName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const PlayerPatternSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
patternData: PatternDataSchema,
|
||||
colorPalette: ColorPaletteSchema.optional(),
|
||||
});
|
||||
export const PlayerCosmeticsSchema = z.object({
|
||||
flag: FlagSchema.optional(),
|
||||
pattern: PlayerPatternSchema.optional(),
|
||||
});
|
||||
export const PlayerSchema = z.object({
|
||||
clientID: ID,
|
||||
username: UsernameSchema,
|
||||
flag: FlagSchema,
|
||||
pattern: PatternSchema.optional(),
|
||||
cosmetics: PlayerCosmeticsSchema.optional(),
|
||||
});
|
||||
|
||||
export const GameStartInfoSchema = z.object({
|
||||
@@ -474,8 +497,8 @@ export const ClientJoinMessageSchema = z.object({
|
||||
gameID: ID,
|
||||
lastTurn: z.number(), // The last turn the client saw.
|
||||
username: UsernameSchema,
|
||||
flag: FlagSchema,
|
||||
patternName: z.string().optional(),
|
||||
// Server replaces the refs with the actual cosmetic data.
|
||||
cosmetics: PlayerCosmeticRefsSchema.optional(),
|
||||
});
|
||||
|
||||
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
||||
|
||||
@@ -177,11 +177,12 @@ export interface Config {
|
||||
|
||||
export interface Theme {
|
||||
teamColor(team: Team): Colord;
|
||||
// Don't call directly, use PlayerView
|
||||
territoryColor(playerInfo: PlayerView): Colord;
|
||||
specialBuildingColor(playerInfo: PlayerView): Colord;
|
||||
railroadColor(playerInfo: PlayerView): Colord;
|
||||
// Don't call directly, use PlayerView
|
||||
borderColor(playerInfo: PlayerView): Colord;
|
||||
defendedBorderColors(playerInfo: PlayerView): { light: Colord; dark: Colord };
|
||||
// Don't call directly, use PlayerView
|
||||
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
|
||||
focusedBorderColor(): Colord;
|
||||
terrainColor(gm: GameMap, tile: TileRef): Colord;
|
||||
backgroundColor(): Colord;
|
||||
|
||||
@@ -54,29 +54,7 @@ export class PastelTheme implements Theme {
|
||||
return this.nationColorAllocator.assignColor(player.id());
|
||||
}
|
||||
|
||||
textColor(player: PlayerView): string {
|
||||
return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D";
|
||||
}
|
||||
|
||||
specialBuildingColor(player: PlayerView): Colord {
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
return colord({
|
||||
r: Math.max(tc.r - 50, 0),
|
||||
g: Math.max(tc.g - 50, 0),
|
||||
b: Math.max(tc.b - 50, 0),
|
||||
});
|
||||
}
|
||||
|
||||
railroadColor(player: PlayerView): Colord {
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
const color = colord({
|
||||
r: Math.max(tc.r - 10, 0),
|
||||
g: Math.max(tc.g - 10, 0),
|
||||
b: Math.max(tc.b - 10, 0),
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
// Don't call directly, use PlayerView
|
||||
borderColor(player: PlayerView): Colord {
|
||||
if (this.borderColorCache.has(player.id())) {
|
||||
return this.borderColorCache.get(player.id())!;
|
||||
@@ -92,10 +70,13 @@ export class PastelTheme implements Theme {
|
||||
return color;
|
||||
}
|
||||
|
||||
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
|
||||
defendedBorderColors(territoryColor: Colord): {
|
||||
light: Colord;
|
||||
dark: Colord;
|
||||
} {
|
||||
return {
|
||||
light: this.territoryColor(player).darken(0.2),
|
||||
dark: this.territoryColor(player).darken(0.4),
|
||||
light: territoryColor.darken(0.2),
|
||||
dark: territoryColor.darken(0.4),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +84,10 @@ export class PastelTheme implements Theme {
|
||||
return colord({ r: 230, g: 230, b: 230 });
|
||||
}
|
||||
|
||||
textColor(player: PlayerView): string {
|
||||
return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D";
|
||||
}
|
||||
|
||||
terrainColor(gm: GameMap, tile: TileRef): Colord {
|
||||
const mag = gm.magnitude(tile);
|
||||
if (gm.isShore(tile)) {
|
||||
|
||||
@@ -1,119 +1,25 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { PlayerType, Team, TerrainType } from "../game/Game";
|
||||
import { TerrainType } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
import { PlayerView } from "../game/GameView";
|
||||
import { ColorAllocator } from "./ColorAllocator";
|
||||
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
import { PastelTheme } from "./PastelTheme";
|
||||
|
||||
type ColorCache = Map<string, Colord>;
|
||||
export class PastelThemeDark extends PastelTheme {
|
||||
private darkShore = colord({ r: 134, g: 133, b: 88 });
|
||||
|
||||
export class PastelThemeDark implements Theme {
|
||||
private borderColorCache: ColorCache = new Map<string, Colord>();
|
||||
private rand = new PseudoRandom(123);
|
||||
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
|
||||
private botColorAllocator = new ColorAllocator(botColors, botColors);
|
||||
private teamColorAllocator = new ColorAllocator(humanColors, fallbackColors);
|
||||
private nationColorAllocator = new ColorAllocator(nationColors, nationColors);
|
||||
|
||||
private background = colord({ r: 0, g: 0, b: 0 });
|
||||
private shore = colord({ r: 134, g: 133, b: 88 });
|
||||
private falloutColors = [
|
||||
colord({ r: 120, g: 255, b: 71 }), // Original color
|
||||
colord({ r: 130, g: 255, b: 85 }), // Slightly lighter
|
||||
colord({ r: 110, g: 245, b: 65 }), // Slightly darker
|
||||
colord({ r: 125, g: 255, b: 75 }), // Warmer tint
|
||||
colord({ r: 115, g: 250, b: 68 }), // Cooler tint
|
||||
];
|
||||
private water = colord({ r: 14, g: 11, b: 30 });
|
||||
private shorelineWater = colord({ r: 50, g: 50, b: 50 });
|
||||
|
||||
private _selfColor = colord({ r: 0, g: 255, b: 0 });
|
||||
private _allyColor = colord({ r: 255, g: 255, b: 0 });
|
||||
private _neutralColor = colord({ r: 128, g: 128, b: 128 });
|
||||
private _enemyColor = colord({ r: 255, g: 0, b: 0 });
|
||||
|
||||
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
|
||||
|
||||
teamColor(team: Team): Colord {
|
||||
return this.teamColorAllocator.assignTeamColor(team);
|
||||
}
|
||||
|
||||
territoryColor(player: PlayerView): Colord {
|
||||
const team = player.team();
|
||||
if (team !== null) {
|
||||
return this.teamColorAllocator.assignTeamPlayerColor(team, player.id());
|
||||
}
|
||||
if (player.type() === PlayerType.Human) {
|
||||
return this.humanColorAllocator.assignColor(player.id());
|
||||
}
|
||||
if (player.type() === PlayerType.Bot) {
|
||||
return this.botColorAllocator.assignColor(player.id());
|
||||
}
|
||||
return this.nationColorAllocator.assignColor(player.id());
|
||||
}
|
||||
|
||||
textColor(player: PlayerView): string {
|
||||
return player.type() === PlayerType.Human ? "#ffffff" : "#e6e6e6";
|
||||
}
|
||||
|
||||
specialBuildingColor(player: PlayerView): Colord {
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
return colord({
|
||||
r: Math.max(tc.r - 50, 0),
|
||||
g: Math.max(tc.g - 50, 0),
|
||||
b: Math.max(tc.b - 50, 0),
|
||||
});
|
||||
}
|
||||
|
||||
railroadColor(player: PlayerView): Colord {
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
const color = colord({
|
||||
r: Math.max(tc.r - 10, 0),
|
||||
g: Math.max(tc.g - 10, 0),
|
||||
b: Math.max(tc.b - 10, 0),
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
borderColor(player: PlayerView): Colord {
|
||||
if (this.borderColorCache.has(player.id())) {
|
||||
return this.borderColorCache.get(player.id())!;
|
||||
}
|
||||
const tc = this.territoryColor(player).rgba;
|
||||
const color = colord({
|
||||
r: Math.max(tc.r - 40, 0),
|
||||
g: Math.max(tc.g - 40, 0),
|
||||
b: Math.max(tc.b - 40, 0),
|
||||
});
|
||||
|
||||
this.borderColorCache.set(player.id(), color);
|
||||
return color;
|
||||
}
|
||||
|
||||
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
|
||||
return {
|
||||
light: this.territoryColor(player).darken(0.2),
|
||||
dark: this.territoryColor(player).darken(0.4),
|
||||
};
|
||||
}
|
||||
|
||||
focusedBorderColor(): Colord {
|
||||
return colord({ r: 255, g: 255, b: 255 });
|
||||
}
|
||||
private darkWater = colord({ r: 14, g: 11, b: 30 });
|
||||
private darkShorelineWater = colord({ r: 50, g: 50, b: 50 });
|
||||
|
||||
terrainColor(gm: GameMap, tile: TileRef): Colord {
|
||||
const mag = gm.magnitude(tile);
|
||||
if (gm.isShore(tile)) {
|
||||
return this.shore;
|
||||
return this.darkShore;
|
||||
}
|
||||
switch (gm.terrainType(tile)) {
|
||||
case TerrainType.Ocean:
|
||||
case TerrainType.Lake:
|
||||
const w = this.water.rgba;
|
||||
const w = this.darkWater.rgba;
|
||||
if (gm.isShoreline(tile) && gm.isWater(tile)) {
|
||||
return this.shorelineWater;
|
||||
return this.darkShorelineWater;
|
||||
}
|
||||
if (gm.magnitude(tile) < 10) {
|
||||
return colord({
|
||||
@@ -122,7 +28,7 @@ export class PastelThemeDark implements Theme {
|
||||
b: Math.max(w.b + 9 - mag, 0),
|
||||
});
|
||||
}
|
||||
return this.water;
|
||||
return this.darkWater;
|
||||
case TerrainType.Plains:
|
||||
return colord({
|
||||
r: 140,
|
||||
@@ -143,33 +49,4 @@ export class PastelThemeDark implements Theme {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
backgroundColor(): Colord {
|
||||
return this.background;
|
||||
}
|
||||
|
||||
falloutColor(): Colord {
|
||||
return this.rand.randElement(this.falloutColors);
|
||||
}
|
||||
|
||||
font(): string {
|
||||
return "Overpass, sans-serif";
|
||||
}
|
||||
|
||||
selfColor(): Colord {
|
||||
return this._selfColor;
|
||||
}
|
||||
allyColor(): Colord {
|
||||
return this._allyColor;
|
||||
}
|
||||
neutralColor(): Colord {
|
||||
return this._neutralColor;
|
||||
}
|
||||
enemyColor(): Colord {
|
||||
return this._enemyColor;
|
||||
}
|
||||
|
||||
spawnHighlightColor(): Colord {
|
||||
return this._spawnHighlightColor;
|
||||
}
|
||||
}
|
||||
|
||||
+69
-13
@@ -1,7 +1,9 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { Config } from "../configuration/Config";
|
||||
import { ColorPalette } from "../CosmeticSchemas";
|
||||
import { PatternDecoder } from "../PatternDecoder";
|
||||
import { ClientID, GameID, Player } from "../Schemas";
|
||||
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
|
||||
import { createRandomName } from "../Util";
|
||||
import { WorkerClient } from "../worker/WorkerClient";
|
||||
import {
|
||||
@@ -39,11 +41,6 @@ import { UserSettings } from "./UserSettings";
|
||||
|
||||
const userSettings: UserSettings = new UserSettings();
|
||||
|
||||
interface PlayerCosmetics {
|
||||
pattern?: string | undefined;
|
||||
flag?: string | undefined;
|
||||
}
|
||||
|
||||
export class UnitView {
|
||||
public _wasUpdated = true;
|
||||
public lastPos: TileRef[] = [];
|
||||
@@ -181,6 +178,10 @@ export class PlayerView {
|
||||
public anonymousName: string | null = null;
|
||||
private decoder?: PatternDecoder;
|
||||
|
||||
private _territoryColor: Colord;
|
||||
private _borderColor: Colord;
|
||||
private _defendedBorderColors: { light: Colord; dark: Colord };
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
public data: PlayerUpdate,
|
||||
@@ -195,14 +196,72 @@ export class PlayerView {
|
||||
this.data.playerType,
|
||||
);
|
||||
}
|
||||
|
||||
const pattern = this.cosmetics.pattern;
|
||||
if (pattern) {
|
||||
const territoryColor = this.game.config().theme().territoryColor(this);
|
||||
pattern.colorPalette ??= {
|
||||
name: "",
|
||||
primaryColor: territoryColor.toHex(),
|
||||
secondaryColor: territoryColor.darken(0.125).toHex(),
|
||||
} satisfies ColorPalette;
|
||||
}
|
||||
|
||||
if (
|
||||
this.team() === null &&
|
||||
this.cosmetics.pattern?.colorPalette?.primaryColor !== undefined
|
||||
) {
|
||||
this._territoryColor = colord(
|
||||
this.cosmetics.pattern.colorPalette.primaryColor,
|
||||
);
|
||||
} else {
|
||||
this._territoryColor = this.game.config().theme().territoryColor(this);
|
||||
}
|
||||
|
||||
if (this.cosmetics.pattern?.colorPalette?.secondaryColor !== undefined) {
|
||||
this._borderColor = colord(
|
||||
this.cosmetics.pattern.colorPalette.secondaryColor,
|
||||
);
|
||||
} else if (this.game.myClientID() === this.data.clientID) {
|
||||
this._borderColor = this.game.config().theme().focusedBorderColor();
|
||||
} else {
|
||||
this._borderColor = this.game.config().theme().borderColor(this);
|
||||
}
|
||||
|
||||
this._defendedBorderColors = this.game
|
||||
.config()
|
||||
.theme()
|
||||
.defendedBorderColors(this._borderColor);
|
||||
|
||||
this.decoder =
|
||||
this.cosmetics.pattern === undefined
|
||||
? undefined
|
||||
: new PatternDecoder(this.cosmetics.pattern, base64url.decode);
|
||||
}
|
||||
|
||||
patternDecoder(): PatternDecoder | undefined {
|
||||
return this.decoder;
|
||||
territoryColor(tile?: TileRef): Colord {
|
||||
if (tile === undefined || this.decoder === undefined) {
|
||||
return this._territoryColor;
|
||||
}
|
||||
const isPrimary = this.decoder.isPrimary(
|
||||
this.game.x(tile),
|
||||
this.game.y(tile),
|
||||
);
|
||||
return isPrimary ? this._territoryColor : this._borderColor;
|
||||
}
|
||||
|
||||
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
|
||||
if (tile === undefined || !isDefended) {
|
||||
return this._borderColor;
|
||||
}
|
||||
|
||||
const x = this.game.x(tile);
|
||||
const y = this.game.y(tile);
|
||||
const lightTile =
|
||||
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
|
||||
return lightTile
|
||||
? this._defendedBorderColors.light
|
||||
: this._defendedBorderColors.dark;
|
||||
}
|
||||
|
||||
async actions(tile: TileRef): Promise<PlayerActions> {
|
||||
@@ -387,16 +446,13 @@ export class GameView implements GameMap {
|
||||
this.lastUpdate = null;
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
this._cosmetics = new Map(
|
||||
this.humans.map((h) => [
|
||||
h.clientID,
|
||||
{ flag: h.flag, pattern: h.pattern } satisfies PlayerCosmetics,
|
||||
]),
|
||||
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
|
||||
);
|
||||
for (const nation of this._mapData.manifest.nations) {
|
||||
// Nations don't have client ids, so we use their name as the key instead.
|
||||
this._cosmetics.set(nation.name, {
|
||||
flag: nation.flag,
|
||||
});
|
||||
} satisfies PlayerCosmetics);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Cosmetics } from "../CosmeticSchemas";
|
||||
import { PlayerPattern } from "../Schemas";
|
||||
|
||||
const PATTERN_KEY = "territoryPattern";
|
||||
|
||||
export class UserSettings {
|
||||
@@ -112,12 +115,36 @@ export class UserSettings {
|
||||
}
|
||||
|
||||
// For development only. Used for testing patterns, set in the console manually.
|
||||
getDevOnlyPattern(): string | undefined {
|
||||
return localStorage.getItem("dev-pattern") ?? undefined;
|
||||
getDevOnlyPattern(): PlayerPattern | undefined {
|
||||
const data = localStorage.getItem("dev-pattern") ?? undefined;
|
||||
if (data === undefined) return undefined;
|
||||
return {
|
||||
name: "dev-pattern",
|
||||
patternData: data,
|
||||
colorPalette: {
|
||||
name: "dev-color-palette",
|
||||
primaryColor: localStorage.getItem("dev-primary") ?? "#ffffff",
|
||||
secondaryColor: localStorage.getItem("dev-secondary") ?? "#000000",
|
||||
},
|
||||
} satisfies PlayerPattern;
|
||||
}
|
||||
|
||||
getSelectedPatternName(): string | undefined {
|
||||
return localStorage.getItem(PATTERN_KEY) ?? undefined;
|
||||
getSelectedPatternName(cosmetics: Cosmetics | null): PlayerPattern | null {
|
||||
if (cosmetics === null) return null;
|
||||
let data = localStorage.getItem(PATTERN_KEY) ?? null;
|
||||
if (data === null) return null;
|
||||
const patternPrefix = "pattern:";
|
||||
if (data.startsWith(patternPrefix)) {
|
||||
data = data.slice(patternPrefix.length);
|
||||
}
|
||||
const [patternName, colorPalette] = data.split(":");
|
||||
const pattern = cosmetics.patterns[patternName];
|
||||
if (pattern === undefined) return null;
|
||||
return {
|
||||
name: patternName,
|
||||
patternData: pattern.pattern,
|
||||
colorPalette: cosmetics.colorPalettes?.[colorPalette],
|
||||
} satisfies PlayerPattern;
|
||||
}
|
||||
|
||||
setSelectedPatternName(patternName: string | undefined): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import WebSocket from "ws";
|
||||
import { TokenPayload } from "../core/ApiSchemas";
|
||||
import { Tick } from "../core/game/Game";
|
||||
import { ClientID, Winner } from "../core/Schemas";
|
||||
import { ClientID, PlayerCosmetics, Winner } from "../core/Schemas";
|
||||
|
||||
export class Client {
|
||||
public lastPing: number = Date.now();
|
||||
@@ -19,7 +19,6 @@ export class Client {
|
||||
public readonly ip: string,
|
||||
public readonly username: string,
|
||||
public readonly ws: WebSocket,
|
||||
public readonly flag: string | undefined,
|
||||
public readonly pattern: string | undefined,
|
||||
public readonly cosmetics: PlayerCosmetics | undefined,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -389,8 +389,7 @@ export class GameServer {
|
||||
players: this.activeClients.map((c) => ({
|
||||
username: c.username,
|
||||
clientID: c.clientID,
|
||||
pattern: c.pattern,
|
||||
flag: c.flag,
|
||||
cosmetics: c.cosmetics,
|
||||
})),
|
||||
});
|
||||
if (!result.success) {
|
||||
|
||||
+29
-12
@@ -1,15 +1,17 @@
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../core/PatternDecoder";
|
||||
import { decodePatternData } from "../core/PatternDecoder";
|
||||
import { PlayerPattern } from "../core/Schemas";
|
||||
|
||||
type PatternResult =
|
||||
| { type: "allowed"; pattern: string }
|
||||
| { type: "allowed"; pattern: PlayerPattern }
|
||||
| { type: "unknown" }
|
||||
| { type: "forbidden"; reason: string };
|
||||
|
||||
export interface PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
base64: string,
|
||||
flares: readonly string[] | undefined,
|
||||
flares: readonly string[],
|
||||
name: string,
|
||||
colorPaletteName: string | null,
|
||||
): PatternResult;
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
@@ -24,25 +26,39 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
) {}
|
||||
|
||||
isPatternAllowed(
|
||||
flares: readonly string[],
|
||||
name: string,
|
||||
flares: readonly string[] | undefined,
|
||||
colorPaletteName: string | null,
|
||||
): PatternResult {
|
||||
// Look for the pattern in the cosmetics.json config
|
||||
const found = this.cosmetics.patterns[name];
|
||||
if (!found) return { type: "forbidden", reason: "pattern not found" };
|
||||
|
||||
try {
|
||||
new PatternDecoder(found.pattern, this.b64urlDecode);
|
||||
decodePatternData(found.pattern, this.b64urlDecode);
|
||||
} catch (e) {
|
||||
return { type: "forbidden", reason: "invalid pattern" };
|
||||
}
|
||||
|
||||
if (
|
||||
flares?.includes(`pattern:${found.name}`) ||
|
||||
flares?.includes("pattern:*")
|
||||
) {
|
||||
const colorPalette = this.cosmetics.colorPalettes?.[colorPaletteName ?? ""];
|
||||
|
||||
if (flares.includes("pattern:*")) {
|
||||
return {
|
||||
type: "allowed",
|
||||
pattern: { name: found.name, patternData: found.pattern, colorPalette },
|
||||
};
|
||||
}
|
||||
|
||||
const flareName =
|
||||
`pattern:${found.name}` +
|
||||
(colorPaletteName ? `:${colorPaletteName}` : "");
|
||||
|
||||
if (flares.includes(flareName)) {
|
||||
// Player has a flare for this pattern
|
||||
return { type: "allowed", pattern: found.pattern };
|
||||
return {
|
||||
type: "allowed",
|
||||
pattern: { name: found.name, patternData: found.pattern, colorPalette },
|
||||
};
|
||||
} else {
|
||||
return { type: "forbidden", reason: "no flares for pattern" };
|
||||
}
|
||||
@@ -124,8 +140,9 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
|
||||
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
flares: readonly string[],
|
||||
name: string,
|
||||
flares: readonly string[] | undefined,
|
||||
colorPaletteName: string | null,
|
||||
): PatternResult {
|
||||
return { type: "unknown" };
|
||||
}
|
||||
|
||||
+84
-43
@@ -13,6 +13,9 @@ import {
|
||||
ClientMessageSchema,
|
||||
ID,
|
||||
PartialGameRecordSchema,
|
||||
PlayerCosmeticRefs,
|
||||
PlayerCosmetics,
|
||||
PlayerPattern,
|
||||
ServerErrorMessage,
|
||||
} from "../core/Schemas";
|
||||
import { replacer } from "../core/Util";
|
||||
@@ -363,47 +366,16 @@ export async function startWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the flag is allowed
|
||||
if (clientMsg.flag !== undefined) {
|
||||
if (clientMsg.flag.startsWith("!")) {
|
||||
const allowed = privilegeRefresher
|
||||
.get()
|
||||
.isCustomFlagAllowed(clientMsg.flag, flares);
|
||||
if (allowed !== true) {
|
||||
log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
|
||||
ws.close(1002, `Custom flag ${allowed}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pattern: string | undefined;
|
||||
// Check if the pattern is allowed
|
||||
if (clientMsg.patternName !== undefined) {
|
||||
const result = privilegeRefresher
|
||||
.get()
|
||||
.isPatternAllowed(clientMsg.patternName, flares);
|
||||
switch (result.type) {
|
||||
case "allowed":
|
||||
pattern = result.pattern;
|
||||
break;
|
||||
case "unknown":
|
||||
log.warn(`Pattern ${clientMsg.patternName} unknown`);
|
||||
ws.close(
|
||||
1002,
|
||||
"Could not look up pattern, backend may be offline",
|
||||
);
|
||||
return;
|
||||
case "forbidden":
|
||||
log.warn(`Pattern ${clientMsg.patternName}: ${result.reason}`);
|
||||
ws.close(
|
||||
1002,
|
||||
`Pattern ${clientMsg.patternName}: ${result.reason}`,
|
||||
);
|
||||
return;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
const { perm, cosmetics, error } = checkCosmetics(
|
||||
clientMsg.cosmetics,
|
||||
flares ?? [],
|
||||
);
|
||||
if (perm === "forbidden") {
|
||||
log.warn(`Forbidden: ${error}`, {
|
||||
clientID: clientMsg.clientID,
|
||||
});
|
||||
ws.close(1002, error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create client and add to game
|
||||
@@ -416,8 +388,7 @@ export async function startWorker() {
|
||||
ip,
|
||||
clientMsg.username,
|
||||
ws,
|
||||
clientMsg.flag,
|
||||
pattern,
|
||||
cosmetics,
|
||||
);
|
||||
|
||||
const wasFound = gm.addClient(
|
||||
@@ -453,6 +424,76 @@ export async function startWorker() {
|
||||
});
|
||||
});
|
||||
|
||||
function checkCosmetics(
|
||||
cosmetics: PlayerCosmeticRefs | undefined,
|
||||
flares: readonly string[],
|
||||
): {
|
||||
perm: "forbidden" | "allowed";
|
||||
cosmetics?: PlayerCosmetics | undefined;
|
||||
error?: string;
|
||||
} {
|
||||
if (cosmetics === undefined) {
|
||||
return {
|
||||
perm: "allowed",
|
||||
cosmetics: undefined,
|
||||
};
|
||||
}
|
||||
// Check if the flag is allowed
|
||||
if (cosmetics.flag !== undefined) {
|
||||
if (cosmetics.flag.startsWith("!")) {
|
||||
const allowed = privilegeRefresher
|
||||
.get()
|
||||
.isCustomFlagAllowed(cosmetics.flag, flares);
|
||||
if (allowed !== true) {
|
||||
log.warn(`Custom flag ${allowed}: ${cosmetics.flag}`);
|
||||
return {
|
||||
perm: "forbidden",
|
||||
error: `Custom flag ${allowed}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pattern: PlayerPattern | undefined;
|
||||
// Check if the pattern is allowed
|
||||
if (cosmetics.patternName !== undefined) {
|
||||
const result = privilegeRefresher
|
||||
.get()
|
||||
.isPatternAllowed(
|
||||
flares,
|
||||
cosmetics.patternName,
|
||||
cosmetics.patternColorPaletteName ?? null,
|
||||
);
|
||||
switch (result.type) {
|
||||
case "allowed":
|
||||
pattern = result.pattern;
|
||||
break;
|
||||
case "unknown":
|
||||
log.warn(`Pattern ${cosmetics.patternName} unknown`);
|
||||
return {
|
||||
perm: "forbidden",
|
||||
error: "Could not look up pattern, backend may be offline",
|
||||
};
|
||||
case "forbidden":
|
||||
log.warn(`Pattern ${cosmetics.patternName}: ${result.reason}`);
|
||||
return {
|
||||
perm: "forbidden",
|
||||
error: `Pattern ${cosmetics.patternName}: ${result.reason}`,
|
||||
};
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
perm: "allowed",
|
||||
cosmetics: {
|
||||
flag: cosmetics.flag,
|
||||
pattern: pattern,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The load balancer will handle routing to this server based on path
|
||||
const PORT = config.workerPortByIndex(workerId);
|
||||
server.listen(PORT, () => {
|
||||
|
||||
Reference in New Issue
Block a user