Cosmetic system overhaul: integrate cosmetic manifest handling and update sprite/icon loading logic

This commit is contained in:
Aotumuri
2025-09-30 22:13:44 +09:00
parent d16accafef
commit 3122052f60
7 changed files with 211 additions and 21 deletions
@@ -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": {}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+62
View File
@@ -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<string> {
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<string, any>)[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;
}
+35 -14
View File
@@ -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<Record<UnitType | TrainTypeSprite, string>> = {
[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 | TrainTypeSprite, { key: string; url: string }>
> = {
[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<UnitType | TrainTypeSprite, ImageBitmap> = new Map();
@@ -43,14 +58,20 @@ export const loadAllSprites = async (): Promise<void> => {
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();
+56 -7
View File
@@ -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<Record<UnitType, UnitRenderConfig>> = {
private unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[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<void> {
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");
+29
View File
@@ -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<any> = z.lazy(() =>
z.union([imageFile, z.record(z.string(), ImageAssetNode)]),
);
const AudioAssetNode: z.ZodType<any> = 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<typeof CosmeticManifestSchema>;