mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge pull request #875 from Hornwitser/refactor-export
Refactor export
This commit is contained in:
27 files changed
+1002
-465
No files matched your search
@@ -241,12 +241,13 @@ async function uploadExport(req: Request, res: Response) {
|
||||
"export/settings.json",
|
||||
"export/prototypes.json",
|
||||
"export/locale.json",
|
||||
"export/defines.json",
|
||||
"export/spritesheet.png",
|
||||
"export/metadata.json",
|
||||
];
|
||||
|
||||
let assets: any = {};
|
||||
let settingPrototypes = {};
|
||||
let settingPrototypes: lib.ExportSettings = {};
|
||||
for (let filePath of exportFiles) {
|
||||
let file = zip.file(filePath);
|
||||
if (!file) {
|
||||
@@ -695,12 +696,12 @@ export const webRoutes = [
|
||||
"/instances/:id/view",
|
||||
"/mods",
|
||||
"/mods/mod-packs/:id/view",
|
||||
"/mods/mod-packs/:id/icon-reference",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
"/roles/:id/view",
|
||||
"/plugins",
|
||||
"/plugins/:name/view",
|
||||
"/icon-reference",
|
||||
"/error",
|
||||
];
|
||||
@@ -9,4 +9,5 @@ script.on_init(function()
|
||||
for name, mod_setting in pairs(mod_settings) do
|
||||
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
|
||||
end
|
||||
send_json("defines", defines)
|
||||
end)
|
||||
@@ -23,26 +23,26 @@
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"jimp": "^1.6.0",
|
||||
"jszip": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"jimp": "^0.22.8",
|
||||
"pidusage": "^3.0.2",
|
||||
"rcon-client": "^4.2.5",
|
||||
"semver": "^7.7.1",
|
||||
"set-blocking": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/pidusage": "^2.0.2",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
+132
-167
@@ -1,19 +1,16 @@
|
||||
// Export of item icons and locale
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import Jimp from "jimp";
|
||||
import { Jimp, JimpMime, type JimpInstance } from "jimp";
|
||||
import JSZip from "jszip";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod";
|
||||
import type { FactorioServer } from "./server";
|
||||
import { definesPrototypesFallback } from "./export_fallback";
|
||||
|
||||
interface Prototype {
|
||||
[index: string]: unknown,
|
||||
type: string,
|
||||
name: string,
|
||||
}
|
||||
interface SimpleIconSpecification {
|
||||
icons?: never;
|
||||
icon: string;
|
||||
icon_size?: number;
|
||||
icon_mipmaps?: number;
|
||||
@@ -32,11 +29,9 @@ interface LayeredIconSpecification {
|
||||
icon_mipmaps?: number;
|
||||
}
|
||||
type IconSpecification = LayeredIconSpecification | SimpleIconSpecification;
|
||||
type ItemPrototype = Prototype & IconSpecification;
|
||||
type PrototypeWithIcon = lib.FactorioPrototypeBase & IconSpecification;
|
||||
|
||||
|
||||
type Prototypes = Record<string, Record<string, Prototype>>
|
||||
|
||||
/**
|
||||
* Generate the export mod needed for exportData
|
||||
*
|
||||
@@ -69,7 +64,7 @@ async function generateExportMod(server: FactorioServer) {
|
||||
});
|
||||
}
|
||||
|
||||
let zipCache = new Map();
|
||||
let zipCache = new Map<string, JSZip>();
|
||||
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, mod: string) {
|
||||
let modVersion = modVersions.get(mod);
|
||||
if (!modVersion) {
|
||||
@@ -83,7 +78,7 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
|
||||
zipCache.set(zipPath, zip);
|
||||
}
|
||||
|
||||
return zip.folder(lib.findRoot(zip));
|
||||
return zip.folder(lib.findRoot(zip))!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +129,8 @@ async function loadFile(server: FactorioServer, modVersions: Map<string, string>
|
||||
return await file.async("nodebuffer");
|
||||
}
|
||||
|
||||
type IconCache = Map<string, Jimp | null>;
|
||||
type Image = JimpInstance;
|
||||
type IconCache = Map<string, Image | null>;
|
||||
async function loadIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
@@ -147,8 +143,8 @@ async function loadIcon(
|
||||
if (icon === undefined) {
|
||||
let fileContent = await loadFile(server, modVersions, iconPath);
|
||||
if (fileContent) {
|
||||
icon = await Jimp.read(fileContent);
|
||||
icon.crop(0, 0, iconSize, iconSize);
|
||||
icon = await Jimp.fromBuffer(fileContent) as Image;
|
||||
icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize });
|
||||
iconCache.set(iconPath, icon);
|
||||
} else {
|
||||
icon = null;
|
||||
@@ -184,7 +180,7 @@ async function loadLayeredIcon(
|
||||
iconCache: IconCache,
|
||||
) {
|
||||
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
|
||||
let icon = await Jimp.create(size, size);
|
||||
let icon = new Jimp({ width: size, height: size });
|
||||
|
||||
// The scaling factor of the base layer
|
||||
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
|
||||
@@ -246,7 +242,7 @@ async function loadLayeredIcon(
|
||||
return icon;
|
||||
}
|
||||
|
||||
function fixIcons(item: ItemPrototype) {
|
||||
function fixIcons(item: PrototypeWithIcon) {
|
||||
const icons = item.icons;
|
||||
if (typeof icons === "object" && !(icons instanceof Array) && icons !== null) {
|
||||
// It's possible to specify icons as an object with arbitrary keys
|
||||
@@ -258,84 +254,28 @@ function fixIcons(item: ItemPrototype) {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */
|
||||
function getPrimaryIconPath(item: ItemPrototype): string | undefined {
|
||||
if (item.icons) {
|
||||
return (item as LayeredIconSpecification).icons[0]?.icon;
|
||||
}
|
||||
return (item as SimpleIconSpecification).icon;
|
||||
}
|
||||
|
||||
const itemTypes = new Set([
|
||||
const prototypesWithIcon = [
|
||||
"item",
|
||||
"ammo",
|
||||
"capsule",
|
||||
"gun",
|
||||
"item-with-entity-data",
|
||||
"item-with-label",
|
||||
"item-with-inventory",
|
||||
"blueprint-book",
|
||||
"item-with-tags",
|
||||
"selection-tool",
|
||||
"blueprint",
|
||||
"copy-paste-tool",
|
||||
"deconstruction-item",
|
||||
"upgrade-item",
|
||||
"module",
|
||||
"rail-planner",
|
||||
"spidertron-remote",
|
||||
"tool",
|
||||
"armor",
|
||||
"mining-tool",
|
||||
"repair-tool",
|
||||
// XXX Bad hack to get icons for fluids. These should be treated as a
|
||||
// separate namespace from items as they may be named the same as items.
|
||||
"fluid",
|
||||
]);
|
||||
"recipe",
|
||||
"virtual-signal",
|
||||
"technology",
|
||||
"space-location",
|
||||
"quality",
|
||||
"entity",
|
||||
] as const;
|
||||
|
||||
const recipeTypes = new Set(["recipe"]);
|
||||
const signalTypes = new Set(["virtual-signal"]);
|
||||
const technologyTypes = new Set(["technology"]);
|
||||
const planetTypes = new Set(["planet", "space-location"]);
|
||||
const qualityTypes = new Set(["quality"]);
|
||||
|
||||
// Entity types to export. Entities whose name matches an already-exported
|
||||
const entityTypes = new Set([
|
||||
"assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt",
|
||||
"splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container",
|
||||
"electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler",
|
||||
"generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon",
|
||||
"roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret",
|
||||
"electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car",
|
||||
"spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon",
|
||||
"train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a",
|
||||
"curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a",
|
||||
"elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support",
|
||||
"space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod",
|
||||
"cargo-landing-pad", "asteroid",
|
||||
"construction-robot", "logistic-robot", "combat-robot", "capture-robot",
|
||||
"unit", "unit-spawner", "spider-unit", "segmented-unit",
|
||||
"tree", "plant", "fish", "resource", "cliff", "simple-entity",
|
||||
"simple-entity-with-owner", "simple-entity-with-force",
|
||||
"arithmetic-combinator", "decider-combinator", "constant-combinator",
|
||||
"selector-combinator", "programmable-speaker", "power-switch", "display-panel",
|
||||
"character",
|
||||
"offshore-pump", "valve", "lightning-attractor",
|
||||
"fusion-generator", "fusion-reactor", "burner-generator",
|
||||
"agricultural-tower", "market", "lane-splitter",
|
||||
]);
|
||||
|
||||
function filterPrototypes(prototypes: Prototypes, allowedTypes: Set<string>): ItemPrototype[] {
|
||||
function filterAndFixPrototypesWithIcons(prototypes: lib.FactorioPrototypes, allowedTypes: Set<string>) {
|
||||
return Object.entries(prototypes)
|
||||
.filter(([type]) => allowedTypes.has(type))
|
||||
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
|
||||
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as PrototypeWithIcon[])
|
||||
.map(fixIcons);
|
||||
}
|
||||
|
||||
/** Shared state for building a unified spritesheet across all categories. */
|
||||
interface SheetState {
|
||||
sheet: Jimp;
|
||||
metadata: Map<string, { x: number, y: number, size: number, category: string, path?: string }>;
|
||||
sheet: Image;
|
||||
metadata: Record<string, lib.ExportMetadataEntry[]>;
|
||||
iconCache: IconCache;
|
||||
simpleIcons: Map<string, number>;
|
||||
pos: number;
|
||||
@@ -346,8 +286,8 @@ const SHEET_WIDTH = 1024;
|
||||
|
||||
async function createSheetState(): Promise<SheetState> {
|
||||
return {
|
||||
sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE),
|
||||
metadata: new Map(),
|
||||
sheet: new Jimp({ width: SHEET_WIDTH, height: SHEET_ICON_SIZE }),
|
||||
metadata: Object.create(null),
|
||||
iconCache: new Map(),
|
||||
simpleIcons: new Map(),
|
||||
pos: 0,
|
||||
@@ -359,79 +299,98 @@ async function growSheet(state: SheetState) {
|
||||
const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
||||
const neededHeight = (row + 1) * SHEET_ICON_SIZE;
|
||||
if (neededHeight > state.sheet.bitmap.height) {
|
||||
const extended = await Jimp.create(SHEET_WIDTH, neededHeight);
|
||||
const extended = new Jimp({ width: SHEET_WIDTH, height: neededHeight });
|
||||
extended.composite(state.sheet, 0, 0);
|
||||
(state.sheet as any).bitmap = (extended as any).bitmap;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
state: SheetState,
|
||||
prototype: PrototypeWithIcon,
|
||||
) {
|
||||
let icon: Image | null = null;
|
||||
let iconPos: number | undefined;
|
||||
let iconData: lib.ExportMetadataEntry["icon"];
|
||||
let iconPath: string | undefined;
|
||||
|
||||
if (prototype.icons) {
|
||||
icon = await loadLayeredIcon(
|
||||
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
iconPos = state.pos;
|
||||
iconPath = prototype.icons[0].icon;
|
||||
|
||||
} else if (prototype.icon) {
|
||||
iconPos = state.simpleIcons.get(prototype.icon);
|
||||
iconPath = prototype.icon;
|
||||
if (iconPos === undefined) {
|
||||
icon = await loadSimpleIcon(
|
||||
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
if (icon) {
|
||||
iconPos = state.pos;
|
||||
state.simpleIcons.set(prototype.icon, state.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iconPos !== undefined) {
|
||||
iconData = {
|
||||
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
size: SHEET_ICON_SIZE,
|
||||
path: iconPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
await growSheet(state);
|
||||
state.sheet.composite(
|
||||
icon,
|
||||
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
);
|
||||
state.pos += 1;
|
||||
}
|
||||
|
||||
return iconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export item icons for a single category onto the shared spritesheet.
|
||||
* Export prototype icons for a single base prototype onto the shared spritesheet.
|
||||
*
|
||||
* @param server - The server to load icons from.
|
||||
* @param modVersions - Mapping of mod name to version.
|
||||
* @param items - Array of item prototypes to export.
|
||||
* @param category - Category name for metadata.
|
||||
* @param prototypes - Array of prototypes with icon to export for this base type.
|
||||
* @param baseName - name of base type (used in metadata).
|
||||
* @param state - Shared spritesheet state (mutated in place).
|
||||
* @returns Number of icons exported for this category.
|
||||
* @returns Number of icons exported for this base type.
|
||||
* @internal
|
||||
*/
|
||||
async function exportItems(
|
||||
async function exportIcons(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
items: ItemPrototype[],
|
||||
category: string,
|
||||
prototypes: PrototypeWithIcon[],
|
||||
baseName: string,
|
||||
state: SheetState,
|
||||
) {
|
||||
const metadata = state.metadata[baseName] ?? [];
|
||||
state.metadata[baseName] ??= metadata;
|
||||
let count = 0;
|
||||
for (let item of items) {
|
||||
// Skip prototypes with no icon data at all
|
||||
if (!item.icons && !item.icon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let icon: Jimp | null = null;
|
||||
let iconPos: number | undefined;
|
||||
if (item.icons) {
|
||||
icon = await loadLayeredIcon(
|
||||
server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
iconPos = state.pos;
|
||||
|
||||
} else {
|
||||
iconPos = state.simpleIcons.get(item.icon as string);
|
||||
if (iconPos === undefined) {
|
||||
icon = await loadSimpleIcon(
|
||||
server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
if (icon) {
|
||||
iconPos = state.pos;
|
||||
state.simpleIcons.set(item.icon as string, state.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iconPos !== undefined) {
|
||||
const iconPath = getPrimaryIconPath(item);
|
||||
state.metadata.set(`${category}.${item.name}`, {
|
||||
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
size: SHEET_ICON_SIZE,
|
||||
category,
|
||||
...(iconPath ? { path: iconPath } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (let prototype of prototypes) {
|
||||
const icon = await exportIcon(server, modVersions, state, prototype);
|
||||
if (icon) {
|
||||
await growSheet(state);
|
||||
state.sheet.composite(
|
||||
icon,
|
||||
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
);
|
||||
state.pos += 1;
|
||||
count += 1;
|
||||
}
|
||||
metadata.push({
|
||||
name: prototype.name,
|
||||
type: prototype.type,
|
||||
localised_name: prototype.localised_name,
|
||||
icon,
|
||||
});
|
||||
}
|
||||
|
||||
return count;
|
||||
@@ -505,7 +464,7 @@ async function exportLocale(
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the locale and item icons for the given factorio server
|
||||
* Export locale, icons and metadata for the given factorio server
|
||||
*
|
||||
* @param server - The server to export the data from.
|
||||
* @returns zip file with exported data.
|
||||
@@ -513,12 +472,13 @@ async function exportLocale(
|
||||
export async function exportData(server: FactorioServer) {
|
||||
await generateExportMod(server);
|
||||
|
||||
let settings: Prototypes = {};
|
||||
let prototypes: Prototypes = {};
|
||||
let settings: lib.ExportSettings = {};
|
||||
let prototypes: lib.ExportPrototypes = {};
|
||||
let modVersions = new Map();
|
||||
let modOrder: string[] = [];
|
||||
let defines: lib.FactorioDefines | undefined;
|
||||
|
||||
function add(obj: Prototypes, prototype: Prototype) {
|
||||
function add(obj: lib.FactorioPrototypes, prototype: lib.FactorioPrototypeBase) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) {
|
||||
obj[prototype.type] = {};
|
||||
}
|
||||
@@ -537,6 +497,7 @@ export async function exportData(server: FactorioServer) {
|
||||
server._logger.error(`Unable to find ${name} in settings prototypes`);
|
||||
});
|
||||
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
|
||||
server.on("ipc-defines", data => { defines = data; });
|
||||
server.on("output", parsed => {
|
||||
if (parsed.format === "seconds" && parsed.type === "generic") {
|
||||
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
|
||||
@@ -557,6 +518,15 @@ export async function exportData(server: FactorioServer) {
|
||||
throw new Error("No prototypes got exported");
|
||||
}
|
||||
|
||||
if (!defines) {
|
||||
throw new Error("No defines got exported");
|
||||
}
|
||||
|
||||
// compat with versions prior to 1.1.31
|
||||
if (!defines.prototypes) {
|
||||
defines.prototypes = definesPrototypesFallback;
|
||||
}
|
||||
|
||||
// Some mod authors put leading zeros into the versions of their zip files.
|
||||
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
|
||||
for (let entry of await fs.readdir(server.writePath("mods"))) {
|
||||
@@ -579,45 +549,40 @@ export async function exportData(server: FactorioServer) {
|
||||
}
|
||||
}
|
||||
|
||||
const categories: { name: string, types: Set<string> }[] = [
|
||||
{ name: "item", types: itemTypes },
|
||||
{ name: "recipe", types: recipeTypes },
|
||||
{ name: "signal", types: signalTypes },
|
||||
{ name: "technology", types: technologyTypes },
|
||||
{ name: "planet", types: planetTypes },
|
||||
{ name: "quality", types: qualityTypes },
|
||||
{ name: "entity", types: entityTypes },
|
||||
];
|
||||
|
||||
let locale = await exportLocale(server, modVersions, modOrder, "en");
|
||||
|
||||
// Free up the memory used by zip files loaded during the export.
|
||||
zipCache.clear();
|
||||
|
||||
let zip = new JSZip();
|
||||
zip.file("export/settings.json", JSON.stringify(settings));
|
||||
zip.file("export/prototypes.json", JSON.stringify(prototypes));
|
||||
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
|
||||
zip.file("export/settings.json", JSON.stringify(settings satisfies lib.ExportSettings));
|
||||
zip.file("export/prototypes.json", JSON.stringify(prototypes satisfies lib.ExportPrototypes));
|
||||
zip.file("export/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale));
|
||||
zip.file("export/defines.json", JSON.stringify(defines satisfies lib.ExportDefines));
|
||||
|
||||
// Build a single unified spritesheet across all prototype categories.
|
||||
// Build a single unified spritesheet across all prototypes with icons.
|
||||
const state = await createSheetState();
|
||||
for (const category of categories) {
|
||||
const categoryItems = filterPrototypes(prototypes, category.types);
|
||||
|
||||
if (categoryItems.length === 0) {
|
||||
for (const baseName of prototypesWithIcon) {
|
||||
if (!Object.prototype.hasOwnProperty.call(defines.prototypes, baseName)) {
|
||||
continue;
|
||||
}
|
||||
const count = await exportItems(server, modVersions, categoryItems, category.name, state);
|
||||
server._logger.info(`Exported ${count} ${category.name} icons`);
|
||||
const subTypeNames = new Set(Object.keys(defines.prototypes[baseName]));
|
||||
const prototypesWithIcons = filterAndFixPrototypesWithIcons(prototypes, subTypeNames);
|
||||
|
||||
if (prototypesWithIcons.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const count = await exportIcons(server, modVersions, prototypesWithIcons, baseName, state);
|
||||
server._logger.info(`Exported ${count} ${baseName} icons`);
|
||||
}
|
||||
|
||||
// Crop sheet to actual used height and write single spritesheet + metadata.
|
||||
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
||||
state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1));
|
||||
zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG));
|
||||
zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()]));
|
||||
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
|
||||
zip.file("export/spritesheet.png", await state.sheet.getBuffer(JimpMime.png));
|
||||
zip.file("export/metadata.json", JSON.stringify(state.metadata satisfies lib.ExportMetadata));
|
||||
|
||||
server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`);
|
||||
server._logger.info(`Export complete: ${state.pos} icons on ${usedRows} row(s)`);
|
||||
|
||||
return zip;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// defines.prototypes from Factorio 1.1.31, the first version that has this.
|
||||
export const definesPrototypesFallback = {
|
||||
"achievement": {
|
||||
"achievement": 0,
|
||||
"build-entity-achievement": 0,
|
||||
"combat-robot-count": 0,
|
||||
"construct-with-robots-achievement": 0,
|
||||
"deconstruct-with-robots-achievement": 0,
|
||||
"deliver-by-robots-achievement": 0,
|
||||
"dont-build-entity-achievement": 0,
|
||||
"dont-craft-manually-achievement": 0,
|
||||
"dont-use-entity-in-energy-production-achievement": 0,
|
||||
"finish-the-game-achievement": 0,
|
||||
"group-attack-achievement": 0,
|
||||
"kill-achievement": 0,
|
||||
"player-damaged-achievement": 0,
|
||||
"produce-achievement": 0,
|
||||
"produce-per-hour-achievement": 0,
|
||||
"research-achievement": 0,
|
||||
"train-path-achievement": 0,
|
||||
},
|
||||
"ambient-sound": {
|
||||
"ambient-sound": 0,
|
||||
},
|
||||
"ammo-category": {
|
||||
"ammo-category": 0,
|
||||
},
|
||||
"animation": {
|
||||
"animation": 0,
|
||||
},
|
||||
"autoplace-control": {
|
||||
"autoplace-control": 0,
|
||||
},
|
||||
"custom-input": {
|
||||
"custom-input": 0,
|
||||
},
|
||||
"damage-type": {
|
||||
"damage-type": 0,
|
||||
},
|
||||
"decorative": {
|
||||
"optimized-decorative": 0,
|
||||
},
|
||||
"editor-controller": {
|
||||
"editor-controller": 0,
|
||||
},
|
||||
"entity": {
|
||||
"accumulator": 0,
|
||||
"ammo-turret": 0,
|
||||
"arithmetic-combinator": 0,
|
||||
"arrow": 0,
|
||||
"artillery-flare": 0,
|
||||
"artillery-projectile": 0,
|
||||
"artillery-turret": 0,
|
||||
"artillery-wagon": 0,
|
||||
"assembling-machine": 0,
|
||||
"beacon": 0,
|
||||
"beam": 0,
|
||||
"boiler": 0,
|
||||
"burner-generator": 0,
|
||||
"car": 0,
|
||||
"cargo-wagon": 0,
|
||||
"character": 0,
|
||||
"character-corpse": 0,
|
||||
"cliff": 0,
|
||||
"combat-robot": 0,
|
||||
"constant-combinator": 0,
|
||||
"construction-robot": 0,
|
||||
"container": 0,
|
||||
"corpse": 0,
|
||||
"curved-rail": 0,
|
||||
"decider-combinator": 0,
|
||||
"deconstructible-tile-proxy": 0,
|
||||
"electric-energy-interface": 0,
|
||||
"electric-pole": 0,
|
||||
"electric-turret": 0,
|
||||
"entity-ghost": 0,
|
||||
"explosion": 0,
|
||||
"fire": 0,
|
||||
"fish": 0,
|
||||
"flame-thrower-explosion": 0,
|
||||
"fluid-turret": 0,
|
||||
"fluid-wagon": 0,
|
||||
"flying-text": 0,
|
||||
"furnace": 0,
|
||||
"gate": 0,
|
||||
"generator": 0,
|
||||
"heat-interface": 0,
|
||||
"heat-pipe": 0,
|
||||
"highlight-box": 0,
|
||||
"infinity-container": 0,
|
||||
"infinity-pipe": 0,
|
||||
"inserter": 0,
|
||||
"item-entity": 0,
|
||||
"item-request-proxy": 0,
|
||||
"lab": 0,
|
||||
"lamp": 0,
|
||||
"land-mine": 0,
|
||||
"leaf-particle": 0,
|
||||
"linked-belt": 0,
|
||||
"linked-container": 0,
|
||||
"loader": 0,
|
||||
"loader-1x1": 0,
|
||||
"locomotive": 0,
|
||||
"logistic-container": 0,
|
||||
"logistic-robot": 0,
|
||||
"market": 0,
|
||||
"mining-drill": 0,
|
||||
"offshore-pump": 0,
|
||||
"particle": 0,
|
||||
"particle-source": 0,
|
||||
"pipe": 0,
|
||||
"pipe-to-ground": 0,
|
||||
"player-port": 0,
|
||||
"power-switch": 0,
|
||||
"programmable-speaker": 0,
|
||||
"projectile": 0,
|
||||
"pump": 0,
|
||||
"radar": 0,
|
||||
"rail-chain-signal": 0,
|
||||
"rail-remnants": 0,
|
||||
"rail-signal": 0,
|
||||
"reactor": 0,
|
||||
"resource": 0,
|
||||
"roboport": 0,
|
||||
"rocket-silo": 0,
|
||||
"rocket-silo-rocket": 0,
|
||||
"rocket-silo-rocket-shadow": 0,
|
||||
"simple-entity": 0,
|
||||
"simple-entity-with-force": 0,
|
||||
"simple-entity-with-owner": 0,
|
||||
"smoke": 0,
|
||||
"smoke-with-trigger": 0,
|
||||
"solar-panel": 0,
|
||||
"speech-bubble": 0,
|
||||
"spider-leg": 0,
|
||||
"spider-vehicle": 0,
|
||||
"splitter": 0,
|
||||
"sticker": 0,
|
||||
"storage-tank": 0,
|
||||
"straight-rail": 0,
|
||||
"stream": 0,
|
||||
"tile-ghost": 0,
|
||||
"train-stop": 0,
|
||||
"transport-belt": 0,
|
||||
"tree": 0,
|
||||
"turret": 0,
|
||||
"underground-belt": 0,
|
||||
"unit": 0,
|
||||
"unit-spawner": 0,
|
||||
"wall": 0,
|
||||
},
|
||||
"equipment": {
|
||||
"active-defense-equipment": 0,
|
||||
"battery-equipment": 0,
|
||||
"belt-immunity-equipment": 0,
|
||||
"energy-shield-equipment": 0,
|
||||
"generator-equipment": 0,
|
||||
"movement-bonus-equipment": 0,
|
||||
"night-vision-equipment": 0,
|
||||
"roboport-equipment": 0,
|
||||
"solar-panel-equipment": 0,
|
||||
},
|
||||
"equipment-category": {
|
||||
"equipment-category": 0,
|
||||
},
|
||||
"equipment-grid": {
|
||||
"equipment-grid": 0,
|
||||
},
|
||||
"fluid": {
|
||||
"fluid": 0,
|
||||
},
|
||||
"font": {
|
||||
"font": 0,
|
||||
},
|
||||
"fuel-category": {
|
||||
"fuel-category": 0,
|
||||
},
|
||||
"god-controller": {
|
||||
"god-controller": 0,
|
||||
},
|
||||
"gui-style": {
|
||||
"gui-style": 0,
|
||||
},
|
||||
"item": {
|
||||
"ammo": 0,
|
||||
"armor": 0,
|
||||
"blueprint": 0,
|
||||
"blueprint-book": 0,
|
||||
"capsule": 0,
|
||||
"copy-paste-tool": 0,
|
||||
"deconstruction-item": 0,
|
||||
"gun": 0,
|
||||
"item": 0,
|
||||
"item-with-entity-data": 0,
|
||||
"item-with-inventory": 0,
|
||||
"item-with-label": 0,
|
||||
"item-with-tags": 0,
|
||||
"mining-tool": 0,
|
||||
"module": 0,
|
||||
"rail-planner": 0,
|
||||
"repair-tool": 0,
|
||||
"selection-tool": 0,
|
||||
"spidertron-remote": 0,
|
||||
"tool": 0,
|
||||
"upgrade-item": 0,
|
||||
},
|
||||
"item-group": {
|
||||
"item-group": 0,
|
||||
},
|
||||
"item-subgroup": {
|
||||
"item-subgroup": 0,
|
||||
},
|
||||
"map-gen-presets": {
|
||||
"map-gen-presets": 0,
|
||||
},
|
||||
"map-settings": {
|
||||
"map-settings": 0,
|
||||
},
|
||||
"module-category": {
|
||||
"module-category": 0,
|
||||
},
|
||||
"mouse-cursor": {
|
||||
"mouse-cursor": 0,
|
||||
},
|
||||
"noise-expression": {
|
||||
"noise-expression": 0,
|
||||
},
|
||||
"noise-layer": {
|
||||
"noise-layer": 0,
|
||||
},
|
||||
"particle": {
|
||||
"optimized-particle": 0,
|
||||
},
|
||||
"recipe": {
|
||||
"recipe": 0,
|
||||
},
|
||||
"recipe-category": {
|
||||
"recipe-category": 0,
|
||||
},
|
||||
"resource-category": {
|
||||
"resource-category": 0,
|
||||
},
|
||||
"shortcut": {
|
||||
"shortcut": 0,
|
||||
},
|
||||
"sound": {
|
||||
"sound": 0,
|
||||
},
|
||||
"spectator-controller": {
|
||||
"spectator-controller": 0,
|
||||
},
|
||||
"sprite": {
|
||||
"sprite": 0,
|
||||
},
|
||||
"technology": {
|
||||
"technology": 0,
|
||||
},
|
||||
"tile": {
|
||||
"tile": 0,
|
||||
},
|
||||
"tile-effect": {
|
||||
"tile-effect": 0,
|
||||
},
|
||||
"tips-and-tricks-item": {
|
||||
"tips-and-tricks-item": 0,
|
||||
},
|
||||
"tips-and-tricks-item-category": {
|
||||
"tips-and-tricks-item-category": 0,
|
||||
},
|
||||
"trigger-target-type": {
|
||||
"trigger-target-type": 0,
|
||||
},
|
||||
"trivial-smoke": {
|
||||
"trivial-smoke": 0,
|
||||
},
|
||||
"tutorial": {
|
||||
"tutorial": 0,
|
||||
},
|
||||
"utility-constants": {
|
||||
"utility-constants": 0,
|
||||
},
|
||||
"utility-sounds": {
|
||||
"utility-sounds": 0,
|
||||
},
|
||||
"utility-sprites": {
|
||||
"utility-sprites": 0,
|
||||
},
|
||||
"virtual-signal": {
|
||||
"virtual-signal": 0,
|
||||
},
|
||||
"wind-sound": {
|
||||
"wind-sound": 0,
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@ export * from "./src/api";
|
||||
export * from "./src/config";
|
||||
export * from "./src/data";
|
||||
export * from "./src/errors";
|
||||
export * from "./src/export";
|
||||
export * from "./src/factorio/exchange_string";
|
||||
export * from "./src/helpers";
|
||||
export * from "./src/ini";
|
||||
@@ -16,6 +17,7 @@ export * from "./src/subscriptions";
|
||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||
export { default as RateLimiter } from "./src/RateLimiter";
|
||||
export { default as ValueCache } from "./src/ValueCache";
|
||||
export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal";
|
||||
|
||||
import { checkSingletonImport } from "./src/check_singleton_import";
|
||||
checkSingletonImport(import.meta.url);
|
||||
@@ -9,6 +9,7 @@ export * from "./src/config";
|
||||
export * from "./src/data";
|
||||
export * from "./src/database";
|
||||
export * from "./src/errors";
|
||||
export * from "./src/export";
|
||||
export * from "./src/external";
|
||||
export * from "./src/factorio";
|
||||
export * from "./src/file_ops";
|
||||
@@ -38,6 +39,7 @@ export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||
export { default as ModStore } from "./src/ModStore";
|
||||
export { default as RateLimiter } from "./src/RateLimiter";
|
||||
export { default as ValueCache } from "./src/ValueCache";
|
||||
export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal";
|
||||
|
||||
import { checkSingletonImport } from "./src/check_singleton_import";
|
||||
checkSingletonImport(__filename);
|
||||
@@ -5,6 +5,7 @@ import * as libSchema from "../schema";
|
||||
import * as libLuaTools from "../lua_tools";
|
||||
|
||||
import ExportManifest from "./ExportManifest";
|
||||
import type { ExportSettings } from "../export";
|
||||
import type { Logger } from "../logging";
|
||||
|
||||
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
@@ -357,7 +358,7 @@ export default class ModPack {
|
||||
* Setting prototypes exported from the game.
|
||||
* @param logger - Logger used to report warnings on.
|
||||
*/
|
||||
fillDefaultSettings(settingPrototypes: Record<string, object>, logger: Logger) {
|
||||
fillDefaultSettings(settingPrototypes: ExportSettings, logger: Logger) {
|
||||
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
|
||||
let prototypes = Object.entries(settingPrototypes)
|
||||
.filter(([type, _]) => knownTypes.includes(type))
|
||||
@@ -370,7 +371,7 @@ export default class ModPack {
|
||||
logger.warn(`Ignoring ${prototype.name} with unknown setting_type ${settingType}`);
|
||||
continue;
|
||||
}
|
||||
if (prototype.default_value === undefined) {
|
||||
if ((prototype as any).default_value === undefined) {
|
||||
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
|
||||
continue;
|
||||
}
|
||||
@@ -389,7 +390,7 @@ export default class ModPack {
|
||||
if (type === "color-setting") {
|
||||
value = libLuaTools.normalizeColor(value as object);
|
||||
}
|
||||
this.settings[settingType].set(prototype.name, { value });
|
||||
this.settings[settingType].set(prototype.name, { value } as ModSetting);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { FactorioColor } from "./lua_tools";
|
||||
|
||||
/**
|
||||
* A localised string.
|
||||
* @see [LocalisedString](https://lua-api.factorio.com/stable/types/LocalisedString.html)
|
||||
*/
|
||||
export type FactorioLocalisedString = string | [string, ...FactorioLocalisedString[]];
|
||||
|
||||
/**
|
||||
* Shared properties of all prototypes.
|
||||
* @see [PrototypeBase](https://lua-api.factorio.com/stable/types/PrototypeBase.html)
|
||||
*/
|
||||
export interface FactorioPrototypeBase {
|
||||
[index: string]: unknown,
|
||||
type: string,
|
||||
name: string,
|
||||
order?: string,
|
||||
localised_name?: FactorioLocalisedString,
|
||||
localised_description?: FactorioLocalisedString,
|
||||
factoriopedia_description?: FactorioLocalisedString,
|
||||
subgroup?: string,
|
||||
hidden?: boolean,
|
||||
hidden_in_factoriopedia?: boolean,
|
||||
parameter?: boolean,
|
||||
factoriopedia_simulation?: unknown,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage the mod setting is part of.
|
||||
* @see [settings_type property](https://wiki.factorio.com/Tutorial:Mod_settings#The_setting_type_property)
|
||||
*/
|
||||
export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per-user";
|
||||
|
||||
/**
|
||||
* Boolean mod setting prototype.
|
||||
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
|
||||
*/
|
||||
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "bool-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: boolean,
|
||||
forced_value?: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer mod setting prototype.
|
||||
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
|
||||
*/
|
||||
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "int-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
minimum_value?: number,
|
||||
maximum_value?: number,
|
||||
allowed_values?: number[],
|
||||
}
|
||||
|
||||
/**
|
||||
* Real number mod setting prototype.
|
||||
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
|
||||
*/
|
||||
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "double-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
minimum_value?: number,
|
||||
maximum_value?: number,
|
||||
allowed_values?: number[],
|
||||
}
|
||||
|
||||
/**
|
||||
* String mod setting prototype.
|
||||
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
|
||||
*/
|
||||
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "string-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: string,
|
||||
allow_black?: boolean,
|
||||
auto_trim?: string,
|
||||
allowed_values?: string[],
|
||||
}
|
||||
|
||||
/**
|
||||
* Color mod setting prototype.
|
||||
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
|
||||
*/
|
||||
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "color-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: FactorioColor,
|
||||
forced_value?: boolean,
|
||||
}
|
||||
|
||||
export type FactorioModSettingPrototype =
|
||||
| FactorioBoolSettingPrototype
|
||||
| FactorioIntSettingPrototype
|
||||
| FactorioDoubleSettingPrototype
|
||||
| FactorioStringSettingPrototype
|
||||
| FactorioColorSettingPrototype
|
||||
;
|
||||
|
||||
/**
|
||||
* A list of prototypes as it appears in Factorio's Lua API.
|
||||
* @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw)
|
||||
*/
|
||||
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>
|
||||
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
|
||||
export type FactorioDefines = {
|
||||
[index: string]: FactorioDefinesValue,
|
||||
prototypes: Record<string, Record<string, number>>,
|
||||
};
|
||||
|
||||
export type ExportSettings = FactorioPrototypes<FactorioModSettingPrototype>;
|
||||
export type ExportPrototypes = FactorioPrototypes;
|
||||
export type ExportLocale = [string, string][];
|
||||
export interface ExportMetadataEntry {
|
||||
name: string,
|
||||
type: string,
|
||||
localised_name?: FactorioLocalisedString,
|
||||
icon?: {
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
path?: string,
|
||||
},
|
||||
}
|
||||
export type ExportMetadata = Record<string, ExportMetadataEntry[]>;
|
||||
export type ExportDefines = FactorioDefines;
|
||||
@@ -10,6 +10,7 @@
|
||||
"./src/check_singleton_import.ts",
|
||||
"./src/datastore.ts",
|
||||
"./src/errors.ts",
|
||||
"./src/export.ts",
|
||||
"./src/factorio/exchange_string.ts",
|
||||
"./src/helpers.ts",
|
||||
"./src/hash.ts",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
|
||||
|
||||
export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) {
|
||||
return <span
|
||||
className="factorio-icon"
|
||||
data-mod-pack={props.modPackId}
|
||||
data-type={props.prototype?.base_type}
|
||||
data-name={props.prototype?.name}
|
||||
data-unknown={props.prototype ? undefined : "true"}
|
||||
/>;
|
||||
}
|
||||
@@ -1,109 +1,56 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Card, Input, Space, Table, Tag, Typography } from "antd";
|
||||
|
||||
import {
|
||||
useItemMetadata,
|
||||
useRecipeMetadata,
|
||||
useSignalMetadata,
|
||||
useTechnologyMetadata,
|
||||
usePlanetMetadata,
|
||||
useQualityMetadata,
|
||||
useEntityMetadata,
|
||||
} from "../model/item_metadata";
|
||||
import { useExportManifest } from "../model/export_manifest";
|
||||
import { useExportPrototypeMetadata } from "../model/export_prototype_metadata";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import FactorioIcon from "./FactorioIcon";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useModPack } from "../model/mod_pack";
|
||||
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type IconEntry = {
|
||||
key: string;
|
||||
category: string;
|
||||
name: string;
|
||||
cssClass: string;
|
||||
size: number;
|
||||
path?: string;
|
||||
};
|
||||
function filterPrototypes(
|
||||
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
|
||||
filter: string
|
||||
) {
|
||||
const query = filter.trim().toLowerCase();
|
||||
return new Map(
|
||||
[...prototypes ?? []].map(([baseType, entries]) => [
|
||||
baseType,
|
||||
new Map([...entries.entries()].filter(([name, entry]) => (
|
||||
`${entry.base_type}.${name}`.includes(query)
|
||||
|| `${entry.type}.${name}`.includes(query)
|
||||
))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
"item", "recipe", "signal", "technology", "planet", "quality", "entity",
|
||||
] as const;
|
||||
|
||||
type Category = typeof CATEGORIES[number];
|
||||
|
||||
const CATEGORY_LABELS: Record<Category, string> = {
|
||||
item: "Item",
|
||||
recipe: "Recipe",
|
||||
signal: "Signal",
|
||||
technology: "Technology",
|
||||
planet: "Planet",
|
||||
quality: "Quality",
|
||||
entity: "Entity",
|
||||
};
|
||||
|
||||
function useAllIconEntries() {
|
||||
const item = useItemMetadata();
|
||||
const recipe = useRecipeMetadata();
|
||||
const signal = useSignalMetadata();
|
||||
const technology = useTechnologyMetadata();
|
||||
const planet = usePlanetMetadata();
|
||||
const quality = useQualityMetadata();
|
||||
const entity = useEntityMetadata();
|
||||
|
||||
const byCategory: Record<Category, Map<string, { size: number; path?: string }>> = {
|
||||
item, recipe, signal, technology, planet, quality, entity,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
const entries: IconEntry[] = [];
|
||||
|
||||
for (const category of CATEGORIES) {
|
||||
const names = [...byCategory[category].keys()].sort();
|
||||
for (const name of names) {
|
||||
const meta = byCategory[category].get(name)!;
|
||||
entries.push({
|
||||
key: `${category}:${name}`,
|
||||
category,
|
||||
name,
|
||||
cssClass: `${category}-${CSS.escape(name)}`,
|
||||
size: meta.size,
|
||||
path: meta.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [item, recipe, signal, technology, planet, quality, entity]);
|
||||
function countPrototypes(
|
||||
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
|
||||
) {
|
||||
return prototypes ? [...prototypes.values()].reduce((a, e) => a + e.size, 0) : 0;
|
||||
}
|
||||
|
||||
export default function IconReferencePage() {
|
||||
let params = useParams();
|
||||
let modPackId = Number(params.id);
|
||||
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const allEntries = useAllIconEntries();
|
||||
const [modPack] = useModPack(modPackId);
|
||||
const manifest = modPack?.exportManifest;
|
||||
const prototypes = useExportPrototypeMetadata(modPack);
|
||||
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
|
||||
const groups = useMemo(() => (
|
||||
[...filtered]
|
||||
.map(([baseType, entries]) => ({ baseType, entries: [...entries.values()] }))
|
||||
.filter(group => group.entries.length)
|
||||
), [filtered]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return allEntries;
|
||||
}
|
||||
return allEntries.filter(e => e.name.toLowerCase().includes(q) || e.cssClass.toLowerCase().includes(q)
|
||||
);
|
||||
}, [allEntries, filter]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, IconEntry[]>();
|
||||
for (const cat of CATEGORIES) {
|
||||
map.set(cat, []);
|
||||
}
|
||||
for (const entry of filtered) {
|
||||
map.get(entry.category)!.push(entry);
|
||||
}
|
||||
return CATEGORIES.map(cat => ({ category: cat, entries: map.get(cat)! }))
|
||||
.filter(g => g.entries.length > 0);
|
||||
}, [filtered]);
|
||||
|
||||
const visibleCount = filtered.length;
|
||||
const totalCount = allEntries.length;
|
||||
const visibleCount = countPrototypes(filtered);
|
||||
const totalCount = countPrototypes(prototypes);
|
||||
const autoExpand = visibleCount <= 20;
|
||||
|
||||
const isExpanded = (category: string) => autoExpand || expandedGroups.has(category);
|
||||
@@ -123,11 +70,10 @@ export default function IconReferencePage() {
|
||||
const columns = [
|
||||
{
|
||||
title: "Icon",
|
||||
dataIndex: "cssClass",
|
||||
key: "icon",
|
||||
width: 48,
|
||||
render: (_: string, record: IconEntry) => (
|
||||
<div className={record.cssClass} style={{ imageRendering: "pixelated" }} />
|
||||
render: (prototype: PrototypeMetadataEntry) => (
|
||||
<FactorioIcon modPackId={modPackId} prototype={prototype}/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -138,19 +84,9 @@ export default function IconReferencePage() {
|
||||
<Text style={{ fontSize: 12 }}>{name}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Usage",
|
||||
dataIndex: "cssClass",
|
||||
key: "usage",
|
||||
render: (cssClass: string) => (
|
||||
<Text code style={{ fontSize: 11, color: "#90ee90" }}>
|
||||
{`<div class="${cssClass}" />`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Path",
|
||||
dataIndex: "path",
|
||||
dataIndex: ["icon", "path"],
|
||||
key: "path",
|
||||
render: (p: string | undefined) => (p
|
||||
? <Text type="secondary" style={{ fontSize: 11 }}>{p}</Text>
|
||||
@@ -158,13 +94,17 @@ export default function IconReferencePage() {
|
||||
},
|
||||
];
|
||||
|
||||
const manifest = useExportManifest();
|
||||
|
||||
let nav = [
|
||||
{ name: "Mods", path: "/mods" },
|
||||
{ name: "Mod Packs" },
|
||||
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
|
||||
{ name: "Icon Reference" },
|
||||
];
|
||||
return (
|
||||
<PageLayout nav={[{ name: "Icon Reference" }]}>
|
||||
<PageLayout nav={nav}>
|
||||
<PageHeader title="Icon Reference" />
|
||||
<div style={{ width: "80%", margin: "0 auto" }}>
|
||||
{!manifest && (
|
||||
{!modPack?.exportManifest && (
|
||||
<Card>
|
||||
<Text type="secondary">
|
||||
No export data available. Run export-data on an instance to generate icons.
|
||||
@@ -180,7 +120,7 @@ export default function IconReferencePage() {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Text type="secondary">Icons: </Text>
|
||||
<Text type="secondary">Prototypes: </Text>
|
||||
<Text>{totalCount}</Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +128,7 @@ export default function IconReferencePage() {
|
||||
<Card>
|
||||
<Space style={{ marginBottom: 16, display: "flex", alignItems: "center" }}>
|
||||
<Input
|
||||
placeholder="Filter by name or class…"
|
||||
placeholder="Filter by type or name…"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
allowClear
|
||||
@@ -200,13 +140,12 @@ export default function IconReferencePage() {
|
||||
: `${visibleCount} / ${totalCount} icons`}
|
||||
</Text>
|
||||
</Space>
|
||||
{groups.map(({ category, entries }) => {
|
||||
const label = CATEGORY_LABELS[category as Category] ?? category;
|
||||
const expanded = isExpanded(category);
|
||||
{groups.map(({ baseType, entries }) => {
|
||||
const expanded = isExpanded(baseType);
|
||||
return (
|
||||
<div key={category} style={{ marginBottom: 8 }}>
|
||||
<div key={baseType} style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
onClick={() => toggleGroup(category)}
|
||||
onClick={() => toggleGroup(baseType)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -222,13 +161,13 @@ export default function IconReferencePage() {
|
||||
<span style={{ fontSize: 10, color: "#888", width: 12 }}>
|
||||
{expanded ? "▾" : "►"}
|
||||
</span>
|
||||
<Tag style={{ margin: 0 }}>{label}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{baseType}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{entries.length}
|
||||
</Text>
|
||||
</div>
|
||||
{expanded && (
|
||||
<Table<IconEntry>
|
||||
<Table<PrototypeMetadataEntry>
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="key"
|
||||
|
||||
@@ -27,6 +27,7 @@ import PageLayout from "./PageLayout";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
import InputPartialVersion from "./InputPartialVersion";
|
||||
import Link from "./Link";
|
||||
|
||||
const { logger } = lib;
|
||||
const { Text } = Typography;
|
||||
@@ -1334,6 +1335,12 @@ export default function ModPackViewPage() {
|
||||
prototypes={prototypes}
|
||||
locale={locale}
|
||||
/>
|
||||
<Typography.Title level={4}>
|
||||
Inspect exported data
|
||||
</Typography.Title>
|
||||
<Link to={`/mods/mod-packs/${modPackId}/icon-reference`}>
|
||||
Icon reference
|
||||
</Link>
|
||||
</Form>
|
||||
|
||||
<div
|
||||
|
||||
@@ -4,11 +4,21 @@ export { default as notify, notifyErrorHandler } from "./util/notify";
|
||||
export { default as BaseWebPlugin } from "./BaseWebPlugin";
|
||||
export * from "./util/websocket";
|
||||
|
||||
export * from "./model/instance";
|
||||
export * from "./model/host";
|
||||
export * from "./model/locale";
|
||||
export * from "./model/item_metadata";
|
||||
export * from "./model/account";
|
||||
export * from "./model/export_locale";
|
||||
export * from "./model/export_prototype_metadata";
|
||||
export * from "./model/host";
|
||||
export * from "./model/instance";
|
||||
export * from "./model/is_dragging";
|
||||
export * from "./model/mod_pack";
|
||||
export * from "./model/mods";
|
||||
export * from "./model/roles";
|
||||
export * from "./model/saves";
|
||||
export * from "./model/system";
|
||||
export * from "./model/user";
|
||||
|
||||
export * from "./store/export_locale_stores";
|
||||
export * from "./store/export_prototype_metadata_store";
|
||||
|
||||
export { default as ControlContext } from "./components/ControlContext";
|
||||
|
||||
@@ -30,3 +40,5 @@ export { default as SavesList } from "./components/SavesList";
|
||||
export { default as SectionHeader } from "./components/SectionHeader";
|
||||
|
||||
export { default as Link } from "./components/Link";
|
||||
|
||||
export { default as FactorioIcon } from "./components/FactorioIcon";
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ModPack } from "@clusterio/lib";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { exportLocaleStore } from "../store/export_locale_stores";
|
||||
|
||||
|
||||
export function useExportLocale(modPack: ModPack | undefined) {
|
||||
const localePath = modPack?.exportManifest?.assets["locale"];
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback),
|
||||
[modPack?.id, localePath],
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id));
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
let exportManifestCache: lib.ExportManifest|null = null;
|
||||
let loaded = false;
|
||||
export function useExportManifest(): lib.ExportManifest|null {
|
||||
const control = useContext(ControlContext);
|
||||
let [exportManifest, setExportManifest] = useState<lib.ExportManifest|null>(exportManifestCache);
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
let modPack = await control.send(new lib.ModPackGetDefaultRequest());
|
||||
if (modPack.exportManifest) {
|
||||
exportManifestCache = modPack.exportManifest;
|
||||
setExportManifest(exportManifestCache);
|
||||
}
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return exportManifest;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import { ModPack } from "@clusterio/lib";
|
||||
import { exportPrototypeMetadataStore } from "../store/export_prototype_metadata_store";
|
||||
|
||||
|
||||
/**
|
||||
* Load the unified spritesheet metadata and inject CSS rules for every icon.
|
||||
* Required for {@link FactorioIcon}.
|
||||
*/
|
||||
export function useExportPrototypeMetadata(modPack: ModPack | undefined) {
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => exportPrototypeMetadataStore.subscribe(modPack, callback),
|
||||
[modPack]
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => exportPrototypeMetadataStore.getSnapshot(modPack?.id));
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useExportManifest } from "./export_manifest";
|
||||
|
||||
|
||||
type Metadata = {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
category: string;
|
||||
localised_name?: any[];
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const metadataCaches = new Map<string, Map<string, Metadata>>();
|
||||
let cssInjected = false;
|
||||
|
||||
/**
|
||||
* Load the unified spritesheet metadata and inject CSS rules for every icon.
|
||||
* CSS class name: `.{category}-{CSS.escape(name)}`
|
||||
* e.g. `.item-iron-plate`, `.recipe-iron-plate`, `.signal-signal-red`, `.planet-nauvis`
|
||||
*/
|
||||
function useSpriteMetadata(category: string): Map<string, Metadata> {
|
||||
const exportManifest = useExportManifest();
|
||||
const [metadata, setMetadata] = useState<Map<string, Metadata>>(
|
||||
metadataCaches.get(category) ?? new Map()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (
|
||||
!exportManifest
|
||||
|| !exportManifest.assets["metadata"]
|
||||
|| !exportManifest.assets["spritesheet"]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${staticRoot}static/${exportManifest.assets["metadata"]}`);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: [string, Metadata][] = await response.json();
|
||||
|
||||
if (!cssInjected) {
|
||||
const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`;
|
||||
const style = document.createElement("style");
|
||||
document.head.appendChild(style);
|
||||
for (const [key, meta] of data) {
|
||||
const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key;
|
||||
style.sheet!.insertRule(`\
|
||||
.${meta.category}-${CSS.escape(name)} {
|
||||
background-image: url("${sheetUrl}");
|
||||
background-repeat: no-repeat;
|
||||
background-position: -${meta.x}px -${meta.y}px;
|
||||
height: ${meta.size}px;
|
||||
width: ${meta.size}px;
|
||||
}`
|
||||
);
|
||||
}
|
||||
cssInjected = true;
|
||||
}
|
||||
|
||||
const cache = new Map<string, Metadata>();
|
||||
for (const [key, meta] of data) {
|
||||
if (meta.category === category) {
|
||||
const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key;
|
||||
cache.set(name, meta);
|
||||
}
|
||||
}
|
||||
metadataCaches.set(category, cache);
|
||||
setMetadata(cache);
|
||||
}
|
||||
|
||||
if (!metadataCaches.has(category)) {
|
||||
load();
|
||||
}
|
||||
}, [exportManifest]);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function useItemMetadata() { return useSpriteMetadata("item"); }
|
||||
export function useRecipeMetadata() { return useSpriteMetadata("recipe"); }
|
||||
export function useSignalMetadata() { return useSpriteMetadata("signal"); }
|
||||
export function useTechnologyMetadata() { return useSpriteMetadata("technology"); }
|
||||
export function usePlanetMetadata() { return useSpriteMetadata("planet"); }
|
||||
export function useQualityMetadata() { return useSpriteMetadata("quality"); }
|
||||
export function useEntityMetadata() { return useSpriteMetadata("entity"); }
|
||||
@@ -1,29 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useExportManifest } from "./export_manifest";
|
||||
|
||||
|
||||
let localeCache: Map<string, string>|null = null;
|
||||
export function useLocale(): Map<string, string> {
|
||||
let exportManifest = useExportManifest();
|
||||
let [locale, setLocale] = useState<Map<string, string>>(localeCache || new Map());
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!exportManifest?.assets["locale"]) {
|
||||
return;
|
||||
}
|
||||
let response = await fetch(`${staticRoot}static/${exportManifest.assets["locale"]}`);
|
||||
if (response.ok) {
|
||||
let data = await response.json();
|
||||
localeCache = new Map(data);
|
||||
setLocale(localeCache);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localeCache) {
|
||||
load();
|
||||
}
|
||||
}, [exportManifest]);
|
||||
|
||||
return locale;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useContext, useSyncExternalStore } from "react";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
import { defaultModPackStore } from "../store/default_mod_pack_store";
|
||||
|
||||
|
||||
export function useModPack(id?: number) {
|
||||
const [modPacks, synced] = useModPacks();
|
||||
@@ -11,3 +13,11 @@ export function useModPacks() {
|
||||
const subscribe = useCallback((callback: () => void) => control.modPacks.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => control.modPacks.getSnapshot());
|
||||
}
|
||||
|
||||
export function useDefaultModPack() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => defaultModPackStore.subscribe(control, callback), [control],
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => defaultModPackStore.getSnapshot());
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import PluginViewPage from "./components/PluginViewPage";
|
||||
import ErrorPage from "./components/ErrorPage";
|
||||
import IconReferencePage from "./components/IconReferencePage";
|
||||
|
||||
// When adding or editing page paths here the corresponding webRoutes entry
|
||||
// in packages/controller/src/routes.ts also needs to be updated.
|
||||
export const pages: PluginPage[] = [
|
||||
{
|
||||
path: "/controller",
|
||||
@@ -57,6 +59,11 @@ export const pages: PluginPage[] = [
|
||||
sidebarPath: "/mods",
|
||||
content: <ModPackViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/mods/mod-packs/:id/icon-reference",
|
||||
sidebarPath: "/mods",
|
||||
content: <IconReferencePage />,
|
||||
},
|
||||
{
|
||||
path: "/users",
|
||||
sidebarName: "Users",
|
||||
@@ -89,10 +96,6 @@ export const pages: PluginPage[] = [
|
||||
sidebarPath: "/plugins",
|
||||
content: <PluginViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/icon-reference",
|
||||
content: <IconReferencePage />,
|
||||
},
|
||||
{
|
||||
path: "/error",
|
||||
content: <ErrorPage throw error={new Error("Example Error Message")} />,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ModPackGetDefaultRequest, type ModPack, isDeepStrictEqual } from "@clusterio/lib";
|
||||
import type { Control } from "../util/websocket";
|
||||
|
||||
// TODO: replace this super duper expensive method with listening to a default mod pack id event.
|
||||
class DefaultModPackStore {
|
||||
interval: undefined | ReturnType<typeof setInterval>;
|
||||
modPack: undefined | ModPack;
|
||||
callbacks: (() => void)[] = [];
|
||||
|
||||
fetchDefaultModPack(control: Control) {
|
||||
control.send(new ModPackGetDefaultRequest()).then(modPack => {
|
||||
if (!isDeepStrictEqual(this.modPack, modPack)) {
|
||||
this.modPack = modPack;
|
||||
for (const callback of this.callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
if (this.modPack !== undefined) {
|
||||
this.modPack = undefined;
|
||||
for (const callback of this.callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(control: Control, callback: () => void) {
|
||||
if (this.callbacks.length === 0) {
|
||||
const update = () => { this.fetchDefaultModPack(control); };
|
||||
update();
|
||||
this.interval = setInterval(update, 60e3);
|
||||
}
|
||||
this.callbacks.push(callback);
|
||||
return () => {
|
||||
const index = this.callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.callbacks.splice(index, 1);
|
||||
}
|
||||
if (this.callbacks.length === 0) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.modPack;
|
||||
}
|
||||
};
|
||||
export const defaultModPackStore = new DefaultModPackStore();
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ExportLocale, logger } from "@clusterio/lib";
|
||||
import notify from "../util/notify";
|
||||
|
||||
|
||||
interface ExportLocaleCacheEntry {
|
||||
callbacks: (() => void)[];
|
||||
localePath: string | undefined;
|
||||
data: Map<string, string>;
|
||||
}
|
||||
|
||||
class ExportLocaleStore {
|
||||
cache = new Map<number, ExportLocaleCacheEntry>();
|
||||
_emptyMap = new Map();
|
||||
|
||||
async loadLocale(modPackId: number, cacheEntry: ExportLocaleCacheEntry) {
|
||||
if (!cacheEntry.localePath) {
|
||||
cacheEntry.data = new Map();
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let response = await fetch(`${staticRoot}static/${cacheEntry.localePath}`);
|
||||
if (response.ok) {
|
||||
cacheEntry.data = new Map(await response.json() as ExportLocale);
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
notify(
|
||||
`Failed to locale for mod pack ${modPackId}, server returned: ` +
|
||||
`${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(modPackId: number | undefined, localePath: string | undefined, callback: () => void) {
|
||||
if (modPackId === undefined || !localePath) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let cacheEntry = this.cache.get(modPackId);
|
||||
if (!cacheEntry) {
|
||||
cacheEntry = {
|
||||
callbacks: [],
|
||||
localePath: undefined,
|
||||
data: new Map(),
|
||||
};
|
||||
this.cache.set(modPackId, cacheEntry);
|
||||
}
|
||||
if (cacheEntry.localePath !== localePath) {
|
||||
cacheEntry.localePath = localePath;
|
||||
this.loadLocale(modPackId, cacheEntry).catch(logger.error);
|
||||
}
|
||||
cacheEntry.callbacks.push(callback);
|
||||
return () => {
|
||||
const index = cacheEntry.callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
cacheEntry.callbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(modPackId: number | undefined): ReadonlyMap<string, string> {
|
||||
if (modPackId === undefined) {
|
||||
return this._emptyMap;
|
||||
}
|
||||
return this.cache.get(modPackId)?.data ?? this._emptyMap;
|
||||
}
|
||||
}
|
||||
export const exportLocaleStore = new ExportLocaleStore();
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ExportMetadata, ExportMetadataEntry, logger, ModPack } from "@clusterio/lib";
|
||||
import unknownIconUrl from "../images/unknown-item.png";
|
||||
import notify from "../util/notify";
|
||||
|
||||
|
||||
export type PrototypeMetadataEntry = ExportMetadataEntry & { base_type: string };
|
||||
|
||||
interface MetadataCacheEntry {
|
||||
callbacks: (() => void)[],
|
||||
metadataPath: string | undefined,
|
||||
spritesheetPath: string | undefined,
|
||||
metadata: Map<string, Map<string, PrototypeMetadataEntry>>,
|
||||
styleSheet: CSSStyleSheet,
|
||||
}
|
||||
|
||||
class ExportPrototypeMetadataStore {
|
||||
cache = new Map<number, MetadataCacheEntry>();
|
||||
async loadMetadata(modPackId: number, cacheEntry: MetadataCacheEntry) {
|
||||
if (!cacheEntry.metadataPath) {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
cacheEntry.styleSheet.replaceSync("");
|
||||
cacheEntry.metadata = new Map();
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${staticRoot}static/${cacheEntry.metadataPath}`);
|
||||
if (!response.ok) {
|
||||
notify(
|
||||
`Failed to fetch prototype metadata for mod pack ${modPackId}, server returned: ` +
|
||||
`${response.status} ${response.statusText}`
|
||||
);
|
||||
// eslint-disable-next-line node/no-sync
|
||||
cacheEntry.styleSheet.replaceSync("");
|
||||
cacheEntry.metadata = new Map();
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = await response.json() as ExportMetadata;
|
||||
|
||||
const spriteUrl = `${staticRoot}static/${cacheEntry.spritesheetPath}`;
|
||||
const styleContent: string[] = [];
|
||||
styleContent.push(`\
|
||||
.factorio-icon\
|
||||
[data-mod-pack="${modPackId}"] {
|
||||
background-image: url("${spriteUrl}");
|
||||
}`);
|
||||
for (const [baseName, prototypes] of Object.entries(metadata)) {
|
||||
for (const prototype of prototypes) {
|
||||
const icon = prototype.icon;
|
||||
if (!icon) {
|
||||
styleContent.push(`\
|
||||
.factorio-icon\
|
||||
[data-mod-pack="${modPackId}"]\
|
||||
[data-type="${baseName}"]\
|
||||
[data-name="${CSS.escape(prototype.name)}"] {
|
||||
background-image: url("${unknownIconUrl}");
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}`);
|
||||
continue;
|
||||
}
|
||||
styleContent.push(`\
|
||||
.factorio-icon\
|
||||
[data-mod-pack="${modPackId}"]\
|
||||
[data-type="${baseName}"]\
|
||||
[data-name="${CSS.escape(prototype.name)}"] {
|
||||
background-position: -${icon.x}px -${icon.y}px;
|
||||
height: ${icon.size}px;
|
||||
width: ${icon.size}px;
|
||||
}`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line node/no-sync
|
||||
cacheEntry.styleSheet.replaceSync(styleContent.join("\n"));
|
||||
cacheEntry.metadata = new Map(
|
||||
Object.entries(metadata)
|
||||
.map(([baseName, entries]) => [
|
||||
baseName,
|
||||
new Map(
|
||||
entries.map(entry => [entry.name, { base_type: baseName, ...entry }])
|
||||
),
|
||||
])
|
||||
);
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(modPack: ModPack | undefined, callback: () => void) {
|
||||
if (!modPack) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const assets = modPack.exportManifest?.assets;
|
||||
let cacheEntry = this.cache.get(modPack.id);
|
||||
if (!cacheEntry) {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
document.adoptedStyleSheets.push(styleSheet);
|
||||
cacheEntry = {
|
||||
callbacks: [],
|
||||
spritesheetPath: undefined,
|
||||
metadataPath: undefined,
|
||||
metadata: new Map(),
|
||||
styleSheet,
|
||||
};
|
||||
this.cache.set(modPack.id, cacheEntry);
|
||||
}
|
||||
if (
|
||||
cacheEntry.metadataPath !== assets?.metadata
|
||||
|| cacheEntry.spritesheetPath !== assets?.spritesheet
|
||||
) {
|
||||
cacheEntry.metadataPath = assets?.metadata;
|
||||
cacheEntry.spritesheetPath = assets?.spritesheet;
|
||||
this.loadMetadata(modPack.id, cacheEntry).catch(logger.error);
|
||||
}
|
||||
cacheEntry.callbacks.push(callback);
|
||||
return () => {
|
||||
const index = cacheEntry.callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
cacheEntry.callbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(modPackId: number | undefined) {
|
||||
if (modPackId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return this.cache.get(modPackId)?.metadata;
|
||||
}
|
||||
}
|
||||
export const exportPrototypeMetadataStore = new ExportPrototypeMetadataStore();
|
||||
@@ -320,7 +320,14 @@ body {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.factorio-icon.item-unknown-item {
|
||||
.factorio-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* data-mod-pack might not be present, but we need the specificity to win over it if it is */
|
||||
.factorio-icon:is([data-mod-pack],*)[data-unknown="true"] {
|
||||
background: url("./images/unknown-item.png");
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Input, Table, Typography } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import {
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext,
|
||||
notifyErrorHandler, useItemMetadata, useLocale,
|
||||
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon,
|
||||
} from "@clusterio/web_ui";
|
||||
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
|
||||
|
||||
@@ -32,18 +31,25 @@ function useStorage(control: Control) {
|
||||
|
||||
function StoragePage() {
|
||||
let control = useContext(ControlContext);
|
||||
let locale = useLocale();
|
||||
let itemMetadata = useItemMetadata();
|
||||
const modPack = useDefaultModPack();
|
||||
let locale = useExportLocale(modPack);
|
||||
const prototypes = useExportPrototypeMetadata(modPack);
|
||||
let itemMetadata = prototypes?.get("item");
|
||||
let fluidMetadata = prototypes?.get("fluid");
|
||||
let storage = useStorage(control);
|
||||
type ItemFilter = ([name, item]: [string, Item]) => boolean;
|
||||
let [filter, setFilter] = useState<null | ItemFilter>(null);
|
||||
|
||||
function getLocaleName(itemName: string) {
|
||||
let localeName = itemName;
|
||||
let meta = itemMetadata.get(itemName);
|
||||
let meta = itemMetadata?.get(itemName) ?? fluidMetadata?.get(itemName);
|
||||
if (meta && meta.localised_name) {
|
||||
// TODO: implement the locale to name conversion.
|
||||
localeName = locale.get(meta.localised_name[0])!;
|
||||
if (typeof meta.localised_name === "string") {
|
||||
localeName = meta.localised_name;
|
||||
} else {
|
||||
localeName = locale.get(meta.localised_name[0])!;
|
||||
}
|
||||
} else {
|
||||
for (let section of ["item-name", "entity-name", "fluid-name", "equipment-name"]) {
|
||||
let name = locale.get(`${section}.${itemName}`);
|
||||
@@ -81,6 +87,7 @@ function StoragePage() {
|
||||
/>
|
||||
</Paragraph>
|
||||
<Table
|
||||
className="subspace-storage-storage"
|
||||
columns={[
|
||||
{
|
||||
title: "Resource",
|
||||
@@ -94,10 +101,11 @@ function StoragePage() {
|
||||
},
|
||||
render: (_, item) => {
|
||||
let localeName = getLocaleName(item[1].name);
|
||||
let hasMeta = itemMetadata.get(item[1].name);
|
||||
let name = item[1].name;
|
||||
const prototype = itemMetadata?.get(name) ?? fluidMetadata?.get(name);
|
||||
|
||||
return <>
|
||||
<span className={`factorio-icon item-${hasMeta ? item[1].name : "unknown-item"}`}/>
|
||||
<FactorioIcon modPackId={modPack?.id} prototype={prototype} />
|
||||
{localeName}
|
||||
</>;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
.factorio-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
.subspace-storage-storage .factorio-icon {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
Reference in new issue
Block a user