UNPKG

isaacscript

Version:

A command line tool for managing Isaac mods written in TypeScript.

312 lines 15.7 kB
import chalk from "chalk"; import { parseFloatSafe, parseIntSafe } from "complete-common"; import { copyFileOrDirectory, fatalError, getFileNamesInDirectory, getPackageManagerAddCommand, isDirectory, isFile, makeDirectory, readFile, writeFile, } from "complete-node"; import path from "node:path"; import xml2js from "xml2js"; import { getJSONRoomDoorSlotFlags } from "./common.js"; import { CUSTOM_STAGE_FILES_DIR, CWD, MOD_SOURCE_PATH, SHADERS_XML_PATH, XML_CONVERTER_PATH, } from "./constants.js"; import { execExe } from "./exec.js"; import { getCustomStagesFromTSConfig } from "./tsconfig.js"; const ISAACSCRIPT_COMMON = "isaacscript-common"; const ISAACSCRIPT_COMMON_PATH = path.join(CWD, "node_modules", ISAACSCRIPT_COMMON, "dist"); const METADATA_LUA_PATH = path.join(ISAACSCRIPT_COMMON_PATH, "customStageMetadata.lua"); const CUSTOM_STAGE_METADATA_JSON_PATH = path.resolve(CWD, "..", "isaacscript", "packages", "isaacscript-common", "src", "customStageMetadata.json"); const ROOM_VARIANT_MULTIPLIER = 10_000; const VARIANT_REGEX = / variant="(?<variant>\d+)"/; const WEIGHT_REGEX = / weight=".+?"/; const EMPTY_SHADER_NAME = "IsaacScript-RenderAboveHUD"; export async function prepareCustomStages(packageManager, isaacScriptCommonDev, verbose) { const customStagesTSConfig = await getCustomStagesFromTSConfig(); if (customStagesTSConfig.length === 0) { return; } validateCustomStagePaths(customStagesTSConfig); copyCustomStageFilesToProject(); await insertEmptyShader(); await fillCustomStageMetadata(customStagesTSConfig, packageManager, isaacScriptCommonDev); combineCustomStageXMLs(customStagesTSConfig, verbose); } /** * Before we proceed with compiling the mod, ensure that all of the file paths that the end-user put * in their "tsconfig.json" file map to actual files on the file system. */ function validateCustomStagePaths(customStagesTSConfig) { for (const customStageTSConfig of customStagesTSConfig) { if (customStageTSConfig.backdropPNGPaths !== undefined) { for (const filePaths of Object.values(customStageTSConfig.backdropPNGPaths)) { for (const filePath of filePaths) { checkFile(filePath); } } } for (const filePath of [ customStageTSConfig.decorationsPNGPath, customStageTSConfig.decorationsANM2Path, customStageTSConfig.rocksPNGPath, customStageTSConfig.rocksANM2Path, customStageTSConfig.pitsPNGPath, customStageTSConfig.pitsANM2Path, ]) { checkFile(filePath); } if (customStageTSConfig.doorPNGPaths !== undefined) { for (const filePath of Object.values(customStageTSConfig.doorPNGPaths)) { checkFile(filePath); } } if (customStageTSConfig.shadows !== undefined) { for (const stageShadows of Object.values(customStageTSConfig.shadows)) { for (const stageShadow of stageShadows) { checkFile(stageShadow.pngPath); } } } if (customStageTSConfig.bossPool !== undefined) { for (const bossPoolEntry of Object.values(customStageTSConfig.bossPool)) { if (bossPoolEntry.versusScreen !== undefined) { checkFile(bossPoolEntry.versusScreen.namePNGPath); checkFile(bossPoolEntry.versusScreen.portraitPNGPath); } } } } } function checkFile(filePath) { if (filePath === undefined) { return; } if (!filePath.includes("gfx/")) { fatalError(`Failed to validate the "${filePath}" file: all PNG file paths must be inside of a "gfx" directory. (e.g. "./mod/resources/gfx/backdrop/foo/nfloor.png")`); } if (!isFile(filePath)) { fatalError(`Failed to find the "${filePath}" file. Check your "tsconfig.json" file and then restart IsaacScript.`); } } /** The custom stages feature needs some anm2 files in order to work properly. */ function copyCustomStageFilesToProject() { const dstDirPath = path.join(CWD, "mod", "resources", "gfx", "isaacscript-custom-stage"); makeDirectory(dstDirPath); const fileNames = getFileNamesInDirectory(CUSTOM_STAGE_FILES_DIR); for (const fileName of fileNames) { const srcPath = path.join(CUSTOM_STAGE_FILES_DIR, fileName); const dstPath = path.join(dstDirPath, fileName); copyFileOrDirectory(srcPath, dstPath); } } /** * The custom stage feature requires an empty shader to be present in order to render sprites on top * of the HUD. */ async function insertEmptyShader() { const shadersXMLDstPath = path.join(CWD, "mod", "content", "shaders.xml"); if (!isFile(shadersXMLDstPath)) { copyFileOrDirectory(SHADERS_XML_PATH, shadersXMLDstPath); return; } // The end-user mod might have their own custom shaders, so we need to merge our empty shader // inside the existing "shaders.xml" file. const shadersXMLDstContents = readFile(shadersXMLDstPath); const shadersXMLDst = (await xml2js.parseStringPromise(shadersXMLDstContents)); const hasIsaacScriptEmptyShader = shadersXMLDst.shaders.shader.some((shader) => shader.$.name === EMPTY_SHADER_NAME); if (hasIsaacScriptEmptyShader) { // Our empty shader already exists, so we don't have to do anything. return; } const shadersXMLSrcContents = readFile(SHADERS_XML_PATH); const shadersXMLSrc = (await xml2js.parseStringPromise(shadersXMLSrcContents)); const isaacScriptEmptyShader = shadersXMLSrc.shaders.shader.find((shader) => shader.$.name === EMPTY_SHADER_NAME); if (isaacScriptEmptyShader === undefined) { fatalError(`Failed to find the empty shader named "${EMPTY_SHADER_NAME}" in the following file: ${SHADERS_XML_PATH}`); } // Add the empty shader to the mod's existing "shaders.xml" file. shadersXMLDst.shaders.shader.push(isaacScriptEmptyShader); const xmlBuilder = new xml2js.Builder(); const newXML = xmlBuilder.buildObject(shadersXMLDst); writeFile(shadersXMLDstPath, newXML); } /** * In order for the custom stage feature to work properly, Lua code will need to know the list of * possible rooms corresponding to a custom stage. In an end-user mod, this is a Lua file located at * `METADATA_LUA_PATH`. By default, the file is blank, and must be filled in by tooling before * compiling the mod. */ async function fillCustomStageMetadata(customStagesTSConfig, packageManager, isaacScriptCommonDev) { validateMetadataLuaFileExists(packageManager); const customStages = await getCustomStagesWithMetadata(customStagesTSConfig); const customStagesLua = await convertCustomStagesToLua(customStages); writeFile(METADATA_LUA_PATH, customStagesLua); console.log(`Wrote metadata for ${customStagesLua.length} custom stage room(s) to: ${METADATA_LUA_PATH}`); // In development, the "isaacscript-common" directory will be recompiled. Since the // "customStageMetadata.json" file is normally left blank, it will overwrite any changes made to // the compiled "customStageMetadata.lua" file. Thus, we also must write the custom stage data to // the "customStageMetadata.json" file. if (isaacScriptCommonDev === true) { const customStagesJSON = JSON.stringify(customStages, undefined, 2); writeFile(CUSTOM_STAGE_METADATA_JSON_PATH, customStagesJSON); console.log(`Wrote metadata for ${customStagesLua.length} custom stage rooms to: ${CUSTOM_STAGE_METADATA_JSON_PATH}`); } } function validateMetadataLuaFileExists(packageManager) { if (!isDirectory(ISAACSCRIPT_COMMON_PATH)) { const addCommand = getPackageManagerAddCommand(packageManager, ISAACSCRIPT_COMMON); fatalError(`The custom stages feature requires a dependency of "${ISAACSCRIPT_COMMON}" in the "package.json" file. You can add it with the following command:\n${chalk.green(addCommand)}`); } if (!isFile(METADATA_LUA_PATH)) { fatalError(`${chalk.red("Failed to find the custom stage metadata file at:")} ${chalk.red(METADATA_LUA_PATH)}`); } } /** * This parses all of the end-user's XML files and gathers metadata about all of the rooms within. * (In other words, this creates the full set of `CustomStageLua` objects.) */ async function getCustomStagesWithMetadata(customStagesTSConfig) { if (!isFile(METADATA_LUA_PATH)) { fatalError(`${chalk.red("Failed to find the custom stage metadata file at:")} ${chalk.red(METADATA_LUA_PATH)}`); } const customStagesLua = []; for (const customStageTSConfig of customStagesTSConfig) { // Some manual input validation was already performed in the `getCustomStagesFromTSConfig` // function. const { name } = customStageTSConfig; const { xmlPath } = customStageTSConfig; const resolvedXMLPath = path.resolve(CWD, xmlPath); if (!isFile(resolvedXMLPath)) { fatalError(`${chalk.red("Failed to find the custom stage XML file at:")} ${chalk.red(resolvedXMLPath)}`); } const xmlContents = readFile(resolvedXMLPath); // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-unsafe-assignment const jsonRoomsFileAny = await xml2js.parseStringPromise(xmlContents); const jsonRoomsFile = jsonRoomsFileAny; const roomVariantSet = new Set(); const customStageRoomsMetadata = []; for (const room of jsonRoomsFile.rooms.room) { const typeString = room.$.type; const type = parseIntSafe(typeString); if (type === undefined) { fatalError(`Failed to parse the type of one of the "${name}" custom stage rooms: ${typeString}`); } const variantString = room.$.variant; const baseVariant = parseIntSafe(variantString); if (baseVariant === undefined) { fatalError(`Failed to parse the variant of one of the "${name}" custom stage rooms: ${variantString}`); } if (roomVariantSet.has(baseVariant)) { fatalError(chalk.red(`There is more than one room with a variant of "${baseVariant}" in the "${resolvedXMLPath}" file. Make sure that each room has a unique variant. (The room variant is also called the "ID" in Basement Renovator.)`)); } roomVariantSet.add(baseVariant); const roomVariantPrefix = customStageTSConfig.roomVariantPrefix * ROOM_VARIANT_MULTIPLIER; const variant = roomVariantPrefix + baseVariant; const subTypeString = room.$.subtype; const subType = parseIntSafe(subTypeString); if (subType === undefined) { fatalError(`Failed to parse the sub-type of one of the "${name}" custom stage rooms: ${subTypeString}`); } const shapeString = room.$.shape; const shape = parseIntSafe(shapeString); if (shape === undefined) { fatalError(`Failed to parse the shape of one of the "${name}" custom stage rooms: ${shapeString}`); } const doorSlotFlags = getJSONRoomDoorSlotFlags(room); const weightString = room.$.weight; const weight = parseFloatSafe(weightString); if (weight === undefined) { fatalError(`Failed to parse the weight of one of the "${name}" custom stage rooms: ${weightString}`); } const customStageRoomMetadata = { type, variant, subType, shape, doorSlotFlags, weight, }; customStageRoomsMetadata.push(customStageRoomMetadata); } const customStageLua = { ...customStageTSConfig, roomsMetadata: customStageRoomsMetadata, }; customStagesLua.push(customStageLua); } return customStagesLua; } async function convertCustomStagesToLua(customStages) { // We perform a dynamic import to prevent non-TSTL projects from having to have TSTL as a // dependency if they use the IsaacScript CLI. ("typescript-to-lua" is listed as an optional peer // dependency in this project's "package.json".) const tstl = await import("typescript-to-lua"); const customStagesString = JSON.stringify(customStages); const fakeTypeScriptFile = `return ${customStagesString}`; const result = tstl.transpileString(fakeTypeScriptFile, { noHeader: true, }); if (result.file === undefined || result.file.lua === undefined) { fatalError("Failed to convert the JSON metadata for the custom stages to a Lua file."); } return result.file.lua; } /** We combine all of the custom stages together and add them to "00.special rooms.xml". */ function combineCustomStageXMLs(customStagesTSConfig, verbose) { let allRooms = ""; for (const customStageTSConfig of customStagesTSConfig) { const xmlPath = path.resolve(CWD, customStageTSConfig.xmlPath); if (!isFile(xmlPath)) { fatalError(`${chalk.red("Failed to find the custom stage XML file at:")} ${chalk.red(xmlPath)}`); } const xmlContents = readFile(xmlPath); // It is easier to work with the XML files as text rather than converting it to JSON and then // converting it back to XML. const lines = xmlContents.trim().split("\n"); // Remove the first line of "<?xml version="1.0" ?>". lines.shift(); // Remove the second line of "<rooms>". lines.shift(); // Remove the last line of "</rooms>". lines.pop(); // Change the variants for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line === undefined) { continue; } if (!line.includes("<room")) { continue; } const match = line.match(VARIANT_REGEX); if (match === null || match.groups === undefined) { continue; } const baseVariantString = match.groups["variant"]; if (baseVariantString === undefined) { continue; } const baseVariant = parseIntSafe(baseVariantString); if (baseVariant === undefined) { fatalError(`Failed to parse the variant of one of the custom stage rooms: ${baseVariantString}`); } const roomVariantPrefix = customStageTSConfig.roomVariantPrefix * ROOM_VARIANT_MULTIPLIER; const variant = roomVariantPrefix + baseVariant; const newLine = line .replace(VARIANT_REGEX, ` variant="${variant}"`) .replace(WEIGHT_REGEX, ' weight="0.0"'); lines[i] = newLine; } const modifiedRooms = lines.join("\n"); allRooms += modifiedRooms; } const combinedXMLFile = ` <?xml version="1.0" ?> <rooms> ${allRooms} </rooms> `.trim(); const contentRoomsDir = path.join(MOD_SOURCE_PATH, "content", "rooms"); if (!isFile(contentRoomsDir)) { makeDirectory(contentRoomsDir); } const specialRoomsXMLPath = path.join(contentRoomsDir, "00.special rooms.xml"); writeFile(specialRoomsXMLPath, combinedXMLFile); // Convert the XML file to an STB file, which is the format actually read by the game. execExe(XML_CONVERTER_PATH, [specialRoomsXMLPath], verbose, contentRoomsDir); } //# sourceMappingURL=customStage.js.map