From d2d911f1b45e9038af769cfa3a46ff62d1bc4ab9 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 15 Jul 2023 23:16:14 +0200 Subject: [PATCH] refactor: assets.ts, assets/index.js --- .gitignore | 6 +- assets/README.md | 39 ++++++++ package-lock.json | 7 -- package.json | 1 - src/assets.ts | 228 ++++++++++++++++++++++++++++------------------ webpack.config.js | 32 +++++++ 6 files changed, 216 insertions(+), 97 deletions(-) create mode 100644 assets/README.md diff --git a/.gitignore b/.gitignore index 4b3b6e92e..63080f50d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ ### Ancient Beast ### # Generated files deploy/ -# List of assets -src/assets.js + +# Generated list of assets +assets/index.js + # Dotenv .env diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 000000000..acc8ed0e5 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,39 @@ +# assets/autoload/phaser + +Images placed anywhere under assets/autoload/phaser will be loaded into Phaser when the game begins. + +## autoloaded images: Phaser keys + +Phaser requires a 'key' to access images, e.g., + +``` +this.display.loadTexture("myKey"); +``` + +When using an autoloaded image, the basename of the file will be used as the key in Phaser. + +### Example + +If /assets/autoload/phaser/apples/apple1.png is a valid path, it will be loaded **for you** into Phaser as follows: + +``` +// NOTE: This is done FOR YOU when using autoloading. +phaser.load.image('apple1', '/assets/autoload/phaser/apple/apple1.png'); +``` + +The basename of the file - "apple1" - is the Phaser key, so you'll use the image like this: + +``` +this.display.loadTexture("apple1"); +``` + +### Troubleshooting + +Because the file basenames are used as Phaser keys, all basenames under assets/autoload/phaser must be unique. + +For example, including these two images in the project will cause an error to be thrown, because they have the same basename. + +``` +/assets/autoload/phaser/monster/scary.png +/assets/autoload/phaser/head/hair/morning/scary.png +``` diff --git a/package-lock.json b/package-lock.json index 24eca58a0..6def59163 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "@babel/preset-typescript": "^7.21.4", "@types/jest": "^29.5.0", "@types/jquery": "^3.5.9", - "@types/webpack-env": "^1.18.1", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", "babel-jest": "^29.5.0", @@ -3249,12 +3248,6 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, - "node_modules/@types/webpack-env": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.1.tgz", - "integrity": "sha512-D0HJET2/UY6k9L6y3f5BL+IDxZmPkYmPT4+qBrRdmRLYRuV0qNKizMgTvYxXZYn+36zjPeoDZAEYBCM6XB+gww==", - "dev": true - }, "node_modules/@types/ws": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", diff --git a/package.json b/package.json index 07e2ab37a..f9bb9fcbb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@babel/preset-typescript": "^7.21.4", "@types/jest": "^29.5.0", "@types/jquery": "^3.5.9", - "@types/webpack-env": "^1.18.1", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", "babel-jest": "^29.5.0", diff --git a/src/assets.ts b/src/assets.ts index 6be4041a0..91a87573a 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -1,16 +1,98 @@ +import { DEBUG } from './debug'; +import { phaserAutoloadAssetPaths, assetPaths } from '../assets/index'; + /** - * TODO: Simplify - * - * Ancient Beast used to use a custom script to list assets as part of the build process: - * https://github.com/FreezingMoon/AncientBeast/blob/76692ad039d7a0530fa30ceef367bb0ef68c5015/assetLister.js + * Load all assets in phaserAutoloadAssetsPaths into Phaser Game instance, using URL basename as Phaser key. * - * But Webpack can list assets using require.context, so we're using it here instead. + * @param {Phaser.Game} e.g., units/shouts/Chimera + * @returns {void} + * @throws Throws an error if two files have the same basename. + */ +export function use(phaser: Phaser.Game): void { + // NOTE: currently only accepts images. + const urls = Object.values(phaserAutoloadAssetPaths); + + const hasDuplicateBasenames = new Set(urls.map((url) => getBasename(url))).size < urls.length; + if (DEBUG && hasDuplicateBasenames) throwDuplicateBasenamesError(); + + for (const url of urls) { + phaser.load.image(getBasename(url), url); + } +} + +/** + * Extract basename from a file path. * - * The assetLister was removed, but its format is duplicated here for compatibility reasons. - * It should probably be rethought and simplified. + * @param {string} path a file path + * @returns {string} the basename from the path + * @example getBasename('./assets/a/b/myFile.png') === 'myFile' */ +function getBasename(path: string): string { + const base = new String(path).substring(path.lastIndexOf('/') + 1); + let i = base.lastIndexOf('.'); + if (base.lastIndexOf('.') === -1) { + return base; + } + while (i > 0 && base[i] === '.') { + i--; + } + return base.substring(0, i + 1); +} + +function throwDuplicateBasenamesError() { + // NOTE: Throw a useful message listing duplicate basenames. + const paths = assetPaths + .map(({ path }) => path) + .filter((path) => path.indexOf('/autoload/phaser/') !== -1); + + const basenameToPaths: { [basename: string]: string[] } = paths.reduce((obj, path) => { + const bn = getBasename(path); + if (obj.hasOwnProperty(bn)) { + obj[bn].push(path); + } else { + obj[bn] = [path]; + } + return obj; + }, {}); + + const duplicates: { [basename: string]: string[] } = Object.entries(basenameToPaths).reduce( + (obj, [basename, paths]) => { + if (paths.length > 1) { + obj[basename] = paths; + } + return obj; + }, + {}, + ); + + const formatted = []; + for (const [basename, paths] of Object.entries(duplicates)) { + for (const path of paths) { + formatted.push(basename + '\t' + path); + } + } + + throw new Error(`[Ancient Beast] +Some files under assets/autoload/phaser/ have the same basename. +Basenames are used as keys by Phaser and must be unique. +Please make each basename unique. + +Duplicate basenames: +${formatted.join('\n')} +`); +} /** + * Legacy asset system + * ///////////////////////////////////////////////////////////////////////////////////////////////// + * + * TODO: Simplify legacy assets + * + * Ancient Beast used to use a custom asset list format in `assetLister`. + * assetLister.ts was removed, but its format is duplicated here for compatibility reasons. + * It should probably be rethought and simplified. + * + * * NOTE: Assemble the legacy Assets format. * * For theses local paths ... @@ -61,61 +143,34 @@ */ type AssetEntry = { id: string; url?: string; children?: AssetEntry[] }; -const dirs: AssetEntry[] = []; -const urls: { [key: string]: string } = {}; - -{ - const importAll = (require: __WebpackModuleApi.RequireContext) => - require.keys().map((localPath) => ({ localPath, url: require(localPath) })); - - const localPaths_urls = importAll(require.context('../assets', true)); - - { - // NOTE: Add entries to URLs. - /** - * Receives the "local path" and returns the "key". - * E.g., getKey('./units/sprites/Gumble - Royal Seal.png') === 'units/sprites/Gumble - Royal Seal; - */ - const getKey = (localPath: string): string => { - const parts = localPath.split('/'); - parts.shift(); - const filename = parts.pop().split('.'); - filename.pop(); - parts.push(filename.join('.')); - return parts.join('/'); - }; - - for (const { localPath, url } of localPaths_urls) { - urls[getKey(localPath)] = url; - } - } - { - // NOTE: Add entries to dirs. - for (const { localPath, url } of localPaths_urls) { - const parts = localPath.split('/'); - parts.shift(); - - const id = parts[parts.length - 1].split('.')[0]; - - const directories = [...parts]; - directories.pop(); - - let currDir = dirs; - for (const dir of directories) { - const matches = currDir.filter((entry) => entry.id === dir); - if (matches.length && matches[0].children) { - currDir = matches[0].children; - } else { - const entry = { id: dir, children: [] }; - currDir.push(entry); - currDir = entry.children; - } +const dirs: AssetEntry[] = (() => { + // NOTE: Add entries to dirs. + const result = []; + for (const [path, url] of Object.entries(assetPaths)) { + const parts = path.split('/'); + parts.shift(); + + const id = parts[parts.length - 1].split('.')[0]; + + const directories = [...parts]; + directories.pop(); + + let currDir = result; + for (const dir of directories) { + const matches = currDir.filter((entry) => entry.id === dir); + if (matches.length && matches[0].children) { + currDir = matches[0].children; + } else { + const entry = { id: dir, children: [] }; + currDir.push(entry); + currDir = entry.children; } - currDir.push({ id, url }); } + currDir.push({ id, url }); } -} + return result; +})(); function getAssetEntry(pathStr: string): string | AssetEntry[] { // Convert path to an array if it is a string @@ -147,18 +202,6 @@ function getAssetEntry(pathStr: string): string | AssetEntry[] { return result; } -/** - * Accepts a key and returns the absolute path to the resource. - * - * @param key {string} e.g., units/shouts/Chimera - * @returns {string} e.g., http://0.0.0.0:8080/assets/units/shouts/Chimera..ogg - * @throws Throws an error if the key is not found. - */ -export function getUrl(key: string): string { - if (urls.hasOwnProperty(key)) return urls[key]; - throw new Error('assets.getUrl(key) is not available for the key: ' + key); -} - const dirCache: Record = {}; /** @@ -193,24 +236,35 @@ export function getDirectory(path: string): AssetEntry[] { return entry; } -export function use(phaser: Phaser.Game) { - const importAll = (require: __WebpackModuleApi.RequireContext) => - require.keys().map((localPath) => require(localPath)); - - const basename = (path: string) => { - const base = new String(path).substring(path.lastIndexOf('/') + 1); - let i = base.lastIndexOf('.'); - if (base.lastIndexOf('.') === -1) { - return base; - } - while (i > 0 && base[i] === '.') { - i--; - } - return base.substring(0, i + 1); +const urls: { [key: string]: string } = (() => { + /** + * Receives the "local path" and returns the "key". + * E.g., getKey('./units/sprites/Gumble - Royal Seal.png') === 'units/sprites/Gumble - Royal Seal; + */ + const getKey = (path: string): string => { + const parts = path.split('/'); + parts.shift(); + const filename = parts.pop().split('.'); + filename.pop(); + parts.push(filename.join('.')); + return parts.join('/'); }; - const urls = importAll(require.context('../assets/autoload/phaser', true)); - for (const url of urls) { - phaser.load.image(basename(url), url); + const result = {}; + for (const [path, url] of Object.entries(assetPaths)) { + result[getKey(path)] = url; } + return result; +})(); + +/** + * Accepts a key and returns the absolute path to the resource. + * + * @param key {string} e.g., units/shouts/Chimera + * @returns {string} e.g., http://0.0.0.0:8080/deploy/assets/0acb67b5fb51207b6b23..ogg + * @throws Throws an error if the key is not found. + */ +export function getUrl(key: string): string { + if (urls.hasOwnProperty(key)) return urls[key]; + throw new Error('assets.getUrl(key) is not available for the key: ' + key); } diff --git a/webpack.config.js b/webpack.config.js index b1ba1e573..201105765 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,6 +15,38 @@ const pixi = path.join(phaserModule, 'build/custom/pixi.js'); const p2 = path.join(phaserModule, 'build/custom/p2.js'); const Dotenv = require('dotenv-webpack'); +{ + // NOTE: Generate asset lists. + // Was using Webpack's require.context to generate asset lists, but + // it appears to have a memory leak that causes the dev server to crash after + // a few consecutive builds. + const fs = require('fs'); + const glob = require('glob'); + const toObjString = (paths) => + '{' + paths.map((path) => `"${path}":require("${path}")`).join(',') + '}'; + const globOptions = { ignore: ['**/*.js', '**/*.ts', '**/*.md'], posix: true }; + const phaserAutoloadAssets = toObjString(glob.sync('assets/autoload/phaser/**/*.*', globOptions)); + const allAssets = toObjString(glob.sync('assets/**/*.*', globOptions)); + + fs.writeFileSync( + 'assets/index.js', + `// NOTE: Generated at build time by ${path.basename(__filename)}. +// Do not add to this file. +// Any changes to this file will be overwritten when the project is rebuilt. +// --------------------------------------------------------------------------- +// Webpack's require.context would make this unnecessary, +// however, it has performance issues https://github.com/webpack/webpack/issues/13636 +// and it appears to have a memory leak that makes the webpack dev server crash after a few builds. +// require.context was last tested July 15, 2023. + +export const phaserAutoloadAssetPaths=${phaserAutoloadAssets} + +export const assetPaths=${allAssets} + +`, + ); +} + // Expose mode argument to unify our config options module.exports = (env, argv) => { const production = (argv && argv.mode === 'production') || process.env.NODE_ENV === 'production';