arcadekit/content
Loads what a game is played with and drawn from, once per app. Content declares a game's content files (heroes, levels, waves) with the Effect Schema that checks them, and Asset declares something the app loads once, such as a sprite sheet. Each is a class that keys its own service: .layer provides it, and .load loads it, keeps the first success, and can be run again after a failure. memorySource serves files from memory, for tests; fetchJsonSource in arcadekit/content/browser fetches them over HTTP.
An entry of the arcadekit package. The root entry is DOM-free.
Install
pnpm add arcadekit effectPeer dependency: effect (~4.1).
Example
Levels from levels.json, checked by a Schema and by a rule it can't express, and a sheet the app loads once (as @arcadekit/push-puzzle does):
import { Asset, Content, memorySource } from "arcadekit/content";
import { fetchJsonSource } from "arcadekit/content/browser";
import { Data, Effect, Layer, Schema } from "effect";
const Level = Schema.Struct({
id: Schema.String,
rows: Schema.Array(Schema.String),
});
// The genre declares its content: the Schema, the file each part comes from, and extra checks.
export class GameContent extends Content("GameContent", {
schema: Schema.Struct({ levels: Schema.Array(Level) }),
files: { levels: "levels.json" },
validate: (content) =>
content.levels.length === 0 ? ["levels.json has no levels"] : [],
}) {}
class SheetError extends Data.TaggedError("SheetError")<{
readonly message: string;
}> {}
interface Sheet {
readonly image: ImageBitmap;
}
// And an asset, with how to free it. The type and the error come first.
export class GameSheet extends Asset<Sheet, SheetError>()("GameSheet", {
release: (sheet) => sheet.image.close(),
}) {}
// The app says where each comes from. Building the layers loads nothing.
export const resources = Layer.mergeAll(
GameContent.layer(fetchJsonSource("/content/")),
GameSheet.layer(
Effect.tryPromise({
try: async () => {
const response = await fetch("/sprites/sheet.png");
return { image: await createImageBitmap(await response.blob()) };
},
catch: (cause) => new SheetError({ message: String(cause) }),
})
)
);
// Anything loads them. The first success is kept; a failed load can be run again.
export const loadEverything = Effect.all([GameContent.load, GameSheet.load], {
concurrency: "unbounded",
});
// A test reads the game's files with the same checks, and keeps nothing.
export const checked = GameContent.read(
memorySource({ "levels.json": [{ id: "first", rows: ["#@$.#"] }] })
);GameContent.load gives the content, or fails with a ContentError whose message names the file and the problem. Each class is the type of its own service, so a requirement reads Effect.Effect<A, E, GameContent | GameSheet>.
A Game.Engine names what it draws from in uses (uses: { sheet: GameSheet.load }), so each attach loads the sheet or takes the one already loaded, and the app's loading screen runs the same GameSheet.load to show a failure and retry.
loadOnce(load, release) is what both are built on: a load that keeps its first success for as long as its scope is open. loadContent and makeContentLoader are the older, undeclared forms, which survivors still uses.