mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 12:08:04 +00:00
Cosmetic system overhaul: integrate cosmetic manifest handling and update sprite/icon loading logic
This commit is contained in:
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user