Skip to content

Instantly share code, notes, and snippets.

@Stanko
Last active March 26, 2026 16:37
Show Gist options
  • Select an option

  • Save Stanko/c63b5e7d2b42cb4c6ec2fe752d9ed5d9 to your computer and use it in GitHub Desktop.

Select an option

Save Stanko/c63b5e7d2b42cb4c6ec2fe752d9ed5d9 to your computer and use it in GitHub Desktop.
Kaplay texture packing vite plugin (AI generated)

Kaplay Sprite Atlas Plugin

This plugin is intentionally repo-specific. It scans src, packs PNGs from public/sprites, rewrites static k.loadSprite(...) calls, and serves or emits atlas files from a fixed location.

Fixed Conventions

  • Source code is scanned under src
  • Packable images live under public/sprites
  • Atlas cache is written to .kaplay-sprite-atlas
  • Runtime atlas assets are served and emitted under generated/kaplay-sprite-atlas

Canonical sprite source paths look like sprites/player.png.

The plugin normalizes equivalent spellings like ./sprites/player.png and /sprites/player.png, but the manifest only stores the canonical form.

Dependencies

  • Install pngjs as a dev dependency

Files

  • src/kaplay-atlas-plugin/plugin.ts
  • src/kaplay-atlas-plugin/packer.ts
  • src/kaplay-atlas-plugin/runtime.ts
  • src/kaplay-atlas-plugin/types.d.ts

Type Declaration

The runtime imports virtual:kaplay-sprite-atlas-manifest.

src/kaplay-atlas-plugin/types.d.ts should contain:

declare module "virtual:kaplay-sprite-atlas-manifest" {
  const content: Record<
    string,
    {
      image: string;
      data: string;
      key: string;
    }
  >;

  export default content;
}

Vite Setup

Register the plugin in vite.config.ts:

import { defineConfig } from "vite";
import { kaplaySpriteAtlasPlugin } from "./src/kaplay-atlas-plugin/plugin";

export default defineConfig({
  plugins: [kaplaySpriteAtlasPlugin()],
});

Only one option remains:

kaplaySpriteAtlasPlugin({
  atlasSize: 2048,
});
  • atlasSize: width and height of each atlas page, defaults to 2048

Automatic Static Rewriting

If the sprite source is a plain string literal, keep using normal Kaplay code:

k.loadSprite("player", "sprites/player.png", {
  sliceX: 4,
  sliceY: 1,
});

The plugin will:

  1. include sprites/player.png in the atlas
  2. rewrite the call to the runtime helper
  3. load the sprite from the atlas at runtime

This is the default path.

Dynamic Loads

Use loadAtlasSprite(...) when the source path is dynamic.

import { loadAtlasSprite } from "../kaplay-atlas-plugin/runtime";

loadAtlasSprite(k, name, src, opt);

Its signature matches k.loadSprite(...), except it takes the Kaplay context first.

Example:

import { k } from "../k";
import { loadAtlasSprite } from "../kaplay-atlas-plugin/runtime";

for (const variant of ["ship-red", "ship-blue"] as const) {
  loadAtlasSprite(k, variant, `sprites/${variant}.png`, {
    sliceX: 4,
    sliceY: 1,
  });
}

If the source is not present in the atlas manifest, loadAtlasSprite(...) falls back to normal k.loadSprite(...).

Registering Dynamic Sources

Dynamic loads only use the atlas when the plugin knows ahead of time which files must be packed.

includeAtlasSpriteSources

Use this for exact runtime source paths:

import { includeAtlasSpriteSources } from "../kaplay-atlas-plugin/runtime";

includeAtlasSpriteSources(
  "sprites/nope8.png",
  "sprites/sdx5.png",
);

includeAtlasSpriteSourcesRegex

Use this for predictable file groups:

import { includeAtlasSpriteSourcesRegex } from "../kaplay-atlas-plugin/runtime";

includeAtlasSpriteSourcesRegex(/^sprites\/asteroid_(sm|md|lg)_\d+\.png$/);

Notes:

  • regex registration only supports regex literals
  • matching runs against normalized runtime source paths like sprites/foo.png
  • only PNG files under public/sprites are considered
  • sprite files are only listed when at least one scanned source file uses includeAtlasSpriteSourcesRegex(...)

Dynamic Example

import { k } from "../k";
import {
  includeAtlasSpriteSourcesRegex,
  loadAtlasSprite,
} from "../kaplay-atlas-plugin/runtime";

includeAtlasSpriteSourcesRegex(/^sprites\/asteroid_(sm|md|lg)_\d+\.png$/);

for (const sprite of ["asteroid_sm_1", "asteroid_sm_2"]) {
  loadAtlasSprite(k, sprite, `sprites/${sprite}.png`, {
    sliceX: 12,
    sliceY: 2,
  });
}

Registration is needed because the plugin cannot extract a concrete file list from a template string like `sprites/${sprite}.png`.

Dev And Build Output

The plugin always runs in both vite dev and vite build.

