mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 10:03:06 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user