|
import { promises as fs, type Dirent } from "node:fs"; |
|
import path from "node:path"; |
|
import { PNG } from "pngjs"; |
|
import ts from "typescript"; |
|
import { normalizePath, type Plugin, type ViteDevServer } from "vite"; |
|
import { packSprites } from "./packer"; |
|
|
|
const VIRTUAL_MANIFEST_ID = "virtual:kaplay-sprite-atlas-manifest"; |
|
const RESOLVED_VIRTUAL_MANIFEST_ID = `\0${VIRTUAL_MANIFEST_ID}`; |
|
const DEFAULT_SCAN_DIR = "src"; |
|
const DEFAULT_SPRITE_DIR = "sprites"; |
|
const DEFAULT_CACHE_DIR = ".kaplay-sprite-atlas"; |
|
const DEFAULT_OUTPUT_DIR = "generated/kaplay-sprite-atlas"; |
|
const DEFAULT_ATLAS_SIZE = 2048; |
|
const OUTPUT_PREFIX = `/${DEFAULT_OUTPUT_DIR}/`; |
|
|
|
type SpriteSource = { |
|
normalizedSrc: string; |
|
absPath: string; |
|
}; |
|
|
|
type SourceImage = { |
|
key: string; |
|
normalizedSrc: string; |
|
image: PNG; |
|
}; |
|
|
|
type GeneratedFile = { |
|
fileName: string; |
|
source: Uint8Array | string; |
|
}; |
|
|
|
type ManifestEntry = { |
|
image: string; |
|
data: string; |
|
key: string; |
|
}; |
|
|
|
export interface KaplaySpriteAtlasPluginOptions { |
|
atlasSize?: number; |
|
} |
|
|
|
const isCodeFile = (filePath: string) => /\.[cm]?[jt]sx?$/.test(filePath); |
|
const hasRegexRegistration = (sourceText: string) => |
|
sourceText.includes("includeAtlasSpriteSourcesRegex("); |
|
|
|
const getScriptKind = (filePath: string) => { |
|
if (filePath.endsWith(".tsx")) { |
|
return ts.ScriptKind.TSX; |
|
} |
|
if (filePath.endsWith(".jsx")) { |
|
return ts.ScriptKind.JSX; |
|
} |
|
if ( |
|
filePath.endsWith(".ts") || |
|
filePath.endsWith(".mts") || |
|
filePath.endsWith(".cts") |
|
) { |
|
return ts.ScriptKind.TS; |
|
} |
|
return ts.ScriptKind.JS; |
|
}; |
|
|
|
const normalizeSpriteSource = (src: string) => |
|
normalizePath(src).replace(/^\/+/, "").replace(/^\.\//, ""); |
|
|
|
const joinRuntimePath = (fileName: string) => |
|
normalizePath(path.posix.join(DEFAULT_OUTPUT_DIR, fileName)); |
|
|
|
const walkFiles = async (dirPath: string): Promise<string[]> => { |
|
let entries: Dirent[]; |
|
|
|
try { |
|
entries = await fs.readdir(dirPath, { withFileTypes: true }); |
|
} catch (error) { |
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") { |
|
return []; |
|
} |
|
throw error; |
|
} |
|
|
|
const files = await Promise.all( |
|
entries.map(async (entry) => { |
|
const absPath = path.join(dirPath, entry.name); |
|
|
|
if (entry.isDirectory()) { |
|
return walkFiles(absPath); |
|
} |
|
|
|
return [absPath]; |
|
}), |
|
); |
|
|
|
return files.flat(); |
|
}; |
|
|
|
const unwrapExpression = (node: ts.Expression): ts.Expression => { |
|
let current = node; |
|
|
|
while ( |
|
ts.isAsExpression(current) || |
|
ts.isParenthesizedExpression(current) || |
|
ts.isSatisfiesExpression(current) || |
|
ts.isTypeAssertionExpression(current) |
|
) { |
|
current = current.expression; |
|
} |
|
|
|
return current; |
|
}; |
|
|
|
const getLiteralString = (node: ts.Expression): string | null => { |
|
const current = unwrapExpression(node); |
|
|
|
if ( |
|
ts.isStringLiteral(current) || |
|
ts.isNoSubstitutionTemplateLiteral(current) |
|
) { |
|
return current.text; |
|
} |
|
|
|
return null; |
|
}; |
|
|
|
const getLiteralRegExp = ( |
|
node: ts.Expression, |
|
sourceFile: ts.SourceFile, |
|
): RegExp | null => { |
|
const current = unwrapExpression(node); |
|
|
|
if (!ts.isRegularExpressionLiteral(current)) { |
|
return null; |
|
} |
|
|
|
const literal = current.getText(sourceFile); |
|
const bodyEnd = literal.lastIndexOf("/"); |
|
|
|
return new RegExp(literal.slice(1, bodyEnd), literal.slice(bodyEnd + 1)); |
|
}; |
|
|
|
const isPackableSpriteSource = (src: string) => { |
|
if (!src.endsWith(".png")) { |
|
return false; |
|
} |
|
|
|
return src.startsWith(`${DEFAULT_SPRITE_DIR}/`); |
|
}; |
|
|
|
const collectStaticSpriteLoads = ( |
|
sourceText: string, |
|
filePath: string, |
|
absPublicDir: string, |
|
availableSpriteSources: SpriteSource[], |
|
): SpriteSource[] => { |
|
const sourceFile = ts.createSourceFile( |
|
filePath, |
|
sourceText, |
|
ts.ScriptTarget.Latest, |
|
true, |
|
getScriptKind(filePath), |
|
); |
|
const loads: SpriteSource[] = []; |
|
|
|
const visit = (node: ts.Node) => { |
|
if ( |
|
ts.isCallExpression(node) && |
|
ts.isIdentifier(node.expression) && |
|
node.expression.text === "includeAtlasSpriteSources" |
|
) { |
|
node.arguments.forEach((argument) => { |
|
const src = getLiteralString(argument); |
|
|
|
if (!src) { |
|
return; |
|
} |
|
|
|
const normalizedSrc = normalizeSpriteSource(src); |
|
|
|
if (isPackableSpriteSource(normalizedSrc)) { |
|
loads.push({ |
|
normalizedSrc, |
|
absPath: normalizePath(path.resolve(absPublicDir, normalizedSrc)), |
|
}); |
|
} |
|
}); |
|
} |
|
|
|
if ( |
|
ts.isCallExpression(node) && |
|
ts.isIdentifier(node.expression) && |
|
node.expression.text === "includeAtlasSpriteSourcesRegex" |
|
) { |
|
node.arguments.forEach((argument) => { |
|
const regex = getLiteralRegExp(argument, sourceFile); |
|
|
|
if (!regex) { |
|
return; |
|
} |
|
|
|
availableSpriteSources.forEach((source) => { |
|
regex.lastIndex = 0; |
|
|
|
if (!regex.test(source.normalizedSrc)) { |
|
return; |
|
} |
|
|
|
loads.push({ |
|
normalizedSrc: source.normalizedSrc, |
|
absPath: source.absPath, |
|
}); |
|
}); |
|
}); |
|
} |
|
|
|
if ( |
|
ts.isCallExpression(node) && |
|
ts.isPropertyAccessExpression(node.expression) && |
|
node.expression.name.text === "loadSprite" && |
|
node.arguments.length >= 2 |
|
) { |
|
const src = getLiteralString(node.arguments[1]); |
|
|
|
if (src) { |
|
const normalizedSrc = normalizeSpriteSource(src); |
|
|
|
if (isPackableSpriteSource(normalizedSrc)) { |
|
loads.push({ |
|
normalizedSrc, |
|
absPath: normalizePath(path.resolve(absPublicDir, normalizedSrc)), |
|
}); |
|
} |
|
} |
|
} |
|
|
|
ts.forEachChild(node, visit); |
|
}; |
|
|
|
visit(sourceFile); |
|
|
|
return loads; |
|
}; |
|
|
|
const getUniqueSortedSources = (loads: SpriteSource[]) => |
|
[...new Set(loads.map((load) => load.normalizedSrc))].sort((left, right) => |
|
left.localeCompare(right), |
|
); |
|
|
|
const areSourceListsEqual = ( |
|
left: readonly string[], |
|
right: readonly string[], |
|
) => left.length === right.length && left.every((source, index) => source === right[index]); |
|
|
|
const readAvailableSpriteSources = async ( |
|
absPublicDir: string, |
|
absSpriteDir: string, |
|
): Promise<SpriteSource[]> => |
|
(await walkFiles(absSpriteDir)) |
|
.filter((filePath) => filePath.endsWith(".png")) |
|
.map((filePath) => ({ |
|
normalizedSrc: normalizeSpriteSource(path.relative(absPublicDir, filePath)), |
|
absPath: normalizePath(filePath), |
|
})); |
|
|
|
const readFileSpriteSources = async ( |
|
filePath: string, |
|
absPublicDir: string, |
|
absSpriteDir: string, |
|
) => { |
|
let sourceText: string; |
|
|
|
try { |
|
sourceText = await fs.readFile(filePath, "utf8"); |
|
} catch (error) { |
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") { |
|
return []; |
|
} |
|
throw error; |
|
} |
|
|
|
const availableSpriteSources = !hasRegexRegistration(sourceText) |
|
? [] |
|
: await readAvailableSpriteSources(absPublicDir, absSpriteDir); |
|
|
|
return getUniqueSortedSources( |
|
collectStaticSpriteLoads( |
|
sourceText, |
|
filePath, |
|
absPublicDir, |
|
availableSpriteSources, |
|
), |
|
); |
|
}; |
|
|
|
const readSourceImage = async ( |
|
key: string, |
|
normalizedSrc: string, |
|
absPath: string, |
|
): Promise<SourceImage> => { |
|
const file = await fs.readFile(absPath); |
|
// `PNG.sync.read()` returns plain metadata plus pixel data, so restore the |
|
// PNG prototype before we rely on instance helpers like `bitblt()`. |
|
const image = Object.assign(new PNG(), PNG.sync.read(file)); |
|
|
|
return { |
|
key, |
|
normalizedSrc, |
|
image, |
|
}; |
|
}; |
|
|
|
const writeFileIfChanged = async ( |
|
filePath: string, |
|
source: Uint8Array | string, |
|
) => { |
|
const nextBuffer = Buffer.from(source); |
|
|
|
try { |
|
const currentBuffer = await fs.readFile(filePath); |
|
|
|
if (currentBuffer.equals(nextBuffer)) { |
|
return; |
|
} |
|
} catch (error) { |
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") { |
|
throw error; |
|
} |
|
} |
|
|
|
await fs.writeFile(filePath, nextBuffer); |
|
}; |
|
|
|
const clearStaleAtlasFiles = async ( |
|
cacheDir: string, |
|
currentFiles: Set<string>, |
|
) => { |
|
let entries: string[]; |
|
|
|
try { |
|
entries = await fs.readdir(cacheDir); |
|
} catch (error) { |
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") { |
|
return; |
|
} |
|
throw error; |
|
} |
|
|
|
await Promise.all( |
|
entries.map(async (entry) => { |
|
if ( |
|
!entry.startsWith("atlas-") || |
|
(!entry.endsWith(".png") && !entry.endsWith(".json")) || |
|
currentFiles.has(entry) |
|
) { |
|
return; |
|
} |
|
|
|
await fs.rm(path.join(cacheDir, entry), { force: true }); |
|
}), |
|
); |
|
}; |
|
|
|
const buildHelperImportPath = (filePath: string, helperModulePath: string) => { |
|
let relativePath = normalizePath( |
|
path.relative(path.dirname(filePath), helperModulePath), |
|
); |
|
|
|
relativePath = relativePath.replace(/\.[^.]+$/, ""); |
|
|
|
if (!relativePath.startsWith(".")) { |
|
relativePath = `./${relativePath}`; |
|
} |
|
|
|
return relativePath; |
|
}; |
|
|
|
const rewriteLoadSpriteCalls = ( |
|
code: string, |
|
filePath: string, |
|
packableSources: Set<string>, |
|
helperModulePath: string, |
|
) => { |
|
const sourceFile = ts.createSourceFile( |
|
filePath, |
|
code, |
|
ts.ScriptTarget.Latest, |
|
true, |
|
getScriptKind(filePath), |
|
); |
|
const replacements: Array<{ |
|
start: number; |
|
end: number; |
|
text: string; |
|
}> = []; |
|
|
|
const visit = (node: ts.Node) => { |
|
if ( |
|
ts.isCallExpression(node) && |
|
ts.isPropertyAccessExpression(node.expression) && |
|
node.expression.name.text === "loadSprite" && |
|
node.arguments.length >= 2 |
|
) { |
|
const src = getLiteralString(node.arguments[1]); |
|
|
|
if (src && packableSources.has(normalizeSpriteSource(src))) { |
|
const ctxText = code.slice( |
|
node.expression.expression.getStart(sourceFile), |
|
node.expression.expression.getEnd(), |
|
); |
|
const argText = node.arguments |
|
.map((argument) => |
|
code.slice(argument.getStart(sourceFile), argument.getEnd()), |
|
) |
|
.join(", "); |
|
|
|
replacements.push({ |
|
start: node.getStart(sourceFile), |
|
end: node.getEnd(), |
|
text: `__kaplayLoadAtlasSprite(${ctxText}, ${argText})`, |
|
}); |
|
} |
|
} |
|
|
|
ts.forEachChild(node, visit); |
|
}; |
|
|
|
visit(sourceFile); |
|
|
|
if (replacements.length === 0) { |
|
return null; |
|
} |
|
|
|
let nextCode = code; |
|
|
|
replacements |
|
.sort((left, right) => right.start - left.start) |
|
.forEach((replacement) => { |
|
nextCode = |
|
nextCode.slice(0, replacement.start) + |
|
replacement.text + |
|
nextCode.slice(replacement.end); |
|
}); |
|
|
|
const helperImportPath = buildHelperImportPath(filePath, helperModulePath); |
|
|
|
nextCode = |
|
`import { loadAtlasSprite as __kaplayLoadAtlasSprite } from ${JSON.stringify(helperImportPath)};\n` + |
|
nextCode; |
|
|
|
return { |
|
code: nextCode, |
|
map: null, |
|
}; |
|
}; |
|
|
|
const getContentType = (filePath: string) => { |
|
if (filePath.endsWith(".png")) { |
|
return "image/png"; |
|
} |
|
|
|
if (filePath.endsWith(".json")) { |
|
return "application/json"; |
|
} |
|
|
|
return "application/octet-stream"; |
|
}; |
|
|
|
export const kaplaySpriteAtlasPlugin = ( |
|
options: KaplaySpriteAtlasPluginOptions = {}, |
|
): Plugin => { |
|
let server: ViteDevServer | null = null; |
|
let absPublicDir = ""; |
|
let absCacheDir = ""; |
|
let absSpriteDir = ""; |
|
let absScanDir = ""; |
|
let helperModulePath = ""; |
|
let atlasSize = options.atlasSize ?? DEFAULT_ATLAS_SIZE; |
|
let packableSources = new Set<string>(); |
|
let generatedFiles: GeneratedFile[] = []; |
|
let manifestCode = "export default {};\n"; |
|
let sourceUsageByFile = new Map<string, string[]>(); |
|
let sourceUsageCounts = new Map<string, number>(); |
|
let dirty = true; |
|
let refreshPromise: Promise<void> | null = null; |
|
|
|
const invalidateManifestModule = () => { |
|
if (!server) { |
|
return; |
|
} |
|
|
|
const module = server.moduleGraph.getModuleById( |
|
RESOLVED_VIRTUAL_MANIFEST_ID, |
|
); |
|
|
|
if (module) { |
|
server.moduleGraph.invalidateModule(module); |
|
} |
|
}; |
|
|
|
const refreshAtlas = async () => { |
|
if (absScanDir === "" || absSpriteDir === "") { |
|
return; |
|
} |
|
|
|
const sourceFiles = (await walkFiles(absScanDir)).filter(isCodeFile); |
|
const scannedFiles = await Promise.all( |
|
sourceFiles.map(async (filePath) => ({ |
|
filePath, |
|
sourceText: await fs.readFile(filePath, "utf8"), |
|
})), |
|
); |
|
const usesRegexRegistration = scannedFiles.some(({ sourceText }) => |
|
hasRegexRegistration(sourceText), |
|
); |
|
const availableSpriteSources = !usesRegexRegistration |
|
? [] |
|
: await readAvailableSpriteSources(absPublicDir, absSpriteDir); |
|
const nextSourceUsageByFile = new Map<string, string[]>(); |
|
const staticLoads = scannedFiles.flatMap(({ filePath, sourceText }) => { |
|
const loads = collectStaticSpriteLoads( |
|
sourceText, |
|
filePath, |
|
absPublicDir, |
|
availableSpriteSources, |
|
); |
|
|
|
nextSourceUsageByFile.set(normalizePath(filePath), getUniqueSortedSources(loads)); |
|
|
|
return loads; |
|
}); |
|
const sourceByNormalizedPath = new Map<string, string>(); |
|
const nextSourceUsageCounts = new Map<string, number>(); |
|
|
|
staticLoads.forEach((load) => { |
|
sourceByNormalizedPath.set(load.normalizedSrc, load.absPath); |
|
}); |
|
nextSourceUsageByFile.forEach((sources) => { |
|
sources.forEach((source) => { |
|
nextSourceUsageCounts.set(source, (nextSourceUsageCounts.get(source) ?? 0) + 1); |
|
}); |
|
}); |
|
|
|
const sourceEntries = [...sourceByNormalizedPath.entries()].sort( |
|
([left], [right]) => left.localeCompare(right), |
|
); |
|
const sourceImages = await Promise.all( |
|
sourceEntries.map(([normalizedSrc, absPath], index) => |
|
readSourceImage( |
|
`__kaplay_atlas_sprite_${index}`, |
|
normalizedSrc, |
|
absPath, |
|
), |
|
), |
|
); |
|
const pages = packSprites( |
|
sourceImages.map((image) => ({ |
|
id: image.key, |
|
width: image.image.width, |
|
height: image.image.height, |
|
})), |
|
atlasSize, |
|
); |
|
const sourceByKey = new Map( |
|
sourceImages.map((image) => [image.key, image]), |
|
); |
|
const nextGeneratedFiles: GeneratedFile[] = []; |
|
const nextManifest: Record<string, ManifestEntry> = {}; |
|
|
|
pages.forEach((page, pageIndex) => { |
|
const atlasImage = new PNG({ width: atlasSize, height: atlasSize }); |
|
const atlasData: Record< |
|
string, |
|
{ |
|
x: number; |
|
y: number; |
|
width: number; |
|
height: number; |
|
} |
|
> = {}; |
|
const pngFileName = `atlas-${pageIndex}.png`; |
|
const jsonFileName = `atlas-${pageIndex}.json`; |
|
const runtimePngPath = joinRuntimePath(pngFileName); |
|
const runtimeJsonPath = joinRuntimePath(jsonFileName); |
|
|
|
page.items.forEach((item) => { |
|
const source = sourceByKey.get(item.id); |
|
|
|
if (!source) { |
|
throw new Error(`Missing packed sprite source for "${item.id}".`); |
|
} |
|
|
|
source.image.bitblt( |
|
atlasImage, |
|
0, |
|
0, |
|
source.image.width, |
|
source.image.height, |
|
item.x, |
|
item.y, |
|
); |
|
|
|
atlasData[source.key] = { |
|
x: item.x, |
|
y: item.y, |
|
width: source.image.width, |
|
height: source.image.height, |
|
}; |
|
|
|
nextManifest[source.normalizedSrc] = { |
|
image: runtimePngPath, |
|
data: runtimeJsonPath, |
|
key: source.key, |
|
}; |
|
}); |
|
|
|
nextGeneratedFiles.push( |
|
{ |
|
fileName: pngFileName, |
|
source: PNG.sync.write(atlasImage), |
|
}, |
|
{ |
|
fileName: jsonFileName, |
|
source: JSON.stringify(atlasData, null, 2), |
|
}, |
|
); |
|
}); |
|
|
|
await fs.mkdir(absCacheDir, { recursive: true }); |
|
|
|
await Promise.all( |
|
nextGeneratedFiles.map((file) => |
|
writeFileIfChanged(path.join(absCacheDir, file.fileName), file.source), |
|
), |
|
); |
|
|
|
await clearStaleAtlasFiles( |
|
absCacheDir, |
|
new Set(nextGeneratedFiles.map((file) => file.fileName)), |
|
); |
|
|
|
packableSources = new Set(Object.keys(nextManifest)); |
|
generatedFiles = nextGeneratedFiles; |
|
manifestCode = `export default ${JSON.stringify(nextManifest, null, 2)};\n`; |
|
sourceUsageByFile = nextSourceUsageByFile; |
|
sourceUsageCounts = nextSourceUsageCounts; |
|
dirty = false; |
|
invalidateManifestModule(); |
|
}; |
|
|
|
const ensureAtlas = async () => { |
|
if (!dirty) { |
|
return; |
|
} |
|
|
|
if (!refreshPromise) { |
|
refreshPromise = refreshAtlas().finally(() => { |
|
refreshPromise = null; |
|
}); |
|
} |
|
|
|
await refreshPromise; |
|
}; |
|
|
|
const shouldRefreshForCodeFile = async (filePath: string) => { |
|
const previousSources = sourceUsageByFile.get(filePath) ?? []; |
|
const nextSources = await readFileSpriteSources( |
|
filePath, |
|
absPublicDir, |
|
absSpriteDir, |
|
); |
|
|
|
if (areSourceListsEqual(previousSources, nextSources)) { |
|
return false; |
|
} |
|
|
|
const nextSourceSet = new Set(nextSources); |
|
|
|
for (const source of previousSources) { |
|
if (!nextSourceSet.has(source) && (sourceUsageCounts.get(source) ?? 0) <= 1) { |
|
return true; |
|
} |
|
} |
|
|
|
for (const source of nextSources) { |
|
if (!sourceUsageCounts.has(source)) { |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
}; |
|
|
|
const shouldRefreshForFile = async (filePath: string) => { |
|
const normalizedFilePath = normalizePath(filePath); |
|
|
|
if ( |
|
normalizedFilePath === absCacheDir || |
|
normalizedFilePath.startsWith(`${absCacheDir}/`) |
|
) { |
|
return false; |
|
} |
|
|
|
if ( |
|
isCodeFile(normalizedFilePath) && |
|
(normalizedFilePath === absScanDir || |
|
normalizedFilePath.startsWith(`${absScanDir}/`)) |
|
) { |
|
return shouldRefreshForCodeFile(normalizedFilePath); |
|
} |
|
|
|
if ( |
|
normalizedFilePath.endsWith(".png") && |
|
normalizedFilePath.startsWith(`${absSpriteDir}/`) |
|
) { |
|
return true; |
|
} |
|
|
|
return false; |
|
}; |
|
|
|
return { |
|
name: "kaplay-sprite-atlas", |
|
enforce: "pre", |
|
configResolved(resolvedConfig) { |
|
if (resolvedConfig.publicDir === "") { |
|
throw new Error( |
|
'kaplaySpriteAtlasPlugin requires Vite "publicDir" to be enabled.', |
|
); |
|
} |
|
|
|
absPublicDir = normalizePath( |
|
path.resolve(resolvedConfig.root, resolvedConfig.publicDir), |
|
); |
|
absCacheDir = normalizePath( |
|
path.resolve(resolvedConfig.root, DEFAULT_CACHE_DIR), |
|
); |
|
atlasSize = options.atlasSize ?? DEFAULT_ATLAS_SIZE; |
|
absScanDir = normalizePath( |
|
path.resolve(resolvedConfig.root, DEFAULT_SCAN_DIR), |
|
); |
|
absSpriteDir = normalizePath( |
|
path.resolve(absPublicDir, DEFAULT_SPRITE_DIR), |
|
); |
|
helperModulePath = normalizePath( |
|
path.resolve(resolvedConfig.root, "src/kaplay-atlas-plugin/runtime.ts"), |
|
); |
|
}, |
|
async buildStart() { |
|
dirty = true; |
|
await ensureAtlas(); |
|
}, |
|
resolveId(id) { |
|
if (id === VIRTUAL_MANIFEST_ID) { |
|
return RESOLVED_VIRTUAL_MANIFEST_ID; |
|
} |
|
|
|
return null; |
|
}, |
|
load(id) { |
|
if (id === RESOLVED_VIRTUAL_MANIFEST_ID) { |
|
return manifestCode; |
|
} |
|
|
|
return null; |
|
}, |
|
async transform(code, id) { |
|
const filePath = id.split("?", 1)[0]; |
|
|
|
if ( |
|
!isCodeFile(filePath) || |
|
filePath.includes("/node_modules/") || |
|
filePath.includes("/src/kaplay-atlas-plugin/") |
|
) { |
|
return null; |
|
} |
|
|
|
await ensureAtlas(); |
|
|
|
return rewriteLoadSpriteCalls( |
|
code, |
|
filePath, |
|
packableSources, |
|
helperModulePath, |
|
); |
|
}, |
|
configureServer(devServer) { |
|
server = devServer; |
|
|
|
devServer.middlewares.use(async (req, res, next) => { |
|
const requestPath = req.url?.split("?", 1)[0]; |
|
|
|
if (!requestPath || !requestPath.startsWith(OUTPUT_PREFIX)) { |
|
next(); |
|
return; |
|
} |
|
|
|
const fileName = requestPath.slice(OUTPUT_PREFIX.length); |
|
const filePath = path.join(absCacheDir, fileName); |
|
|
|
try { |
|
const file = await fs.readFile(filePath); |
|
res.setHeader("Content-Type", getContentType(filePath)); |
|
res.end(file); |
|
} catch (error) { |
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") { |
|
next(); |
|
return; |
|
} |
|
next(error as Error); |
|
} |
|
}); |
|
}, |
|
async handleHotUpdate(context) { |
|
if (!(await shouldRefreshForFile(context.file))) { |
|
return; |
|
} |
|
|
|
dirty = true; |
|
await ensureAtlas(); |
|
context.server.ws.send({ type: "full-reload" }); |
|
|
|
return []; |
|
}, |
|
async generateBundle() { |
|
await ensureAtlas(); |
|
|
|
generatedFiles.forEach((file) => { |
|
this.emitFile({ |
|
type: "asset", |
|
fileName: joinRuntimePath(file.fileName), |
|
source: file.source, |
|
}); |
|
}); |
|
}, |
|
}; |
|
}; |