During development:

  • atlas PNG and JSON files are written to .kaplay-sprite-atlas
  • the dev server serves them from /generated/kaplay-sprite-atlas/...

During production build:

  • atlas PNG and JSON files are emitted to dist/generated/kaplay-sprite-atlas/

Typical output:

  • generated/kaplay-sprite-atlas/atlas-0.png
  • generated/kaplay-sprite-atlas/atlas-0.json

Runtime Behavior

The runtime helper internally calls:

k.loadSpriteAtlas(imagePath, jsonPath, false);

The third false argument is intentional for the local Kaplay build used in this repo.

Limitations

  • only PNG files are packed
  • only public/sprites is scanned for atlas input
  • automatic rewriting only works for static string-based k.loadSprite(...) calls
  • dynamic atlas loading only works through loadAtlasSprite(...)
  • dynamic registration only works through includeAtlasSpriteSources(...) and includeAtlasSpriteSourcesRegex(...)
  • includeAtlasSpriteSourcesRegex(...) only supports regex literals

Recommended Workflow

  1. Use normal k.loadSprite(...) for string-literal sources.
  2. Switch to loadAtlasSprite(...) when the source path becomes dynamic.
  3. Register dynamic source sets with includeAtlasSpriteSources(...) or includeAtlasSpriteSourcesRegex(...).
  4. Keep runtime source paths in canonical form like sprites/foo.png.
  5. Run vite build and inspect dist/generated/kaplay-sprite-atlas/ if something is missing.
