diff --git a/resources/cosmetics/cosmetic_pack/test/manifest.json b/resources/cosmetics/cosmetic_pack/test/manifest.json new file mode 100644 index 000000000..14fcdc56d --- /dev/null +++ b/resources/cosmetics/cosmetic_pack/test/manifest.json @@ -0,0 +1,29 @@ +{ + "id": "test", + "name": "Test", + "assets": { + "structure": { + "img": { + "port": "structure/test.png", + "city": "structure/test.png", + "factory": "structure/test.png", + "missilesilo": "structure/test.png", + "defensepost": "structure/test.png", + "samlauncher": "structure/test.png" + } + }, + "sprites": { + "transportship": "sprites/test.png", + "warship": "sprites/test.png", + "sammissile": "sprites/test.png", + "atombomb": "sprites/test.png", + "hydrogenbomb": "sprites/test.png", + "tradeship": "sprites/test.png", + "mirv": "sprites/test.png", + "engine": "sprites/test.png", + "carriage": "sprites/test.png", + "loadedcarriage": "sprites/test.png" + }, + "audio": {} + } +} diff --git a/resources/cosmetics/cosmetic_pack/test/sprites/test.png b/resources/cosmetics/cosmetic_pack/test/sprites/test.png new file mode 100644 index 000000000..5bba75c4c Binary files /dev/null and b/resources/cosmetics/cosmetic_pack/test/sprites/test.png differ diff --git a/resources/cosmetics/cosmetic_pack/test/structure/test.png b/resources/cosmetics/cosmetic_pack/test/structure/test.png new file mode 100644 index 000000000..c915f2cc5 Binary files /dev/null and b/resources/cosmetics/cosmetic_pack/test/structure/test.png differ diff --git a/src/client/CosmeticPackLoader.ts b/src/client/CosmeticPackLoader.ts new file mode 100644 index 000000000..0cf5b5c25 --- /dev/null +++ b/src/client/CosmeticPackLoader.ts @@ -0,0 +1,62 @@ +import test from "../../resources/cosmetics/cosmetic_pack/test/manifest.json"; +import { + CosmeticManifest, + CosmeticManifestSchema, +} from "../core/CosmeticSchemas"; + +function parseCosmeticManifest(json: unknown): CosmeticManifest { + const res = CosmeticManifestSchema.safeParse(json); + if (!res.success) { + throw new Error(`Invalid CosmeticManifest: ${res.error.message}`); + } + return res.data; +} + +function fetchManifest(packId: string): CosmeticManifest | undefined { + switch (packId) { + case "base": + return; + case "test": + return parseCosmeticManifest(test) as CosmeticManifest; + } + return; +} + +export async function resolveCosmeticUrl( + packId: string | null, + key: string | undefined, + fallback: string, +): Promise { + if (!packId || key === undefined) { + return fallback; + } + try { + const manifest = fetchManifest(packId); + if (!manifest) { + return fallback; + } + // Determine category and subKey from the first "/" only. + const firstSlash = key.indexOf("/"); + if (firstSlash === -1) { + return fallback; + } + const category = key.slice(0, firstSlash); + const subKey = key.slice(firstSlash + 1); + + const table = (manifest.assets as Record)[category]; + if (table) { + const parts = subKey.split("/"); + let current: any = table; + for (const part of parts) { + if (current === null) break; + current = current[part]; + } + if (typeof current === "string") { + return `/cosmetics/cosmetic_pack/${packId}/${current}`; + } + } + } catch (e) { + console.warn("[cosmetics] manifest load failed", e); + } + return fallback; +} diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts index 29d5b7791..27165e8da 100644 --- a/src/client/graphics/SpriteLoader.ts +++ b/src/client/graphics/SpriteLoader.ts @@ -12,6 +12,7 @@ import warshipSprite from "../../../resources/sprites/warship.png"; import { Theme } from "../../core/configuration/Config"; import { TrainType, UnitType } from "../../core/game/Game"; import { UnitView } from "../../core/game/GameView"; +import { resolveCosmeticUrl } from "../CosmeticPackLoader"; // Can't reuse TrainType because "loaded" is not a type, just an attribute const TrainTypeSprite = { @@ -22,17 +23,31 @@ const TrainTypeSprite = { type TrainTypeSprite = (typeof TrainTypeSprite)[keyof typeof TrainTypeSprite]; -const SPRITE_CONFIG: Partial> = { - [UnitType.TransportShip]: transportShipSprite, - [UnitType.Warship]: warshipSprite, - [UnitType.SAMMissile]: samMissileSprite, - [UnitType.AtomBomb]: atomBombSprite, - [UnitType.HydrogenBomb]: hydrogenBombSprite, - [UnitType.TradeShip]: tradeShipSprite, - [UnitType.MIRV]: mirvSprite, - [TrainTypeSprite.Engine]: trainEngineSprite, - [TrainTypeSprite.Carriage]: trainCarriageSprite, - [TrainTypeSprite.LoadedCarriage]: trainLoadedCarriageSprite, +const SPRITE_CONFIG: Partial< + Record +> = { + [UnitType.TransportShip]: { + key: "sprites/transportship", + url: transportShipSprite, + }, + [UnitType.Warship]: { key: "sprites/warship", url: warshipSprite }, + [UnitType.SAMMissile]: { key: "sprites/sammissile", url: samMissileSprite }, + [UnitType.AtomBomb]: { key: "sprites/atombomb", url: atomBombSprite }, + [UnitType.HydrogenBomb]: { + key: "sprites/hydrogenbomb", + url: hydrogenBombSprite, + }, + [UnitType.TradeShip]: { key: "sprites/tradeship", url: tradeShipSprite }, + [UnitType.MIRV]: { key: "sprites/mirv", url: mirvSprite }, + [TrainTypeSprite.Engine]: { key: "sprites/engine", url: trainEngineSprite }, + [TrainTypeSprite.Carriage]: { + key: "sprites/carriage", + url: trainCarriageSprite, + }, + [TrainTypeSprite.LoadedCarriage]: { + key: "sprites/loadedcarriage", + url: trainLoadedCarriageSprite, + }, }; const spriteMap: Map = new Map(); @@ -43,14 +58,20 @@ export const loadAllSprites = async (): Promise => { const totalSprites = entries.length; let loadedCount = 0; + const packId: string | null = "test"; // TODO: wire from server/client selection + await Promise.all( - entries.map(async ([unitType, url]) => { + entries.map(async ([unitType, value]) => { const typedUnitType = unitType as UnitType | TrainTypeSprite; - if (!url || url === "") { - console.warn(`No sprite URL for ${typedUnitType}, skipping...`); + const key = value?.key; + const fallbackUrl = value?.url; + + if (!fallbackUrl) { + console.warn(`No sprite url for ${typedUnitType}, skipping...`); return; } + const url = await resolveCosmeticUrl(packId, key, fallbackUrl); try { const img = new Image(); diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index dbfaf5cb7..843dc57f1 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -1,19 +1,19 @@ import { colord, Colord } from "colord"; -import { Theme } from "../../../core/configuration/Config"; -import { EventBus } from "../../../core/EventBus"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - import cityIcon from "../../../../resources/images/buildings/cityAlt1.png"; import factoryIcon from "../../../../resources/images/buildings/factoryAlt1.png"; import shieldIcon from "../../../../resources/images/buildings/fortAlt3.png"; import anchorIcon from "../../../../resources/images/buildings/port1.png"; import missileSiloIcon from "../../../../resources/images/buildings/silo1.png"; import SAMMissileIcon from "../../../../resources/images/buildings/silo4.png"; +import { Theme } from "../../../core/configuration/Config"; +import { EventBus } from "../../../core/EventBus"; import { Cell, UnitType } from "../../../core/game/Game"; import { euclDistFN, isometricDistFN } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; +import { resolveCosmeticUrl } from "../../CosmeticPackLoader"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; const underConstructionColor = colord({ r: 150, g: 150, b: 150 }); @@ -38,7 +38,7 @@ export class StructureLayer implements Layer { private tempContext: CanvasRenderingContext2D; // Configuration for supported unit types only - private readonly unitConfigs: Partial> = { + private unitConfigs: Partial> = { [UnitType.Port]: { icon: anchorIcon, borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, @@ -98,7 +98,8 @@ export class StructureLayer implements Layer { }; } - private loadIconData() { + private async loadIconData() { + await this.applyCosmeticIcons(); Object.entries(this.unitConfigs).forEach(([unitType, config]) => { this.loadIcon(unitType, config); }); @@ -122,6 +123,54 @@ export class StructureLayer implements Layer { this.redraw(); } + private async applyCosmeticIcons(): Promise { + const packSpec = "test"; + this.unitConfigs[UnitType.Port] = { + ...this.unitConfigs[UnitType.Port]!, + icon: await resolveCosmeticUrl( + packSpec, + "structure/img/port", + anchorIcon, + ), + }; + this.unitConfigs[UnitType.City] = { + ...this.unitConfigs[UnitType.City]!, + icon: await resolveCosmeticUrl(packSpec, "structure/img/city", cityIcon), + }; + this.unitConfigs[UnitType.Factory] = { + ...this.unitConfigs[UnitType.Factory]!, + icon: await resolveCosmeticUrl( + packSpec, + "structure/img/factory", + factoryIcon, + ), + }; + this.unitConfigs[UnitType.MissileSilo] = { + ...this.unitConfigs[UnitType.MissileSilo]!, + icon: await resolveCosmeticUrl( + packSpec, + "structure/img/missilesilo", + missileSiloIcon, + ), + }; + this.unitConfigs[UnitType.DefensePost] = { + ...this.unitConfigs[UnitType.DefensePost]!, + icon: await resolveCosmeticUrl( + packSpec, + "structure/img/defensepost", + shieldIcon, + ), + }; + this.unitConfigs[UnitType.SAMLauncher] = { + ...this.unitConfigs[UnitType.SAMLauncher]!, + icon: await resolveCosmeticUrl( + packSpec, + "structure/img/samlauncher", + SAMMissileIcon, + ), + }; + } + redraw() { console.log("structure layer redrawing"); this.canvas = document.createElement("canvas"); diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index a4bcd6762..0f575848b 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -94,3 +94,32 @@ export const DefaultPattern = { patternData: "AAAAAA", colorPalette: undefined, } satisfies PlayerPattern; + +const imageFile = z + .string() + .regex(/\.(png|webp|jpg|jpeg|gif)$/i, "Invalid image extension"); +const audioFile = z + .string() + .regex(/\.(ogg|mp3|wav|m4a)$/i, "Invalid audio extension"); + +const ImageAssetNode: z.ZodType = z.lazy(() => + z.union([imageFile, z.record(z.string(), ImageAssetNode)]), +); +const AudioAssetNode: z.ZodType = z.lazy(() => + z.union([audioFile, z.record(z.string(), AudioAssetNode)]), +); + +export const CosmeticManifestSchema = z + .object({ + id: z.string(), + name: z.string(), + assets: z + .object({ + structure: z.record(z.string(), ImageAssetNode).optional(), + sprites: z.record(z.string(), ImageAssetNode).optional(), + audio: z.record(z.string(), AudioAssetNode).optional(), + }) + .strict(), + }) + .strict(); +export type CosmeticManifest = z.infer;