mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-13 11:43:58 +00:00
support for unlockable flags (#3479)
## Description: Add support for purchasable/gated flags. * Create a new "Store" modal that renders both skins & flags * move all store related logic out of TerritoryPatternsModal * use nation:code for existing nation flags & flag:key for gated flags * check if user has the appropriate flags before purchasing ## 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:
+12
-22
@@ -5,7 +5,8 @@ import { PlayerPattern } from "./Schemas";
|
||||
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type PatternName = z.infer<typeof PatternNameSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
export type PatternData = z.infer<typeof PatternDataSchema>;
|
||||
@@ -16,7 +17,7 @@ export const ProductSchema = z.object({
|
||||
price: z.string(),
|
||||
});
|
||||
|
||||
export const PatternNameSchema = z
|
||||
export const CosmeticNameSchema = z
|
||||
.string()
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.max(32);
|
||||
@@ -51,7 +52,7 @@ export const ColorPaletteSchema = z.object({
|
||||
});
|
||||
|
||||
export const PatternSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
name: CosmeticNameSchema,
|
||||
pattern: PatternDataSchema,
|
||||
colorPalettes: z
|
||||
.object({
|
||||
@@ -64,29 +65,18 @@ export const PatternSchema = z.object({
|
||||
product: ProductSchema.nullable(),
|
||||
});
|
||||
|
||||
export const FlagSchema = z.object({
|
||||
name: CosmeticNameSchema,
|
||||
url: z.string(),
|
||||
affiliateCode: z.string().nullable(),
|
||||
product: ProductSchema.nullable(),
|
||||
});
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
export const CosmeticsSchema = z.object({
|
||||
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
flag: z
|
||||
.object({
|
||||
layers: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
color: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
color: z.string(),
|
||||
name: z.string(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
flags: z.record(z.string(), FlagSchema),
|
||||
});
|
||||
|
||||
export const DefaultPattern = {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Cosmetics } from "./CosmeticSchemas";
|
||||
|
||||
const ANIMATION_DURATIONS: Record<string, number> = {
|
||||
rainbow: 4000,
|
||||
"bright-rainbow": 4000,
|
||||
"copper-glow": 3000,
|
||||
"silver-glow": 3000,
|
||||
"gold-glow": 3000,
|
||||
neon: 3000,
|
||||
lava: 6000,
|
||||
water: 6200,
|
||||
};
|
||||
|
||||
// TODO: Pass in cosmetics as a parameter when
|
||||
// remote cosmetics are implemented for custom flags
|
||||
export function renderPlayerFlag(
|
||||
flag: string,
|
||||
target: HTMLElement,
|
||||
cosmetics: Cosmetics | undefined = undefined,
|
||||
) {
|
||||
if (cosmetics === undefined) {
|
||||
console.warn("No cosmetics provided for flag", flag);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flag.startsWith("!")) return;
|
||||
|
||||
const code = flag.slice("!".length);
|
||||
const layers = code.split("_").map((segment) => {
|
||||
const [layerKey, colorKey] = segment.split("-");
|
||||
return { layerKey, colorKey };
|
||||
});
|
||||
|
||||
target.innerHTML = "";
|
||||
target.style.overflow = "hidden";
|
||||
target.style.position = "relative";
|
||||
target.style.aspectRatio = "3/4";
|
||||
|
||||
for (const { layerKey, colorKey } of layers) {
|
||||
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
|
||||
|
||||
const mask = `/flags/custom/${layerName}.svg`;
|
||||
if (!mask) continue;
|
||||
|
||||
const layer = document.createElement("div");
|
||||
layer.style.position = "absolute";
|
||||
layer.style.top = "0";
|
||||
layer.style.left = "0";
|
||||
layer.style.width = "100%";
|
||||
layer.style.height = "100%";
|
||||
|
||||
const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey;
|
||||
const isSpecial =
|
||||
!colorValue.startsWith("#") &&
|
||||
!/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue);
|
||||
|
||||
if (isSpecial) {
|
||||
const duration = ANIMATION_DURATIONS[colorValue] ?? 5000;
|
||||
const now = performance.now();
|
||||
const offset = now % duration;
|
||||
if (!duration) console.warn(`No animation duration for: ${colorValue}`);
|
||||
layer.classList.add(`flag-color-${colorValue}`);
|
||||
layer.style.animationDelay = `-${offset}ms`;
|
||||
} else {
|
||||
layer.style.backgroundColor = colorValue;
|
||||
}
|
||||
|
||||
layer.style.maskImage = `url(${mask})`;
|
||||
layer.style.maskRepeat = "no-repeat";
|
||||
layer.style.maskPosition = "center";
|
||||
layer.style.maskSize = "contain";
|
||||
|
||||
layer.style.webkitMaskImage = `url(${mask})`;
|
||||
layer.style.webkitMaskRepeat = "no-repeat";
|
||||
layer.style.webkitMaskPosition = "center";
|
||||
layer.style.webkitMaskSize = "contain";
|
||||
|
||||
target.appendChild(layer);
|
||||
}
|
||||
}
|
||||
+18
-16
@@ -1,10 +1,9 @@
|
||||
import countries from "resources/countries.json";
|
||||
import quickChatData from "resources/QuickChat.json";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ColorPaletteSchema,
|
||||
CosmeticNameSchema,
|
||||
PatternDataSchema,
|
||||
PatternNameSchema,
|
||||
} from "./CosmeticSchemas";
|
||||
import type { GameEvent } from "./EventBus";
|
||||
import {
|
||||
@@ -132,7 +131,6 @@ export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
|
||||
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
|
||||
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
|
||||
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
|
||||
export type GameInfo = z.infer<typeof GameInfoSchema>;
|
||||
export type PublicGames = z.infer<typeof PublicGamesSchema>;
|
||||
@@ -284,7 +282,6 @@ export const UsernameSchema = z
|
||||
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
|
||||
.min(3)
|
||||
.max(27);
|
||||
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
|
||||
|
||||
export const QuickChatKeySchema = z.enum(
|
||||
Object.entries(quickChatData).flatMap(([category, entries]) =>
|
||||
@@ -471,28 +468,23 @@ export const TurnSchema = z.object({
|
||||
hash: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const FlagSchema = z
|
||||
export const FlagName = z
|
||||
.string()
|
||||
.max(128)
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === undefined || val === "") return true;
|
||||
if (val.startsWith("!")) return true;
|
||||
return countryCodes.includes(val);
|
||||
return val.startsWith("flag:") || val.startsWith("country:");
|
||||
},
|
||||
{
|
||||
message: "Invalid flag: must start with country: or flag:",
|
||||
},
|
||||
{ message: "Invalid flag: must be a valid country code or start with !" },
|
||||
);
|
||||
|
||||
export const PlayerCosmeticRefsSchema = z.object({
|
||||
flag: FlagSchema.optional(),
|
||||
color: z.string().optional(),
|
||||
patternName: PatternNameSchema.optional(),
|
||||
patternColorPaletteName: z.string().optional(),
|
||||
});
|
||||
export const FlagSchema = z.string();
|
||||
|
||||
export const PlayerPatternSchema = z.object({
|
||||
name: PatternNameSchema,
|
||||
name: CosmeticNameSchema,
|
||||
patternData: PatternDataSchema,
|
||||
colorPalette: ColorPaletteSchema.optional(),
|
||||
});
|
||||
@@ -501,6 +493,16 @@ export const PlayerColorSchema = z.object({
|
||||
color: z.string(),
|
||||
});
|
||||
|
||||
// Refs contain cosmetics names, will be replaced by the actual
|
||||
// content in the server
|
||||
export const PlayerCosmeticRefsSchema = z.object({
|
||||
flag: FlagName.optional(),
|
||||
color: z.string().optional(),
|
||||
patternName: CosmeticNameSchema.optional(),
|
||||
patternColorPaletteName: z.string().optional(),
|
||||
});
|
||||
|
||||
// Server converts refs to the actual cosmetics here
|
||||
export const PlayerCosmeticsSchema = z.object({
|
||||
flag: FlagSchema.optional(),
|
||||
pattern: PlayerPatternSchema.optional(),
|
||||
|
||||
@@ -678,7 +678,7 @@ export class GameView implements GameMap {
|
||||
for (const nation of this._mapData.nations) {
|
||||
// Nations don't have client ids, so we use their name as the key instead.
|
||||
this._cosmetics.set(nation.name, {
|
||||
flag: nation.flag,
|
||||
flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined,
|
||||
} satisfies PlayerCosmetics);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { PlayerPattern } from "../Schemas";
|
||||
const PATTERN_KEY = "territoryPattern";
|
||||
|
||||
export class UserSettings {
|
||||
private emitChange(key: string, value: boolean | number): void {
|
||||
private emitChange(key: string, value: any): void {
|
||||
try {
|
||||
const maybeDispatch = (globalThis as any)?.dispatchEvent;
|
||||
if (typeof maybeDispatch !== "function") return;
|
||||
(globalThis as any).dispatchEvent(
|
||||
new CustomEvent("user-settings-changed", {
|
||||
detail: { key, value },
|
||||
new CustomEvent(`event:user-settings-changed:${key}`, {
|
||||
detail: value,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
@@ -192,6 +192,7 @@ export class UserSettings {
|
||||
} else {
|
||||
localStorage.setItem(PATTERN_KEY, patternName);
|
||||
}
|
||||
this.emitChange("pattern", patternName);
|
||||
}
|
||||
|
||||
getSelectedColor(): string | undefined {
|
||||
@@ -208,12 +209,31 @@ export class UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
getFlag(): string | undefined {
|
||||
const flag = localStorage.getItem("flag");
|
||||
if (!flag || flag === "xx") return undefined;
|
||||
getFlag(): string | null {
|
||||
let flag = localStorage.getItem("flag");
|
||||
if (!flag) return null;
|
||||
// Migrate bare country codes to country: prefix
|
||||
if (!flag.startsWith("flag:") && !flag.startsWith("country:")) {
|
||||
flag = `country:${flag}`;
|
||||
localStorage.setItem("flag", flag);
|
||||
}
|
||||
return flag;
|
||||
}
|
||||
|
||||
setFlag(flag: string): void {
|
||||
if (flag === "country:xx") {
|
||||
this.clearFlag();
|
||||
} else {
|
||||
localStorage.setItem("flag", flag);
|
||||
}
|
||||
console.log("emitting change!");
|
||||
this.emitChange("flag", flag);
|
||||
}
|
||||
|
||||
clearFlag(): void {
|
||||
localStorage.removeItem("flag");
|
||||
}
|
||||
|
||||
backgroundMusicVolume(): number {
|
||||
return this.getFloat("settings.backgroundMusicVolume", 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user