export type PackInput = {
id: string;
width: number;
height: number;
};
export type PackedPage = {
items: Array<PackInput & { x: number; y: number }>;
};
type SkylineNode = {
x: number;
y: number;
width: number;
};
type PageState = PackedPage & {
skyline: SkylineNode[];
};
type Placement = PackInput & {
x: number;
y: number;
index: number;
};
const createPage = (atlasSize: number): PageState => ({
items: [],
skyline: [
{
x: 0,
y: 0,
width: atlasSize,
},
],
});
const findPlacement = (
skyline: SkylineNode[],
sprite: PackInput,
atlasSize: number,
): Placement | null => {
let bestPlacement: Placement | null = null;
for (let index = 0; index < skyline.length; index++) {
const startNode = skyline[index];
if (startNode.x + sprite.width > atlasSize) {
continue;
}
let widthLeft = sprite.width;
let y = startNode.y;
let nodeIndex = index;
while (widthLeft > 0) {
const node = skyline[nodeIndex];
if (!node) {
y = atlasSize + 1;
break;
}
y = Math.max(y, node.y);
if (y + sprite.height > atlasSize) {
y = atlasSize + 1;
break;
}
widthLeft -= node.width;
nodeIndex++;
}
if (y + sprite.height > atlasSize) {
continue;
}
const placement: Placement = {
...sprite,
x: startNode.x,
y,
index,
};
if (
!bestPlacement ||
placement.y < bestPlacement.y ||
(placement.y === bestPlacement.y && placement.x < bestPlacement.x)
) {
bestPlacement = placement;
}
}
return bestPlacement;
};
const mergeSkyline = (skyline: SkylineNode[]) => {
for (let index = 1; index < skyline.length; index++) {
const prev = skyline[index - 1];
const current = skyline[index];
if (prev.y !== current.y) {
continue;
}
prev.width += current.width;
skyline.splice(index, 1);
index--;
}
};
const insertPlacement = (page: PageState, placement: Placement) => {
page.items.push({
id: placement.id,
width: placement.width,
height: placement.height,
x: placement.x,
y: placement.y,
});
page.skyline.splice(placement.index, 0, {
x: placement.x,
y: placement.y + placement.height,
width: placement.width,
});
for (let index = placement.index + 1; index < page.skyline.length; index++) {
const prev = page.skyline[index - 1];
const current = page.skyline[index];
const overlap = prev.x + prev.width - current.x;
if (overlap <= 0) {
break;
}
current.x += overlap;
current.width -= overlap;
if (current.width > 0) {
break;
}
page.skyline.splice(index, 1);
index--;
}
mergeSkyline(page.skyline);
};
const placeInPage = (page: PageState, sprite: PackInput, atlasSize: number) => {
const placement = findPlacement(page.skyline, sprite, atlasSize);
if (!placement) {
return false;
}
insertPlacement(page, placement);
return true;
};
export const packSprites = (inputs: PackInput[], atlasSize: number): PackedPage[] => {
const sprites = [...inputs].sort((left, right) => {
if (right.height !== left.height) {
return right.height - left.height;
}
if (right.width !== left.width) {
return right.width - left.width;
}
return left.id.localeCompare(right.id);
});
const pages: PageState[] = [];
for (const sprite of sprites) {
if (sprite.width > atlasSize || sprite.height > atlasSize) {
throw new Error(
`Sprite "${sprite.id}" (${sprite.width}x${sprite.height}) does not fit into a ${atlasSize}x${atlasSize} atlas.`,
);
}
const existingPage = pages.find((page) =>
placeInPage(page, sprite, atlasSize),
);
if (existingPage) {
continue;
}
const page = createPage(atlasSize);
placeInPage(page, sprite, atlasSize);
pages.push(page);
}
return pages.map(({ items }) => ({ items }));
};
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,
});
});
},
};
};
import type {
Asset,
KAPLAYCtx,
LoadSpriteOpt,
LoadSpriteSrc,
SpriteData,
} from "kaplay";
import atlasManifest from "virtual:kaplay-sprite-atlas-manifest";
type PackedSpriteContext = Pick<
KAPLAYCtx,
"_k" | "getSprite" | "loadSprite" | "loadSpriteAtlas" | "Quad" | "SpriteData"
>;
type AtlasManifestEntry = (typeof atlasManifest)[string];
export const includeAtlasSpriteSources = (..._sources: string[]) => {};
export const includeAtlasSpriteSourcesRegex = (..._patterns: RegExp[]) => {};
const normalizeSpriteSource = (src: string) =>
src.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, "");
const pageCacheByContext = new WeakMap<
PackedSpriteContext,
Map<string, Promise<void>>
>();
const getPageCache = (k: PackedSpriteContext) => {
const existing = pageCacheByContext.get(k);
if (existing) {
return existing;
}
const next = new Map<string, Promise<void>>();
pageCacheByContext.set(k, next);
return next;
};
const waitForAsset = <T>(asset: Asset<T>): Promise<T> => {
if (asset.loaded) {
if (asset.error) {
return Promise.reject(asset.error);
}
if (asset.data !== null) {
return Promise.resolve(asset.data);
}
}
return new Promise<T>((resolve, reject) => {
asset.onLoad(resolve);
asset.onError(reject);
});
};
const ensureAtlasPage = (k: PackedSpriteContext, entry: AtlasManifestEntry) => {
const cache = getPageCache(k);
const cacheKey = `${entry.image}:${entry.data}`;
const existing = cache.get(cacheKey);
if (existing) {
return existing;
}
// @ts-expect-error The local kaplay build supports a third boolean parameter.
const atlas = k.loadSpriteAtlas(entry.image, entry.data, false) as Asset<
Record<string, SpriteData>
>;
const next = waitForAsset(atlas).then(() => undefined);
cache.set(cacheKey, next);
return next;
};
const getFrameRects = (
sourceWidth: number,
sourceHeight: number,
opt: LoadSpriteOpt,
) => {
if (opt.frames) {
return opt.frames.map((frame) => ({
x: frame.x,
y: frame.y,
width: frame.w,
height: frame.h,
}));
}
const sliceX = opt.sliceX ?? 1;
const sliceY = opt.sliceY ?? 1;
const frameWidth = sourceWidth / sliceX;
const frameHeight = sourceHeight / sliceY;
const frames: Array<{
x: number;
y: number;
width: number;
height: number;
}> = [];
for (let y = 0; y < sliceY; y++) {
for (let x = 0; x < sliceX; x++) {
frames.push({
x: x * frameWidth,
y: y * frameHeight,
width: frameWidth,
height: frameHeight,
});
}
}
return frames;
};
const buildSpriteFrames = (
k: PackedSpriteContext,
sourceSprite: SpriteData,
opt: LoadSpriteOpt,
) => {
const sourceFrame = sourceSprite.frames[0];
if (!sourceFrame) {
throw new Error("Packed atlas sprite is missing its source frame.");
}
return getFrameRects(sourceSprite.width, sourceSprite.height, opt).map(
(frameRect) => {
const quad = new k.Quad(
frameRect.x / sourceSprite.width,
frameRect.y / sourceSprite.height,
frameRect.width / sourceSprite.width,
frameRect.height / sourceSprite.height,
);
return {
tex: sourceFrame.tex,
q: sourceFrame.q.scale(quad),
id: sourceFrame.id,
};
},
);
};
export const loadAtlasSprite = (
k: PackedSpriteContext,
name: string | null,
src: LoadSpriteSrc | LoadSpriteSrc[],
opt: LoadSpriteOpt = {},
): Asset<SpriteData> => {
if (name === null || typeof src !== "string" || opt.singular === true) {
return k.loadSprite(name, src, opt);
}
const normalizedSrc = normalizeSpriteSource(src);
const manifestEntry = atlasManifest[normalizedSrc];
if (!manifestEntry) {
return k.loadSprite(name, src, opt);
}
const existing = k.getSprite(name);
if (existing) {
return existing;
}
const loader = ensureAtlasPage(k, manifestEntry).then(async () => {
const sourceAsset = k.getSprite(manifestEntry.key);
if (!sourceAsset) {
throw new Error(
`Packed atlas sprite "${manifestEntry.key}" was not loaded.`,
);
}
const sourceSprite = await waitForAsset(sourceAsset);
return new k.SpriteData(
buildSpriteFrames(k, sourceSprite, opt),
opt.anims,
opt.slice9 ?? null,
);
});
return k._k.assets.sprites.add(name, loader);
};
declare module "virtual:kaplay-sprite-atlas-manifest" {
const content: Record<
string,
{
image: string;
data: string;
key: string;
}
>;
export default content;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment