From 95a57a32e6406e73691333f3cb70b037a7e3d942 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:34:13 +0800 Subject: [PATCH 1/5] feat(vapor): add reactive GBA RPG proof of concept --- package.json | 2 + vapor/DESIGN.md | 40 ++- vapor/README.md | 50 ++- vapor/compiler/cli.ts | 7 +- vapor/compiler/compile.ts | 378 +++++++++++++++++++++- vapor/compiler/rom.ts | 4 +- vapor/examples/rpg/rpg.tsx | 291 +++++++++++++++++ vapor/host/rpg.ts | 113 +++++++ vapor/runtime/gba/vapor_gba.c | 21 +- vapor/runtime/gba/vapor_rpg.c | 585 ++++++++++++++++++++++++++++++++++ vapor/runtime/vapor.h | 38 +++ vapor/scripts/play.ts | 3 +- vapor/tests/compiler.test.ts | 1 + vapor/tests/rpg.test.ts | 241 ++++++++++++++ 14 files changed, 1757 insertions(+), 17 deletions(-) create mode 100644 vapor/examples/rpg/rpg.tsx create mode 100644 vapor/host/rpg.ts create mode 100644 vapor/runtime/gba/vapor_rpg.c create mode 100644 vapor/tests/rpg.test.ts diff --git a/package.json b/package.json index 6d912968..3b81e18f 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,8 @@ "site:build": "bun tools/site-build.ts", "gba:imagegen": "bun imagegen", "vapor": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx", + "vapor:rpg": "bun vapor/compiler/cli.ts vapor/examples/rpg/rpg.tsx", + "vapor:rpg:play": "bun vapor/scripts/play.ts vapor/examples/rpg/rpg.tsx", "vapor:play": "bun vapor/scripts/play.ts", "vapor:test": "bun test vapor/tests/", "serve": "bun hosts/web/serve.ts", diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index 22edb3e7..2656787f 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -194,9 +194,9 @@ oxlint plugin would give red squiggles without booting the compiler. The Each target presents a fixed logical cell screen: 30×20 on GBA, 20×18 on GB and ESP32, 22×18 on NES, and 50×30 on Playdate. The ESP32 MeowBit profile rasterizes its 20×18 grid as 8×7 cells into a 160×126 content area on the 160×128 ST7735 -panel. The JSX vocabulary is deliberately one intrinsic with two -interpreters — the C cell grid on device, and a ~60-line tree walker over -the oracle's micro-DOM: +panel. The portable, cross-target cell vocabulary is deliberately one +intrinsic with two interpreters — the C cell grid on device, and a ~60-line +tree walker over the oracle's micro-DOM: - `` — paints its text children at (x, y) in palette `p`, padded with spaces to the right edge; later rows overwrite @@ -210,6 +210,40 @@ the oracle's micro-DOM: - Looks come from the class DSL (§4.5); the painter and every runtime agree on pair ids, and the oracle asserts them as a per-cell grid. +There is one deliberately target-specific exception to that portable cell +vocabulary: the experimental GBA RPG host. It is a narrow vertical slice, +not a new general-purpose JSX renderer, and non-GBA target admission rejects +it. Its responsibilities are split four ways: + +1. **Static assets — `defineRpgMap(...)`.** Map rows, solid characters, + event-character ids, and dialogue records are build-time declarations. + The compiler validates the 30×20 bound, equal row widths and byte-oriented + ids, then emits flat tile/collision data plus event and dialogue tables in + ROM. None of this is mutable gameplay state. +2. **Pure world queries — `rpgBlocked(...)` / `rpgEventAt(...)`.** The host + module provides real, deterministic JS lookup semantics; AOT recognizes + the same calls and emits calls to fixed C helpers over the generated map. + Out-of-bounds movement fails closed. +3. **Reactive gameplay — ordinary Vue Vapor.** Mode, player position and + facing, quest state, current dialogue/choice, HP and battle selection are + ordinary `ref` slots; derived gates are `computed`; `onButton` keymaps and + setup functions implement movement, interaction, dialogue and battle. + They use the same dirty-bit graph and direct C lowering as Todo. There is + no residual script bytecode, stack machine, hidden controller state, or + second gameplay VM. +4. **Fixed presentation — ``.** The component is stateless: + every dynamic input arrives as a prop and therefore participates in the + normal dependency mask. The GBA runtime renders the tile world on BG1 and + characters through OBJ, with its fixed dialogue/battle presentation; it + does not expose GBA registers or SDK concepts to application code. + +The current RPG movement contract consumes the same hardware-neutral +`onButton` press edges as the rest of Vapor and advances one cell per press. +The JS `RpgScreen` currently returns no pixels, so collision/event behavior +can run in the JS/oracle path but pixel-frame acceptance is mGBA-only for +this POC. A browser pixel renderer, continuous held-button movement, saves, +audio and CJK text are follow-ups, not implied capabilities. + Input is not DOM events. The host module exposes two explicit capabilities: - `onButton((b: Button) => void)` for frame-latched press edges; diff --git a/vapor/README.md b/vapor/README.md index 937db309..30b915e8 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -172,6 +172,53 @@ Vapor in your browser — inspectable DOM rows, keyboard as the pad, `?target=esp32` to preview the MeowBit viewport, or `?target=playdate` for the 50×30 one-bit contract. +## GBA RPG POC + +[`examples/rpg/rpg.tsx`](examples/rpg/rpg.tsx) is an experimental, +GBA-only RPG vertical slice. It adds a small native game host without adding +a second gameplay language or VM: + +- `defineRpgMap(...)` declares static map rows, solid tile characters, event + characters, and dialogue records. The compiler validates the declaration + and emits fixed ROM tables. +- `rpgBlocked(...)` and `rpgEventAt(...)` are pure map queries with real JS + implementations for behavior/oracle use and equivalent native C helpers. +- The player, mode, facing, quest, dialogue choice, HP, and battle cursor are + ordinary `ref` state; the quest gate is an ordinary `computed`. Button + handlers and setup functions contain the actual gameplay and compile + directly to C. +- One stateless `` receives that complete reactive state. On + GBA the compiler lowers it to the fixed tile/sprite RPG renderer rather + than shipping Vue or JavaScript in the ROM. + +The demo's ten reactive refs occupy 40 bytes. The fixed GBA host adds 3,076 +bytes of BG1 tilemap and OAM shadow buffers so all VRAM commits stay inside +vblank; map, collision, event, dialogue, tile and sprite assets remain in ROM. + +The demo is one complete loop: start at `(2,2)`, walk next to the solid +Elder, face them and press A, choose whether to accept the quest, walk onto +the Slime event, use ATTACK or HEAL in a turn battle, defeat the Slime with +three attacks, then return to the Elder to complete the quest. + +Controls: + +- World: D-pad moves one cell per press; A talks to the event cell directly + in front of the player. +- Dialogue: Up/Down selects YES or NO; A confirms or advances. +- Battle: Up/Down selects ATTACK or HEAL; A performs the action. + +```sh +bun run vapor:rpg # build dist/vapor/rpg.gba +bun run vapor:rpg:play # build and open the ROM in mGBA +``` + +The POC deliberately keeps a narrow evidence and product boundary. It is +GBA-only, step-based, and uses printable English ASCII; there is no save +data, audio, or CJK text yet. The JS collision/event helpers are real, but +the browser `RpgScreen` is currently a stateless placeholder rather than a +pixel oracle renderer. Validation is scoped to mGBA; this section does not +claim physical-GBA or flash-cart acceptance. + ## Commands The ESP32 `flash` and default `verify` commands below write the connected @@ -219,7 +266,8 @@ arithmetic and bit masks come from a ROM table. vapor/ DESIGN.md the thesis + subset + target/style contracts examples/todo/ portable Todo + Playdate relative-axis input variant - host/ input.ts (buttons + relative axes), screen.ts (SCREEN geometry) + examples/rpg/ experimental GBA-only reactive RPG vertical slice + host/ input/screen contracts + the experimental RPG host oracle/ micro-DOM + grid painter + bundle boot (real vue) compiler/ compile.ts (TS AST → C), styles.ts (class DSL), rom.ts, cli.ts runtime/ vapor.h contract + vapor_core.c (shared grid/strings/line) diff --git a/vapor/compiler/cli.ts b/vapor/compiler/cli.ts index cf5010b1..5a86d5e0 100644 --- a/vapor/compiler/cli.ts +++ b/vapor/compiler/cli.ts @@ -136,10 +136,15 @@ if (target !== "playdate" && playdateModeIdx >= 0) { const source = await Bun.file(entry).text(); const name = basename(entry).replace(/\.tsx$/, ""); +const title = name.startsWith("todo") + ? "VAPOR TODO" + : name === "rpg" + ? "VAPOR QUEST" + : name.toUpperCase(); const app = compileVaporApp( entry, source, - name.startsWith("todo") ? "VAPOR TODO" : name.toUpperCase(), + title, target, ); diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 9ae59949..bb450a85 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -68,6 +68,8 @@ const TARGET_RELATIVE_AXES: Record = { export interface CompiledApp { c: string; title: string; + /** GBA-only pixel RPG host/runtime is required by this app. */ + rpgEnabled: boolean; graph: string; plan: string; debugSlots: DebugSlot[]; @@ -160,6 +162,30 @@ interface ComponentBinding { body: ts.JsxElement | ts.JsxSelfClosingElement; // the root } +interface RpgEventSrc { + tile: number; + event: number; +} + +interface RpgDialogSrc { + speaker: string; + line1: string; + line2: string; + choice0: string; + choice1: string; +} + +interface RpgMapBinding { + kind: "rpgMap"; + name: string; + width: number; + height: number; + rows: string[]; + solid: string; + events: RpgEventSrc[]; + dialogs: RpgDialogSrc[]; +} + /** Active props substitution while inlining one component use. */ interface PropsCtx { param: string; @@ -188,7 +214,8 @@ type Binding = | ConstBinding | LocalBinding | KeymapBinding - | ComponentBinding; + | ComponentBinding + | RpgMapBinding; // --------------------------------------------------------------------------- // Compiler @@ -247,6 +274,13 @@ class AppCompiler { private hostOnButton = ""; private hostButton = ""; private hostOnAxisDelta = ""; + private hostDefineRpgMap = ""; + private hostRpgBlocked = ""; + private hostRpgEventAt = ""; + private hostRpgScreen = ""; + + private rpgMaps: RpgMapBinding[] = []; + private rpgScreenCount = 0; // emission private decls: string[] = []; @@ -290,7 +324,7 @@ class AppCompiler { else if (ts.isInterfaceDeclaration(stmt)) this.scanInterface(stmt); else if (ts.isTypeAliasDeclaration(stmt)) continue; // types are erased else if (ts.isFunctionDeclaration(stmt)) this.scanComponent(stmt); - else if (ts.isVariableStatement(stmt)) this.scanModuleConst(stmt); + else if (ts.isVariableStatement(stmt)) this.scanModuleConst(stmt, true); else if (ts.isExportAssignment(stmt)) { if (!ts.isArrowFunction(stmt.expression)) this.err(stmt, "export default must be an arrow component"); component = stmt.expression; @@ -337,6 +371,14 @@ class AppCompiler { name: local, value: { width: this.target.width, height: this.target.height }, }); + } else if (/\/host\/rpg(\.ts)?$/.test(from)) { + if (this.target.name !== "gba") + this.err(spec, "Pocket Vapor RPG is currently supported only on the gba target"); + if (imported === "defineRpgMap") this.hostDefineRpgMap = local; + else if (imported === "rpgBlocked") this.hostRpgBlocked = local; + else if (imported === "rpgEventAt") this.hostRpgEventAt = local; + else if (imported === "RpgScreen") this.hostRpgScreen = local; + else this.err(spec, `unsupported RPG host import: ${imported}`); } else this.err(stmt, `unsupported import source: ${from}`); } } @@ -357,12 +399,21 @@ class AppCompiler { this.ifaces.set(decl.name.text, { name: decl.name.text, fields }); } - private scanModuleConst(stmt: ts.VariableStatement): void { + private scanModuleConst(stmt: ts.VariableStatement, allowRpg: boolean): void { if (!(stmt.declarationList.flags & ts.NodeFlags.Const)) this.err(stmt, "module variables must be const"); for (const decl of stmt.declarationList.declarations) { if (!ts.isIdentifier(decl.name) || !decl.initializer) this.err(decl, "const needs a simple name + initializer"); const name = decl.name.text; const init = decl.initializer; + if ( + ts.isCallExpression(init) && + ts.isIdentifier(init.expression) && + init.expression.text === this.hostDefineRpgMap + ) { + if (!allowRpg) this.err(init, "defineRpgMap() must initialize a module-level const"); + this.scanRpgMap(name, init); + continue; + } const folded = this.constNum(init) ?? this.constBool(init); if (folded !== null) this.scope.set(name, { kind: "const", name, value: folded }); else if (ts.isStringLiteral(init)) this.scope.set(name, { kind: "const", name, value: init.text }); @@ -386,6 +437,152 @@ class AppCompiler { } } + // ---- RPG static assets ---------------------------------------------------- + + private rpgPropertyName(prop: ts.PropertyAssignment, context: string): string { + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) return prop.name.text; + this.err(prop.name, `${context} keys must be identifiers or string literals`); + } + + private rpgObjectFields( + obj: ts.ObjectLiteralExpression, + context: string, + allowed: readonly string[], + required: readonly string[], + ): Map { + const fields = new Map(); + for (const prop of obj.properties) { + if (!ts.isPropertyAssignment(prop)) + this.err(prop, `${context} must use explicit \`name: value\` properties (no shorthand or spreads)`); + const name = this.rpgPropertyName(prop, context); + if (!allowed.includes(name)) + this.err(prop.name, `unknown ${context} property ${JSON.stringify(name)}; expected ${allowed.join(", ")}`); + if (fields.has(name)) this.err(prop.name, `duplicate ${context} property ${JSON.stringify(name)}`); + fields.set(name, prop.initializer); + } + for (const name of required) + if (!fields.has(name)) this.err(obj, `${context} is missing required property ${JSON.stringify(name)}`); + return fields; + } + + private rpgString(e: ts.Expression, context: string): string { + if (!ts.isStringLiteral(e)) this.err(e, `${context} must be a string literal`); + return e.text; + } + + /** RPG tile keys and dialogue text are byte-oriented in the GBA POC. */ + private assertRpgAscii(node: ts.Node, text: string, context: string): void { + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + this.err(node, `${context} must contain printable single-byte ASCII characters`); + } + } + + private scanRpgMap(name: string, call: ts.CallExpression): void { + if (this.target.name !== "gba") this.err(call, "Pocket Vapor RPG is currently supported only on the gba target"); + if (call.arguments.length !== 1 || !ts.isObjectLiteralExpression(call.arguments[0])) + this.err(call, "defineRpgMap() takes exactly one object literal"); + if (this.scope.has(name)) this.err(call, `duplicate binding ${name}`); + + const spec = this.rpgObjectFields( + call.arguments[0], + "RPG map", + ["rows", "solid", "events", "dialogs"], + ["rows", "solid", "events", "dialogs"], + ); + + const rowsExpr = spec.get("rows")!; + if (!ts.isArrayLiteralExpression(rowsExpr)) this.err(rowsExpr, "RPG map rows must be a string-literal array"); + if (rowsExpr.elements.length === 0) this.err(rowsExpr, "RPG map rows cannot be empty"); + if (rowsExpr.elements.length > 20) this.err(rowsExpr, "RPG map height exceeds the GBA limit of 20 rows"); + const rows = rowsExpr.elements.map((row, i) => { + if (!ts.isStringLiteral(row)) this.err(row, `RPG map row ${i} must be a string literal`); + this.assertRpgAscii(row, row.text, `RPG map row ${i}`); + return row.text; + }); + const width = rows[0].length; + if (width === 0) this.err(rowsExpr.elements[0], "RPG map rows cannot be empty strings"); + if (width > 30) this.err(rowsExpr.elements[0], "RPG map width exceeds the GBA limit of 30 columns"); + for (let i = 1; i < rows.length; i++) + if (rows[i].length !== width) + this.err( + rowsExpr.elements[i], + `RPG map rows must be equal width: row 0 is ${width}, row ${i} is ${rows[i].length}`, + ); + + const solidExpr = spec.get("solid")!; + const solid = this.rpgString(solidExpr, "RPG map solid"); + this.assertRpgAscii(solidExpr, solid, "RPG map solid"); + const seenSolid = new Set(); + for (const ch of solid) { + if (seenSolid.has(ch)) this.err(solidExpr, `RPG map solid contains duplicate tile ${JSON.stringify(ch)}`); + seenSolid.add(ch); + } + + const eventsExpr = spec.get("events")!; + if (!ts.isObjectLiteralExpression(eventsExpr)) + this.err(eventsExpr, "RPG map events must be an object of single-character keys to event ids"); + if (eventsExpr.properties.length > 255) this.err(eventsExpr, "RPG map supports at most 255 event tile keys"); + const events: RpgEventSrc[] = []; + const seenEventKeys = new Set(); + const flatRows = rows.join(""); + for (const prop of eventsExpr.properties) { + if (!ts.isPropertyAssignment(prop)) this.err(prop, "RPG event entries must use \`\"X\": id\`"); + const key = this.rpgPropertyName(prop, "RPG event"); + if (key.length !== 1) this.err(prop.name, "RPG event keys must be exactly one character"); + this.assertRpgAscii(prop.name, key, "RPG event key"); + if (seenEventKeys.has(key)) this.err(prop.name, `duplicate RPG event key ${JSON.stringify(key)}`); + seenEventKeys.add(key); + if (!flatRows.includes(key)) + this.err(prop.name, `RPG event key ${JSON.stringify(key)} does not occur in any map row`); + const event = this.constNum(prop.initializer); + if (event === null || !Number.isInteger(event) || event < 1 || event > 255) + this.err(prop.initializer, "RPG event ids must be compile-time integers in 1..255 (0 means no event)"); + events.push({ tile: key.charCodeAt(0), event }); + } + + const dialogsExpr = spec.get("dialogs")!; + if (!ts.isArrayLiteralExpression(dialogsExpr)) this.err(dialogsExpr, "RPG dialogs must be an object-literal array"); + if (dialogsExpr.elements.length > 255) this.err(dialogsExpr, "RPG maps support at most 255 dialogs"); + const dialogs = dialogsExpr.elements.map((dialog, i): RpgDialogSrc => { + if (!ts.isObjectLiteralExpression(dialog)) this.err(dialog, `RPG dialog ${i} must be an object literal`); + const fields = this.rpgObjectFields( + dialog, + `RPG dialog ${i}`, + ["speaker", "line1", "line2", "choice0", "choice1"], + ["speaker", "line1"], + ); + const read = (field: "speaker" | "line1" | "line2" | "choice0" | "choice1"): string => { + const value = fields.get(field); + if (!value) return ""; + const text = this.rpgString(value, `RPG dialog ${i}.${field}`); + this.assertRpgAscii(value, text, `RPG dialog ${i}.${field}`); + return text; + }; + return { + speaker: read("speaker"), + line1: read("line1"), + line2: read("line2"), + choice0: read("choice0"), + choice1: read("choice1"), + }; + }); + + const binding: RpgMapBinding = { + kind: "rpgMap", + name, + width, + height: rows.length, + rows, + solid, + events, + dialogs, + }; + this.rpgMaps.push(binding); + this.scope.set(name, binding); + } + // ---- setup scan ---------------------------------------------------------- private scanSetup(component: ts.ArrowFunction): void { @@ -484,7 +681,7 @@ class AppCompiler { ) { this.scanKeymap(name, init); } else { - this.scanModuleConst(stmt); + this.scanModuleConst(stmt, false); return; } } @@ -1166,8 +1363,39 @@ class AppCompiler { return cName; } + private rpgMapArg(e: ts.Expression, callName: string): RpgMapBinding { + e = this.unparen(e); + if (!ts.isIdentifier(e)) this.err(e, `${callName} map must be a defineRpgMap module const`); + const binding = this.scope.get(e.text); + if (binding?.kind !== "rpgMap") this.err(e, `${callName} map must be a defineRpgMap module const`); + return binding; + } + + private compileRpgQuery( + e: ts.CallExpression, + out: string[], + ind: string, + callName: "rpgBlocked" | "rpgEventAt", + ): { c: string; ty: Ty } { + if (this.target.name !== "gba") this.err(e, `${callName} is currently supported only on the gba target`); + if (e.arguments.length !== 3) this.err(e, `${callName}() takes (map, x, y)`); + const map = this.rpgMapArg(e.arguments[0], callName); + const x = this.compileExpr(e.arguments[1], out, ind); + const y = this.compileExpr(e.arguments[2], out, ind); + if (x.ty.k !== "num" || y.ty.k !== "num") this.err(e, `${callName} x and y must be numbers`); + const cName = callName === "rpgBlocked" ? "vp_rpg_blocked" : "vp_rpg_event_at"; + return { + c: `${cName}(&RPG_${map.name}, ${x.c}, ${y.c})`, + ty: callName === "rpgBlocked" ? BOOL : NUM, + }; + } + private compileCall(e: ts.CallExpression, out: string[], ind: string): { c: string; ty: Ty } { const callee = this.unparen(e.expression); + if (ts.isIdentifier(callee) && callee.text === this.hostRpgBlocked) + return this.compileRpgQuery(e, out, ind, "rpgBlocked"); + if (ts.isIdentifier(callee) && callee.text === this.hostRpgEventAt) + return this.compileRpgQuery(e, out, ind, "rpgEventAt"); // Integer-safe Math subset. Every compiled NUM is s32, so Math.trunc is // an identity after C integer division while preserving Vue-oracle parity. if ( @@ -1582,7 +1810,11 @@ class AppCompiler { continue; } if (ts.isJsxElement(rawChild) || ts.isJsxSelfClosingElement(rawChild)) { - units.push(this.compileRowUnit(rawChild)); + const tag = ts.isJsxElement(rawChild) + ? rawChild.openingElement.tagName.getText(this.sf) + : rawChild.tagName.getText(this.sf); + if (this.hostRpgScreen && tag === this.hostRpgScreen) units.push(this.compileRpgScreenUnit(rawChild)); + else units.push(this.compileRowUnit(rawChild)); continue; } if (ts.isJsxExpression(rawChild)) { @@ -1851,6 +2083,75 @@ class AppCompiler { return { span: [y, y + 1], deps, decls, body, isStatic: false }; } + private compileRpgScreenUnit(el: ts.JsxElement | ts.JsxSelfClosingElement): { + span: [number, number]; + deps: Set; + decls: string[]; + body: string[]; + isStatic: boolean; + } { + if (this.target.name !== "gba") this.err(el, "RpgScreen is currently supported only on the gba target"); + if (!ts.isJsxSelfClosingElement(el)) this.err(el, "RpgScreen must be a self-closing element"); + if (this.rpgScreenCount > 0) this.err(el, "only one RpgScreen is supported per component"); + this.rpgScreenCount++; + + const required = [ + "map", + "mode", + "playerX", + "playerY", + "facing", + "quest", + "dialog", + "choice", + "heroHp", + "enemyHp", + "battleCursor", + ] as const; + type RpgScreenProp = (typeof required)[number]; + const attrs = new Map(); + for (const prop of el.attributes.properties) { + if (!ts.isJsxAttribute(prop)) this.err(prop, "RpgScreen does not support spread props"); + const name = prop.name.getText(this.sf); + if (!(required as readonly string[]).includes(name)) + this.err(prop, `unknown RpgScreen prop ${JSON.stringify(name)}; expected exactly ${required.join(", ")}`); + const typedName = name as RpgScreenProp; + if (attrs.has(typedName)) this.err(prop, `duplicate RpgScreen prop ${JSON.stringify(name)}`); + attrs.set(typedName, this.attrExpr(prop)); + } + for (const name of required) + if (!attrs.has(name)) this.err(el, `RpgScreen is missing required prop ${JSON.stringify(name)}`); + + const map = this.rpgMapArg(attrs.get("map")!, "RpgScreen"); + const deps = new Set(); + const prev = this.curDeps; + this.curDeps = deps; + const { decls, body } = this.withHoist((out) => { + const readNumber = (name: Exclude): { c: string; ty: Ty } => { + const value = this.compileExpr(attrs.get(name)!, out, " "); + if (value.ty.k !== "num") this.err(attrs.get(name)!, `RpgScreen ${name} must be a number`); + return value; + }; + const mode = readNumber("mode"); + const playerX = readNumber("playerX"); + const playerY = readNumber("playerY"); + const facing = readNumber("facing"); + const quest = readNumber("quest"); + const dialog = readNumber("dialog"); + const choice = readNumber("choice"); + const heroHp = readNumber("heroHp"); + const enemyHp = readNumber("enemyHp"); + const battleCursor = readNumber("battleCursor"); + out.push( + ` vp_rpg_render(&RPG_${map.name}, (u8)(${mode.c}), ${playerX.c}, ${playerY.c}, ` + + `(u8)(${facing.c}), ${quest.c}, ${dialog.c}, ${choice.c}, ${heroHp.c}, ${enemyHp.c}, ` + + `${battleCursor.c});`, + ); + }); + this.curDeps = prev; + return { span: [0, this.target.height], deps, decls, body, isStatic: false }; + } + private compileMapUnit(call: ts.CallExpression): { span: [number, number]; deps: Set; @@ -1927,6 +2228,48 @@ class AppCompiler { this.err(yExpr, "map row y must be `i` or `CONST + i`"); } + private emitRpgData(): string { + const out: string[] = []; + for (const map of this.rpgMaps) { + const prefix = `RPG_${map.name}`; + const tiles = map.rows.join(""); + const solidSet = new Set(map.solid.split("")); + const solidMask = tiles.split("").map((tile) => (solidSet.has(tile) ? 1 : 0)); + out.push(`static const u8 ${prefix}_tiles[] = "${this.escC(tiles)}";`); + out.push(`static const u8 ${prefix}_solid[${solidMask.length}] = { ${solidMask.join(", ")} };`); + + if (map.events.length > 0) { + out.push( + `static const vp_rpg_event ${prefix}_events[${map.events.length}] = { ${map.events + .map((event) => `{ ${event.tile}, ${event.event} }`) + .join(", ")} };`, + ); + } else { + out.push(`static const vp_rpg_event ${prefix}_events[1] = { { 0, 0 } };`); + } + + if (map.dialogs.length > 0) { + out.push( + `static const vp_rpg_dialog ${prefix}_dialogs[${map.dialogs.length}] = { ${map.dialogs + .map( + (dialog) => + `{ ${this.cStrLit(dialog.speaker)}, ${this.cStrLit(dialog.line1)}, ${this.cStrLit(dialog.line2)}, ` + + `${this.cStrLit(dialog.choice0)}, ${this.cStrLit(dialog.choice1)} }`, + ) + .join(", ")} };`, + ); + } else { + out.push(`static const vp_rpg_dialog ${prefix}_dialogs[1] = { { 0, 0, 0, 0, 0 } };`); + } + + out.push( + `static const vp_rpg_map ${prefix} = { ${map.width}, ${map.height}, ${prefix}_tiles, ${prefix}_solid, ` + + `${prefix}_events, ${map.events.length}, ${prefix}_dialogs, ${map.dialogs.length} };`, + ); + } + return out.join("\n"); + } + // ---- final emission ------------------------------------------------------- private emit(): CompiledApp { @@ -1977,6 +2320,8 @@ class AppCompiler { } const { inits, effects } = this.emitTemplate(); + // Collect RPG dialogue literals before the shared string table is emitted. + const rpgData = this.emitRpgData(); // seed + init const initOut: string[] = []; @@ -2067,6 +2412,7 @@ class AppCompiler { "static inline const char *VP_UNUSED_FN vp_cstr_at(const char *const *arr, s32 n, s32 i) { return (i >= 0 && i < n) ? arr[i] : (const char *)\"\"; }", ); c.push("static inline char VP_UNUSED_FN vp_char_at(const char *s, s32 n, s32 i) { return (i >= 0 && i < n) ? s[i] : ' '; }"); + c.push(`const u8 vp_rpg_enabled = ${this.rpgMaps.length > 0 ? 1 : 0};`); c.push(""); // record structs @@ -2093,6 +2439,10 @@ class AppCompiler { // string literals for (const [text, name] of this.strLits) c.push(`static const char ${name}[] = "${this.escC(text)}";`); c.push(""); + if (rpgData) { + c.push(rpgData); + c.push(""); + } c.push(this.decls.join("\n")); c.push(""); c.push(this.bodies.join("\n")); @@ -2164,6 +2514,14 @@ class AppCompiler { // ---- reports ---- const graphLines: string[] = []; + if (this.rpgMaps.length > 0) { + graphLines.push("rpg maps:"); + for (const map of this.rpgMaps) + graphLines.push( + ` ${map.name}: ${map.width}x${map.height}, ${map.events.length} event keys, ${map.dialogs.length} dialogs`, + ); + graphLines.push(`rpg screen effects: ${this.rpgScreenCount} full-screen runtime effect(s)`); + } graphLines.push("refs:"); for (const r of this.refs) graphLines.push(` bit ${r.index}: ${r.name} (${r.refTy})`); graphLines.push("computeds:"); @@ -2225,10 +2583,20 @@ class AppCompiler { `reactive tables: ${this.refs.length} dirty bits, ${this.computeds.length} validity bits, ${effects.length} effects`, `ROM data: ${romStrings} B strings + ${fontBytes} B font + ${styleBytes} B style data`, ]; + if (this.rpgMaps.length > 0) { + const tileCount = this.rpgMaps.reduce((sum, map) => sum + map.width * map.height, 0); + const eventCount = this.rpgMaps.reduce((sum, map) => sum + map.events.length, 0); + const dialogCount = this.rpgMaps.reduce((sum, map) => sum + map.dialogs.length, 0); + planLines.push( + `RPG assets: ${this.rpgMaps.length} map(s), ${tileCount} tiles + collision bytes, ${eventCount} events, ${dialogCount} dialogs; ${this.rpgScreenCount} full-screen effect(s)`, + ); + planLines.push("RPG host RAM: 3076 B fixed BG1 tilemap + OAM shadow buffers"); + } return { c: c.join("\n"), title: this.title, + rpgEnabled: this.rpgMaps.length > 0, graph: graphLines.join("\n"), plan: planLines.join("\n"), debugSlots, diff --git a/vapor/compiler/rom.ts b/vapor/compiler/rom.ts index 0aeca0a0..03e258c6 100644 --- a/vapor/compiler/rom.ts +++ b/vapor/compiler/rom.ts @@ -125,7 +125,9 @@ export async function buildGbaRom(app: CompiledApp, outRom: string): Promise<{ r const gbaDir = join(RUNTIME, "gba"); const elf = join(genDir, "app.elf"); const defines = targetDefines("gba"); - await $`arm-none-eabi-gcc -mcpu=arm7tdmi -mthumb-interwork -marm -ffreestanding -nostdlib -Os -fno-strict-aliasing -Wall -Werror=implicit-function-declaration ${defines} -I${RUNTIME} -I${gbaDir} -T${gbaDir}/gba.ld ${gbaDir}/crt0.s ${RUNTIME}/vapor_core.c ${gbaDir}/vapor_gba.c ${genC} -lgcc -o ${elf}`; + const rpgDefine = app.rpgEnabled ? ["-DVP_ENABLE_RPG=1"] : []; + const rpgSource = app.rpgEnabled ? [join(gbaDir, "vapor_rpg.c")] : []; + await $`arm-none-eabi-gcc -mcpu=arm7tdmi -mthumb-interwork -marm -ffreestanding -nostdlib -Os -fno-strict-aliasing -Wall -Werror=implicit-function-declaration ${defines} ${rpgDefine} -I${RUNTIME} -I${gbaDir} -T${gbaDir}/gba.ld ${gbaDir}/crt0.s ${RUNTIME}/vapor_core.c ${rpgSource} ${gbaDir}/vapor_gba.c ${genC} -lgcc -o ${elf}`; await $`arm-none-eabi-objcopy -O binary ${elf} ${outRom}`; const rom = new Uint8Array(await Bun.file(outRom).arrayBuffer()); diff --git a/vapor/examples/rpg/rpg.tsx b/vapor/examples/rpg/rpg.tsx new file mode 100644 index 00000000..72f4d8a2 --- /dev/null +++ b/vapor/examples/rpg/rpg.tsx @@ -0,0 +1,291 @@ +// POCKET VAPOR RPG — one complete reactive gameplay loop for the GBA host. +// +// All mutable game state is ordinary Vue ref/computed state. Under Pocket +// Vapor it becomes fixed native C slots and functions; the host module owns +// only static map data, collision/event lookup, and rendering. There is no JS +// engine, hidden RPG state machine, device SDK input, or fake crank button. +// +// Test route: spawn (2,2); Right, Down, Right turns into the solid Elder at +// (4,3), then A talks. Choose YES and press A again to close. Up, then Right +// x5 reaches the Slime at (8,2). Select ATTACK and press A three times. Close +// the victory dialog, then return and face the Elder with Left x5, Down, +// Right, A to complete the quest. + +import { computed, ref } from "vue"; +import { Button, onButton } from "../../host/input.ts"; +import { + defineRpgMap, + RpgScreen, + rpgBlocked, + rpgEventAt, +} from "../../host/rpg.ts"; + +type Keymap = Record void>; + +const Mode = { + World: 0, + Dialog: 1, + Battle: 2, +}; + +const Facing = { + Down: 0, + Up: 1, + Left: 2, + Right: 3, +}; + +const Quest = { + NotStarted: 0, + Accepted: 1, + Won: 2, + Complete: 3, +}; + +const Dialog = { + None: 0, + Offer: 1, + Accepted: 2, + Declined: 3, + Victory: 4, + Complete: 5, +}; + +const Event = { + None: 0, + Elder: 1, + Slime: 2, +}; + +const RPG_MAP = defineRpgMap({ + rows: [ + "##############################", + "#....T......~~~~......T......#", + "#.......S....==..............#", + "#...N........==....T.........#", + "#............==..............#", + "#....######..==..~~~~........#", + "#............==..~~~~........#", + "#..**........==........T.....#", + "#............======..........#", + "#......T.....................#", + "#............~~~~............#", + "#............~~~~....**......#", + "#..T.........................#", + "#........=====...............#", + "#........=...=.......T.......#", + "#..~~~~..=====...............#", + "#..~~~~..................**..#", + "#..............T.............#", + "#............................#", + "##############################", + ], + solid: "#NT~", + events: { + N: Event.Elder, + S: Event.Slime, + }, + // Dialog ids are one-based: Dialog.Offer selects dialogs[0]. + dialogs: [ + { + speaker: "ELDER", + line1: "SLIME BLOCKS THE EAST ROAD.", + line2: "WILL YOU HELP?", + choice0: "YES", + choice1: "NO", + }, + { + speaker: "ELDER", + line1: "THANK YOU, BRAVE TRAVELER.", + line2: "FOLLOW THE ROAD EAST.", + }, + { + speaker: "ELDER", + line1: "COME BACK WHEN READY.", + }, + { + speaker: "HERO", + line1: "THE SLIME IS DEFEATED!", + line2: "REPORT TO THE ELDER.", + }, + { + speaker: "ELDER", + line1: "THE VILLAGE IS SAFE AGAIN.", + line2: "QUEST COMPLETE!", + }, + ], +}); + +export default () => { + // Ten refs: comfortably inside Pocket Vapor's current 16-ref budget. + const mode = ref(0); + const playerX = ref(2); + const playerY = ref(2); + const facing = ref(0); + const quest = ref(0); + const dialog = ref(0); + const choice = ref(0); + const heroHp = ref(30); + const enemyHp = ref(18); + const battleCursor = ref(0); + + const questActive = computed(() => quest.value === Quest.Accepted); + + function openDialog(id: number) { + dialog.value = id; + choice.value = 0; + mode.value = Mode.Dialog; + } + + function closeDialog() { + dialog.value = Dialog.None; + choice.value = 0; + mode.value = Mode.World; + } + + function startBattle() { + heroHp.value = 30; + enemyHp.value = 18; + battleCursor.value = 0; + mode.value = Mode.Battle; + } + + function handleEvent(event: number) { + if (event === Event.Elder) { + if (quest.value === Quest.NotStarted) { + openDialog(Dialog.Offer); + } else if (quest.value === Quest.Accepted) { + openDialog(Dialog.Accepted); + } else if (quest.value === Quest.Won) { + quest.value = Quest.Complete; + openDialog(Dialog.Complete); + } else { + openDialog(Dialog.Complete); + } + } else if (event === Event.Slime) { + if (questActive.value) startBattle(); + } + } + + function movePlayer(dx: number, dy: number, nextFacing: number) { + facing.value = nextFacing; + const nextX = playerX.value + dx; + const nextY = playerY.value + dy; + if (!rpgBlocked(RPG_MAP, nextX, nextY)) { + playerX.value = nextX; + playerY.value = nextY; + const event = rpgEventAt(RPG_MAP, nextX, nextY); + if (event !== Event.None) handleEvent(event); + } + } + + function moveChoice(delta: number) { + if (dialog.value === Dialog.Offer) { + choice.value = (choice.value + delta + 2) % 2; + } + } + + function interact() { + if (facing.value === Facing.Up) { + handleEvent(rpgEventAt(RPG_MAP, playerX.value, playerY.value - 1)); + } else if (facing.value === Facing.Down) { + handleEvent(rpgEventAt(RPG_MAP, playerX.value, playerY.value + 1)); + } else if (facing.value === Facing.Left) { + handleEvent(rpgEventAt(RPG_MAP, playerX.value - 1, playerY.value)); + } else { + handleEvent(rpgEventAt(RPG_MAP, playerX.value + 1, playerY.value)); + } + } + + function advanceDialog() { + if (dialog.value === Dialog.Offer) { + if (choice.value === 0) { + quest.value = Quest.Accepted; + dialog.value = Dialog.Accepted; + } else { + dialog.value = Dialog.Declined; + } + choice.value = 0; + } else { + closeDialog(); + } + } + + function enemyTurn() { + heroHp.value = Math.max(0, heroHp.value - 4); + if (heroHp.value === 0) { + heroHp.value = 30; + enemyHp.value = 18; + mode.value = Mode.World; + } + } + + function attack() { + enemyHp.value = Math.max(0, enemyHp.value - 6); + if (enemyHp.value === 0) { + quest.value = Quest.Won; + openDialog(Dialog.Victory); + } else { + enemyTurn(); + } + } + + function heal() { + heroHp.value = Math.min(30, heroHp.value + 8); + enemyTurn(); + } + + const worldKeys: Keymap = { + [Button.Up]: () => movePlayer(0, -1, Facing.Up), + [Button.Down]: () => movePlayer(0, 1, Facing.Down), + [Button.Left]: () => movePlayer(-1, 0, Facing.Left), + [Button.Right]: () => movePlayer(1, 0, Facing.Right), + [Button.A]: interact, + }; + + const dialogKeys: Keymap = { + [Button.Up]: () => moveChoice(-1), + [Button.Down]: () => moveChoice(1), + [Button.A]: advanceDialog, + }; + + const battleKeys: Keymap = { + [Button.Up]: () => { + battleCursor.value = 0; + }, + [Button.Down]: () => { + battleCursor.value = 1; + }, + [Button.A]: () => { + if (battleCursor.value === 0) attack(); + else heal(); + }, + }; + + onButton( + (button) => + (mode.value === Mode.Dialog + ? dialogKeys + : mode.value === Mode.Battle + ? battleKeys + : worldKeys)[button]?.(), + ); + + return ( + <> + + + ); +}; diff --git a/vapor/host/rpg.ts b/vapor/host/rpg.ts new file mode 100644 index 00000000..d501f5b8 --- /dev/null +++ b/vapor/host/rpg.ts @@ -0,0 +1,113 @@ +// vapor/host/rpg.ts — the Pocket Vapor RPG host contract. +// +// Like the other Vapor host modules, this file has two lives. The JS/oracle +// path uses the concrete map helpers below. The AOT compiler recognizes the +// same declarations, queries, and RpgScreen component and lowers them to the +// fixed native RPG runtime. Gameplay state never lives here: RpgScreen is a +// pure host boundary whose complete state is supplied through props. + +export interface RpgDialog { + speaker: string; + line1: string; + line2?: string; + choice0?: string; + choice1?: string; +} + +export interface RpgMapDefinition { + rows: readonly string[]; + solid: string; + events: Record; + dialogs: readonly RpgDialog[]; +} + +export interface RpgMap extends RpgMapDefinition { + readonly width: number; + readonly height: number; +} + +export interface RpgScreenProps { + map: RpgMap; + mode: number; + playerX: number; + playerY: number; + facing: number; + quest: number; + dialog: number; + choice: number; + heroHp: number; + enemyHp: number; + battleCursor: number; +} + +/** + * Declare one static, cell-addressed RPG map. + * + * GBA's logical viewport is at most 30x20 cells. Every event is attached to + * one map character so the same source drives oracle lookup and native ROM + * tables without a second coordinate convention. + */ +export function defineRpgMap(definition: RpgMapDefinition): RpgMap { + const height = definition.rows.length; + if (height === 0) throw new Error("RPG map needs at least one row"); + if (height > 20) throw new Error(`RPG map height ${height} exceeds the 20-row GBA viewport`); + + const width = definition.rows[0]!.length; + if (width === 0) throw new Error("RPG map rows must not be empty"); + if (width > 30) throw new Error(`RPG map width ${width} exceeds the 30-column GBA viewport`); + + for (let y = 1; y < height; y++) { + const rowWidth = definition.rows[y]!.length; + if (rowWidth !== width) { + throw new Error(`RPG map row ${y} has width ${rowWidth}; expected ${width}`); + } + } + + const eventEntries = Object.entries(definition.events); + if (eventEntries.length > 255) throw new Error("RPG map supports at most 255 event tile keys"); + for (const [tile, event] of eventEntries) { + if (tile.length !== 1) throw new Error(`RPG event key ${JSON.stringify(tile)} must be one character`); + if (!definition.rows.some((row) => row.includes(tile))) { + throw new Error(`RPG event key ${JSON.stringify(tile)} does not appear in the map`); + } + if (!Number.isInteger(event) || event < 1 || event > 255) { + throw new Error(`RPG event for ${JSON.stringify(tile)} must be an integer from 1 to 255`); + } + } + + if (definition.dialogs.length > 255) throw new Error("RPG map supports at most 255 dialogs"); + + return { + rows: definition.rows, + solid: definition.solid, + events: definition.events, + dialogs: definition.dialogs, + width, + height, + }; +} + +/** Out-of-bounds coordinates are solid, so movement fails closed. */ +export function rpgBlocked(map: RpgMap, x: number, y: number): boolean { + if (!Number.isInteger(x) || !Number.isInteger(y)) return true; + if (x < 0 || y < 0 || x >= map.width || y >= map.height) return true; + return map.solid.includes(map.rows[y]![x]!); +} + +/** Return the event attached to a map cell, or zero for no event. */ +export function rpgEventAt(map: RpgMap, x: number, y: number): number { + if (!Number.isInteger(x) || !Number.isInteger(y)) return 0; + if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 0; + return map.events[map.rows[y]![x]!] ?? 0; +} + +/** + * Stateless RPG render boundary. + * + * The browser oracle may replace this with a visible renderer. Pocket Vapor + * lowers it to the target's native tile/sprite renderer. Keeping the JS body + * empty is intentional: every observable gameplay input is explicit in props. + */ +export function RpgScreen(_props: RpgScreenProps): null { + return null; +} diff --git a/vapor/runtime/gba/vapor_gba.c b/vapor/runtime/gba/vapor_gba.c index 615f2127..4bb487a2 100644 --- a/vapor/runtime/gba/vapor_gba.c +++ b/vapor/runtime/gba/vapor_gba.c @@ -1,9 +1,9 @@ /* vapor/runtime/gba/vapor_gba.c — the GBA half of the runtime. * - * Mode 0, BG0 only: the 30x20 cell grid from vapor_core.c rendered as font - * tiles with real per-bank palettes. Cells diff at write time and mark row - * bits; the commit after vblank copies only dirty rows into the - * screenblock. The debug block is mirrored to EWRAM each frame. + * Mode 0 always keeps the 30x20 BG0 cell grid from vapor_core.c. RPG apps + * additionally enable the fixed BG1 tile-world + OBJ host; their generated + * refs still own every gameplay transition. Commits happen after vblank and + * the debug block is mirrored to EWRAM each frame. */ #include "vapor.h" @@ -96,6 +96,9 @@ int main(void) { for (i = 0; i < (u16)(vp_palette_count * 16); i++) PAL_BG[i] = vp_palettes[i]; PAL_BG[0] = vp_backdrop; upload_font(); +#if defined(VP_ENABLE_RPG) + if (vp_rpg_enabled) vp_rpg_video_init(); +#endif REG_BG0CNT = (SB_MAP << 8) | 0; /* 4bpp, charblock 0, priority 0 */ REG_BG0HOFS = 0; REG_BG0VOFS = 0; @@ -105,13 +108,21 @@ int main(void) { app_flush(); flushes++; - REG_DISPCNT = 0x0100; /* mode 0, BG0 on */ + /* RPG: mode 0 + 1D OBJ mapping + BG0/BG1/OBJ. Text apps remain BG0-only. */ +#if defined(VP_ENABLE_RPG) + REG_DISPCNT = vp_rpg_enabled ? 0x1340 : 0x0100; +#else + REG_DISPCNT = 0x0100; +#endif for (;;) { u16 keys, edges; u8 b; vsync(); commit_rows(); +#if defined(VP_ENABLE_RPG) + if (vp_rpg_enabled) vp_rpg_video_commit(); +#endif frame++; debug_commit(frame, flushes); diff --git a/vapor/runtime/gba/vapor_rpg.c b/vapor/runtime/gba/vapor_rpg.c new file mode 100644 index 00000000..44923ceb --- /dev/null +++ b/vapor/runtime/gba/vapor_rpg.c @@ -0,0 +1,585 @@ +/* vapor/runtime/gba/vapor_rpg.c -- fixed GBA pixel host for Pocket Vapor RPGs. + * + * Gameplay remains compiler-generated reactive C. This file owns only ROM + * map queries and a small Mode 0 renderer: BG1 is the pixel world, BG0 is the + * existing (now transparent) Vapor font, and OBJ contains the actors. + */ +#include "vapor.h" + +#define REG16(addr) (*(volatile u16 *)(addr)) +#define REG_DISPCNT REG16(0x04000000) +#define REG_BG1CNT REG16(0x0400000a) +#define REG_BG1HOFS REG16(0x04000014) +#define REG_BG1VOFS REG16(0x04000016) + +#define PAL_BG ((volatile u16 *)0x05000000) +#define PAL_OBJ ((volatile u16 *)0x05000200) +#define FONT_VRAM ((volatile u16 *)0x06000000) +#define RPG_BG_VRAM ((volatile u16 *)0x06008000) /* charblock 2 */ +#define RPG_BG_MAP ((volatile u16 *)0x06004800) /* screenblock 9 */ +#define RPG_OBJ_VRAM ((volatile u16 *)0x06010000) +#define OAM ((volatile u16 *)0x07000000) + +#define RPG_BG_BANK 15 +#define RPG_ENTRY(tile) ((u16)((tile) | (RPG_BG_BANK << 12))) +#define RPG_OBJ_PRIORITY (1 << 10) + +enum { + TILE_BLANK, + TILE_GRASS_A, + TILE_GRASS_B, + TILE_PATH_A, + TILE_PATH_B, + TILE_WALL, + TILE_WATER_A, + TILE_WATER_B, + TILE_TREE, + TILE_FLOWER, + TILE_BOX_FILL, + TILE_BOX_TOP, + TILE_BOX_BOTTOM, + TILE_BOX_LEFT, + TILE_BOX_RIGHT, + TILE_BOX_TL, + TILE_BOX_TR, + TILE_BOX_BL, + TILE_BOX_BR, + TILE_BATTLE_SKY, + TILE_BATTLE_GROUND, + TILE_HP_EMPTY, + TILE_HP_FULL, + TILE_HUD, + TILE_COUNT +}; + +enum { + C_CLEAR, + C_DARK, + C_GRASS_DARK, + C_GRASS, + C_PATH_DARK, + C_PATH, + C_WALL_DARK, + C_WALL, + C_WATER_DARK, + C_WATER, + C_TREE_DARK, + C_TREE, + C_GOLD, + C_BOX, + C_BORDER, + C_ACCENT +}; + +static u16 rpg_bg_shadow[32 * 32]; +static u16 rpg_oam_shadow[128 * 4]; +static u8 rpg_bg_dirty; +static u8 rpg_oam_dirty; +static u8 rpg_ready; + +static void pixels_clear(u8 *pixels, u16 count, u8 color) { + u16 i; + for (i = 0; i < count; i++) pixels[i] = color; +} + +static void pixels_rect(u8 *pixels, u8 stride, s16 x0, s16 y0, + s16 x1, s16 y1, u8 color) { + s16 x, y; + for (y = y0; y <= y1; y++) + for (x = x0; x <= x1; x++) + if (x >= 0 && x < stride && y >= 0 && y < stride) + pixels[(u16)y * stride + (u16)x] = color; +} + +static void pixel(u8 *pixels, u8 stride, s16 x, s16 y, u8 color) { + if (x >= 0 && x < stride && y >= 0 && y < stride) + pixels[(u16)y * stride + (u16)x] = color; +} + +static void upload_bg_tile(u8 tile, const u8 *pixels) { + volatile u16 *dst = RPG_BG_VRAM + (u16)tile * 16; + u16 i; + for (i = 0; i < 16; i++) { + u16 p = (u16)i * 4; + dst[i] = (u16)(pixels[p] | ((u16)pixels[p + 1] << 4) | + ((u16)pixels[p + 2] << 8) | + ((u16)pixels[p + 3] << 12)); + } +} + +static void upload_obj(u8 base, const u8 *pixels) { + u8 tx, ty, row, col; + for (ty = 0; ty < 2; ty++) { + for (tx = 0; tx < 2; tx++) { + volatile u16 *dst = RPG_OBJ_VRAM + + (u16)(base + ty * 2 + tx) * 16; + for (row = 0; row < 8; row++) { + for (col = 0; col < 8; col += 4) { + u16 at = (u16)(ty * 8 + row) * 16 + tx * 8 + col; + *dst++ = (u16)(pixels[at] | + ((u16)pixels[at + 1] << 4) | + ((u16)pixels[at + 2] << 8) | + ((u16)pixels[at + 3] << 12)); + } + } + } + } +} + +static void make_bg_tile(u8 tile, u8 *p) { + u8 x, y; + pixels_clear(p, 64, C_CLEAR); + switch (tile) { + case TILE_BLANK: + pixels_clear(p, 64, C_DARK); + break; + case TILE_GRASS_A: + case TILE_GRASS_B: + pixels_clear(p, 64, C_GRASS); + pixel(p, 8, tile == TILE_GRASS_A ? 1 : 5, 2, C_GRASS_DARK); + pixel(p, 8, tile == TILE_GRASS_A ? 6 : 2, 6, C_GRASS_DARK); + pixel(p, 8, tile == TILE_GRASS_A ? 2 : 6, 7, C_GRASS_DARK); + break; + case TILE_PATH_A: + case TILE_PATH_B: + pixels_clear(p, 64, C_PATH); + pixel(p, 8, tile == TILE_PATH_A ? 1 : 6, 1, C_PATH_DARK); + pixel(p, 8, tile == TILE_PATH_A ? 6 : 2, 5, C_PATH_DARK); + pixel(p, 8, 3, 7, C_PATH_DARK); + break; + case TILE_WALL: + pixels_clear(p, 64, C_WALL); + for (x = 0; x < 8; x++) { + p[3 * 8 + x] = C_WALL_DARK; + p[7 * 8 + x] = C_WALL_DARK; + } + for (y = 0; y < 3; y++) p[y * 8 + 3] = C_WALL_DARK; + for (y = 4; y < 7; y++) p[y * 8 + 6] = C_WALL_DARK; + break; + case TILE_WATER_A: + case TILE_WATER_B: + pixels_clear(p, 64, C_WATER); + y = tile == TILE_WATER_A ? 2 : 5; + for (x = 0; x < 3; x++) p[(u16)y * 8 + x] = C_WATER_DARK; + for (x = 5; x < 8; x++) p[(u16)(7 - y) * 8 + x] = C_WATER_DARK; + break; + case TILE_TREE: + pixels_clear(p, 64, C_GRASS); + pixels_rect(p, 8, 3, 4, 4, 7, C_PATH_DARK); + pixels_rect(p, 8, 1, 1, 6, 5, C_TREE_DARK); + pixels_rect(p, 8, 2, 0, 5, 4, C_TREE); + pixel(p, 8, 2, 2, C_GRASS); + pixel(p, 8, 5, 4, C_TREE_DARK); + break; + case TILE_FLOWER: + pixels_clear(p, 64, C_GRASS); + pixel(p, 8, 3, 2, C_BORDER); + pixel(p, 8, 2, 3, C_BORDER); + pixel(p, 8, 4, 3, C_BORDER); + pixel(p, 8, 3, 4, C_GOLD); + pixel(p, 8, 3, 5, C_TREE_DARK); + pixel(p, 8, 3, 6, C_TREE_DARK); + break; + case TILE_BOX_FILL: + pixels_clear(p, 64, C_BOX); + break; + case TILE_BOX_TOP: + case TILE_BOX_BOTTOM: + pixels_clear(p, 64, C_BOX); + y = tile == TILE_BOX_TOP ? 0 : 7; + for (x = 0; x < 8; x++) p[(u16)y * 8 + x] = C_BORDER; + break; + case TILE_BOX_LEFT: + case TILE_BOX_RIGHT: + pixels_clear(p, 64, C_BOX); + x = tile == TILE_BOX_LEFT ? 0 : 7; + for (y = 0; y < 8; y++) p[(u16)y * 8 + x] = C_BORDER; + break; + case TILE_BOX_TL: + case TILE_BOX_TR: + case TILE_BOX_BL: + case TILE_BOX_BR: + pixels_clear(p, 64, C_BOX); + if (tile == TILE_BOX_TL || tile == TILE_BOX_BL) x = 0; + else x = 7; + if (tile == TILE_BOX_TL || tile == TILE_BOX_TR) y = 0; + else y = 7; + pixels_rect(p, 8, x, 0, x, 7, C_BORDER); + pixels_rect(p, 8, 0, y, 7, y, C_BORDER); + break; + case TILE_BATTLE_SKY: + pixels_clear(p, 64, C_WATER_DARK); + pixel(p, 8, 1, 1, C_BORDER); + pixel(p, 8, 6, 4, C_WATER); + break; + case TILE_BATTLE_GROUND: + pixels_clear(p, 64, C_GRASS_DARK); + for (x = 0; x < 8; x += 2) p[6 * 8 + x] = C_TREE_DARK; + break; + case TILE_HP_EMPTY: + pixels_clear(p, 64, C_DARK); + pixels_rect(p, 8, 0, 2, 7, 5, C_WALL_DARK); + pixels_rect(p, 8, 1, 3, 6, 4, C_WALL); + break; + case TILE_HP_FULL: + pixels_clear(p, 64, C_DARK); + pixels_rect(p, 8, 0, 2, 7, 5, C_TREE_DARK); + pixels_rect(p, 8, 1, 3, 6, 4, C_GOLD); + break; + case TILE_HUD: + pixels_clear(p, 64, C_DARK); + for (x = 0; x < 8; x++) p[7 * 8 + x] = C_GOLD; + break; + } +} + +static void make_hero(u8 facing, u8 *p) { + pixels_clear(p, 256, 0); + pixels_rect(p, 16, 5, 1, 10, 6, 1); + pixels_rect(p, 16, 6, 2, 9, 6, 2); + pixels_rect(p, 16, 4, 7, 11, 12, 3); + pixels_rect(p, 16, 3, 8, 4, 11, 2); + pixels_rect(p, 16, 11, 8, 12, 11, 2); + pixels_rect(p, 16, 5, 13, 7, 15, 1); + pixels_rect(p, 16, 9, 13, 11, 15, 1); + pixels_rect(p, 16, 5, 7, 10, 8, 4); + if (facing == 0) { + pixel(p, 16, 7, 4, 1); + pixel(p, 16, 9, 4, 1); + } else if (facing == 1) { + pixels_rect(p, 16, 5, 1, 10, 4, 1); + pixel(p, 16, 6, 5, 4); + pixel(p, 16, 9, 5, 4); + } else if (facing == 2) { + pixels_rect(p, 16, 5, 2, 6, 5, 1); + pixel(p, 16, 6, 4, 2); + pixel(p, 16, 5, 4, 1); + } else { + pixels_rect(p, 16, 9, 2, 10, 5, 1); + pixel(p, 16, 9, 4, 2); + pixel(p, 16, 10, 4, 1); + } +} + +static void make_elder(u8 *p) { + pixels_clear(p, 256, 0); + pixels_rect(p, 16, 5, 1, 10, 6, 1); + pixels_rect(p, 16, 6, 2, 9, 5, 2); + pixel(p, 16, 7, 4, 1); + pixel(p, 16, 9, 4, 1); + pixels_rect(p, 16, 5, 6, 10, 9, 4); + pixels_rect(p, 16, 4, 8, 11, 14, 3); + pixels_rect(p, 16, 5, 15, 7, 15, 1); + pixels_rect(p, 16, 9, 15, 11, 15, 1); + pixels_rect(p, 16, 3, 9, 4, 12, 2); + pixels_rect(p, 16, 11, 9, 12, 12, 2); +} + +static void make_slime(u8 *p) { + pixels_clear(p, 256, 0); + pixels_rect(p, 16, 4, 8, 11, 13, 1); + pixels_rect(p, 16, 5, 6, 10, 14, 2); + pixels_rect(p, 16, 3, 10, 12, 13, 2); + pixels_rect(p, 16, 4, 13, 11, 14, 3); + pixel(p, 16, 6, 9, 1); + pixel(p, 16, 9, 9, 1); + pixel(p, 16, 7, 11, 4); + pixel(p, 16, 8, 11, 4); +} + +static void upload_transparent_font(void) { + volatile u16 *dst = FONT_VRAM + 16; /* tile zero remains transparent */ + u16 i; + for (i = 0; i < 95 * 16; i++) { + u8 a = vp_font_tiles[(u16)i * 2]; + u8 b = vp_font_tiles[(u16)i * 2 + 1]; + u8 lo = (u8)(a & 15); + u8 hi = (u8)(a >> 4); + if (lo == 2) lo = 0; + if (hi == 2) hi = 0; + a = (u8)(lo | (hi << 4)); + lo = (u8)(b & 15); + hi = (u8)(b >> 4); + if (lo == 2) lo = 0; + if (hi == 2) hi = 0; + b = (u8)(lo | (hi << 4)); + dst[i] = (u16)(a | ((u16)b << 8)); + } +} + +static void hide_objects(void) { + u16 i; + for (i = 0; i < 128; i++) { + rpg_oam_shadow[i * 4] = 0x0200; + rpg_oam_shadow[i * 4 + 1] = 0; + rpg_oam_shadow[i * 4 + 2] = 0; + rpg_oam_shadow[i * 4 + 3] = 0; + } + rpg_oam_dirty = 1; +} + +static void show_object(u8 slot, s16 x, s16 y, u8 tile, u8 palette) { + u16 at = (u16)slot * 4; + rpg_oam_shadow[at] = (u16)(y & 0x00ff); /* square, regular OBJ */ + rpg_oam_shadow[at + 1] = (u16)((x & 0x01ff) | 0x4000); /* 16x16 */ + rpg_oam_shadow[at + 2] = (u16)(tile | RPG_OBJ_PRIORITY | + ((u16)palette << 12)); + rpg_oam_shadow[at + 3] = 0; + rpg_oam_dirty = 1; +} + +static void map_fill(u8 tile) { + u16 i; + u16 entry = RPG_ENTRY(tile); + for (i = 0; i < 32 * 32; i++) rpg_bg_shadow[i] = entry; + rpg_bg_dirty = 1; +} + +static void map_cell(u8 x, u8 y, u8 tile) { + if (x < 32 && y < 32) + rpg_bg_shadow[(u16)y * 32 + x] = RPG_ENTRY(tile); +} + +static u8 world_tile(u8 ch, u8 x, u8 y) { + if (ch == '#') return TILE_WALL; + if (ch == '=' || ch == ':' || ch == 'p') + return ((x + y) & 1) ? TILE_PATH_A : TILE_PATH_B; + if (ch == '~') return ((x + y) & 1) ? TILE_WATER_A : TILE_WATER_B; + if (ch == 'T' || ch == 't') return TILE_TREE; + if (ch == '*') return TILE_FLOWER; + return ((x + y) & 1) ? TILE_GRASS_A : TILE_GRASS_B; +} + +static void line_text(u8 row, u8 x, const char *text) { + vp_ln_reset(); + if (text) vp_ln_str(text); + vp_ln_commit(row, x, 0, VP_ALIGN_LEFT); +} + +static void line_hp(u8 row, u8 x, s32 hp, s32 max_hp) { + vp_ln_reset(); + vp_ln_str("HP "); + vp_ln_int(hp); + vp_ln_ch('/'); + vp_ln_int(max_hp); + vp_ln_commit(row, x, 0, VP_ALIGN_LEFT); +} + +static void line_choice(u8 row, u8 x, const char *text, u8 selected) { + vp_ln_reset(); + vp_ln_ch(selected ? '>' : ' '); + vp_ln_ch(' '); + if (text) vp_ln_str(text); + vp_ln_commit(row, x, 0, VP_ALIGN_LEFT); +} + +static void draw_box(u8 left, u8 top, u8 right, u8 bottom) { + u8 x, y; + map_cell(left, top, TILE_BOX_TL); + map_cell(right, top, TILE_BOX_TR); + map_cell(left, bottom, TILE_BOX_BL); + map_cell(right, bottom, TILE_BOX_BR); + for (x = (u8)(left + 1); x < right; x++) { + map_cell(x, top, TILE_BOX_TOP); + map_cell(x, bottom, TILE_BOX_BOTTOM); + } + for (y = (u8)(top + 1); y < bottom; y++) { + map_cell(left, y, TILE_BOX_LEFT); + map_cell(right, y, TILE_BOX_RIGHT); + for (x = (u8)(left + 1); x < right; x++) + map_cell(x, y, TILE_BOX_FILL); + } +} + +static void draw_world(const vp_rpg_map *map, s32 player_x, s32 player_y, + u8 facing, s32 quest, u8 hud) { + u8 x, y, ox, oy, slot; + u16 at; + map_fill(TILE_BLANK); + hide_objects(); + if (!map || !map->tiles || !map->width || !map->height) return; + ox = map->width < 30 ? (u8)((30 - map->width) >> 1) : 0; + oy = map->height < 20 ? (u8)((20 - map->height) >> 1) : 0; + for (y = 0; y < map->height && (u8)(oy + y) < 20; y++) { + for (x = 0; x < map->width && (u8)(ox + x) < 30; x++) { + at = (u16)y * map->width + x; + map_cell((u8)(ox + x), (u8)(oy + y), + world_tile(map->tiles[at], x, y)); + } + } + slot = 1; + for (y = 0; y < map->height && slot < 127; y++) { + for (x = 0; x < map->width && slot < 127; x++) { + u8 ch = map->tiles[(u16)y * map->width + x]; + if (ch == 'N') { + show_object(slot++, (s16)((ox + x) * 8 - 4), + (s16)((oy + y) * 8 - 8), 16, 1); + } else if (ch == 'S' && quest == 1) { + show_object(slot++, (s16)((ox + x) * 8 - 4), + (s16)((oy + y) * 8 - 8), 20, 2); + } + } + } + if (player_x >= 0 && player_y >= 0 && player_x < map->width && + player_y < map->height) { + u8 hero_tile = facing < 4 ? (u8)(facing * 4) : 0; + show_object(0, (s16)((ox + player_x) * 8 - 4), + (s16)((oy + player_y) * 8 - 8), hero_tile, 0); + } + if (hud) { + for (x = 0; x < 30; x++) map_cell(x, 0, TILE_HUD); + if (quest == 0) line_text(0, 1, "QUEST: TALK TO THE ELDER"); + else if (quest == 1) line_text(0, 1, "QUEST: DEFEAT THE SLIME"); + else if (quest == 2) line_text(0, 1, "QUEST: RETURN TO THE ELDER"); + else line_text(0, 1, "QUEST COMPLETE: VILLAGE SAVED"); + } +} + +static void draw_dialog(const vp_rpg_map *map, s32 player_x, s32 player_y, + u8 facing, s32 quest, s32 dialog, s32 choice) { + const vp_rpg_dialog *d; + draw_world(map, player_x, player_y, facing, quest, 0); + draw_box(1, 11, 28, 19); + if (!map || !map->dialogs || dialog < 1 || dialog > map->dialog_count) { + line_text(15, 3, "..."); + return; + } + d = &map->dialogs[dialog - 1]; + line_text(12, 3, d->speaker); + line_text(14, 3, d->line1); + line_text(15, 3, d->line2); + if (d->choice0 && d->choice0[0]) + line_choice(17, 3, d->choice0, choice == 0); + if (d->choice1 && d->choice1[0]) + line_choice(18, 3, d->choice1, choice == 1); +} + +static u8 hp_tiles(s32 hp, s32 max_hp) { + if (hp <= 0 || max_hp <= 0) return 0; + if (hp >= max_hp) return 10; + return (u8)((hp * 10 + max_hp - 1) / max_hp); +} + +static void draw_battle(s32 hero_hp, s32 enemy_hp, s32 cursor) { + u8 x, y, full; + hide_objects(); + for (y = 0; y < 32; y++) + for (x = 0; x < 32; x++) + rpg_bg_shadow[(u16)y * 32 + x] = + RPG_ENTRY(y < 11 ? TILE_BATTLE_SKY : TILE_BATTLE_GROUND); + rpg_bg_dirty = 1; + full = hp_tiles(enemy_hp, 18); + for (x = 0; x < 10; x++) + map_cell((u8)(18 + x), 4, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); + full = hp_tiles(hero_hp, 30); + for (x = 0; x < 10; x++) + map_cell((u8)(2 + x), 13, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); + draw_box(2, 14, 27, 19); + show_object(0, 40, 72, 12, 0); /* hero faces right */ + show_object(1, 184, 56, 20, 2); + line_text(1, 17, "WILD SLIME"); + line_hp(2, 18, enemy_hp, 18); + line_text(10, 2, "HERO"); + line_hp(11, 2, hero_hp, 30); + line_choice(15, 4, "ATTACK", cursor == 0); + line_choice(17, 4, "HEAL", cursor == 1); +} + +u8 vp_rpg_blocked(const vp_rpg_map *map, s32 x, s32 y) { + u32 at; + if (!map || x < 0 || y < 0 || x >= map->width || y >= map->height) + return 1; + if (!map->solid) return 0; + at = (u32)y * map->width + (u32)x; + return map->solid[at] ? 1 : 0; +} + +u8 vp_rpg_event_at(const vp_rpg_map *map, s32 x, s32 y) { + u8 tile, i; + u32 at; + if (!map || !map->tiles || !map->events || x < 0 || y < 0 || + x >= map->width || y >= map->height) + return 0; + at = (u32)y * map->width + (u32)x; + tile = map->tiles[at]; + for (i = 0; i < map->event_count; i++) + if (map->events[i].tile == tile) return map->events[i].event; + return 0; +} + +void vp_rpg_video_init(void) { + static const u16 bg_colors[16] = { + 0x0000, 0x24a3, 0x21e4, 0x2f08, + 0x1570, 0x2a78, 0x3149, 0x5693, + 0x4923, 0x7247, 0x1544, 0x2285, + 0x235f, 0x30c4, 0x4b7d, 0x295f + }; + static const u16 hero_colors[16] = { + 0x0000, 0x1063, 0x32bf, 0x7e08, 0x25ff, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + static const u16 elder_colors[16] = { + 0x0000, 0x1063, 0x32bf, 0x4a52, 0x7fff, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + static const u16 slime_colors[16] = { + 0x0000, 0x1063, 0x56a0, 0x2d60, 0x7fff, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + u8 p8[64]; + u8 p16[256]; + u8 tile, facing, i; + upload_transparent_font(); + for (i = 0; i < 16; i++) { + PAL_BG[RPG_BG_BANK * 16 + i] = bg_colors[i]; + PAL_OBJ[i] = hero_colors[i]; + PAL_OBJ[16 + i] = elder_colors[i]; + PAL_OBJ[32 + i] = slime_colors[i]; + } + for (tile = 0; tile < TILE_COUNT; tile++) { + make_bg_tile(tile, p8); + upload_bg_tile(tile, p8); + } + for (facing = 0; facing < 4; facing++) { + make_hero(facing, p16); + upload_obj((u8)(facing * 4), p16); + } + make_elder(p16); + upload_obj(16, p16); + make_slime(p16); + upload_obj(20, p16); + map_fill(TILE_BLANK); + hide_objects(); + REG_BG1CNT = (u16)(2 | (2 << 2) | (9 << 8)); + REG_BG1HOFS = 0; + REG_BG1VOFS = 0; + REG_DISPCNT = 0x1340; + rpg_ready = 1; +} + +void vp_rpg_render(const vp_rpg_map *map, u8 mode, s32 player_x, + s32 player_y, u8 facing, s32 quest, s32 dialog, + s32 choice, s32 hero_hp, s32 enemy_hp, + s32 battle_cursor) { + if (!rpg_ready) return; + vp_row_clear(0, VP_GRID_H); + if (mode == 1) + draw_dialog(map, player_x, player_y, facing, quest, dialog, choice); + else if (mode == 2) + draw_battle(hero_hp, enemy_hp, battle_cursor); + else + draw_world(map, player_x, player_y, facing, quest, 1); +} + +void vp_rpg_video_commit(void) { + u16 i; + if (!rpg_ready) return; + if (rpg_bg_dirty) { + for (i = 0; i < 32 * 32; i++) RPG_BG_MAP[i] = rpg_bg_shadow[i]; + rpg_bg_dirty = 0; + } + if (rpg_oam_dirty) { + for (i = 0; i < 128 * 4; i++) OAM[i] = rpg_oam_shadow[i]; + rpg_oam_dirty = 0; + } +} diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index a37c758a..62236a7b 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -56,6 +56,43 @@ typedef struct { u8 idx[VP_VIEW_CAP]; } vp_view; +/* ---- RPG host -------------------------------------------------------------- + * Static world/dialogue data is emitted by the Pocket Vapor compiler. Dynamic + * gameplay state remains in ordinary generated refs; these records only give + * the fixed GBA renderer and pure map-query helpers access to ROM assets. */ +typedef struct { + u8 tile; + u8 event; +} vp_rpg_event; + +typedef struct { + const char *speaker; + const char *line1; + const char *line2; + const char *choice0; + const char *choice1; +} vp_rpg_dialog; + +typedef struct { + u8 width; + u8 height; + const u8 *tiles; /* width * height source glyphs */ + const u8 *solid; /* width * height, 0 or 1 */ + const vp_rpg_event *events; + u8 event_count; + const vp_rpg_dialog *dialogs; + u8 dialog_count; +} vp_rpg_map; + +u8 vp_rpg_blocked(const vp_rpg_map *map, s32 x, s32 y); +u8 vp_rpg_event_at(const vp_rpg_map *map, s32 x, s32 y); +void vp_rpg_video_init(void); +void vp_rpg_render(const vp_rpg_map *map, u8 mode, s32 player_x, + s32 player_y, u8 facing, s32 quest, s32 dialog, + s32 choice, s32 hero_hp, s32 enemy_hp, + s32 battle_cursor); +void vp_rpg_video_commit(void); + /* ---- grid (runtime-owned) -------------------------------------------------- */ void vp_row_clear(u8 y0, u8 y1); /* rows [y0, y1): space, pair 0 */ @@ -121,5 +158,6 @@ extern const u16 vp_ink565[]; extern const u16 vp_paper565[]; extern const u8 vp_pal_style[]; extern const char vp_app_title[]; /* cartridge title, <= 12 chars */ +extern const u8 vp_rpg_enabled; /* compiler-selected GBA pixel host */ #endif diff --git a/vapor/scripts/play.ts b/vapor/scripts/play.ts index e310ed20..139ae4d9 100644 --- a/vapor/scripts/play.ts +++ b/vapor/scripts/play.ts @@ -11,7 +11,8 @@ const ROOT = join(import.meta.dir, "..", ".."); const entry = resolve(process.argv[2] ?? join(import.meta.dir, "..", "examples", "todo", "todo.tsx")); const name = basename(entry).replace(/\.tsx$/, ""); -const app = compileVaporApp(entry, await Bun.file(entry).text(), name === "todo" ? "VAPOR TODO" : name.toUpperCase()); +const title = name === "todo" ? "VAPOR TODO" : name === "rpg" ? "VAPOR QUEST" : name.toUpperCase(); +const app = compileVaporApp(entry, await Bun.file(entry).text(), title); console.log(app.graph); console.log(app.plan); const rom = join(ROOT, "dist", "vapor", `${name}.gba`); diff --git a/vapor/tests/compiler.test.ts b/vapor/tests/compiler.test.ts index cb2f1a15..87c933b0 100644 --- a/vapor/tests/compiler.test.ts +++ b/vapor/tests/compiler.test.ts @@ -47,6 +47,7 @@ describe("pocket vapor compiler", () => { const a = compileVaporApp(ENTRY, source, "VAPOR TODO"); const b = compileVaporApp(ENTRY, source, "VAPOR TODO"); expect(a.c).toBe(b.c); + expect(a.rpgEnabled).toBe(false); expect(a.c).toContain("vp_mark"); expect(a.graph).toContain("visible: view(maxLen 12)"); expect(a.plan).toContain("pools"); diff --git a/vapor/tests/rpg.test.ts b/vapor/tests/rpg.test.ts new file mode 100644 index 00000000..25d2fd17 --- /dev/null +++ b/vapor/tests/rpg.test.ts @@ -0,0 +1,241 @@ +// Pocket Vapor RPG POC: compiler contract + a complete native GBA play tape. + +import { beforeAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { $ } from "bun"; +import { compileVaporApp, type CompiledApp, VaporCompileError } from "../compiler/compile.ts"; +import { buildGbaRom } from "../compiler/rom.ts"; +import { Button } from "../host/input.ts"; +import { defineRpgMap, rpgBlocked, rpgEventAt } from "../host/rpg.ts"; + +const HERE = import.meta.dir; +const ENTRY = join(HERE, "..", "examples", "rpg", "rpg.tsx"); +const OUT = join(HERE, "..", "..", "dist", "vapor"); +const ROM = join(OUT, "rpg.gba"); +const RUNNER = join(HERE, "harness", "mgba_runner"); +const DEBUG_STATE = 0x02000010; + +let source = ""; +let app: CompiledApp; +let runReads: Record; + +function press(button: number): string { + return `P ${(1 << button).toString(16)} 2 4`; +} + +function stateAddress(name: string): number { + const slot = app.debugSlots.find((candidate) => candidate.name === name); + if (!slot) throw new Error(`missing debug slot ${name}`); + return DEBUG_STATE + slot.offset; +} + +function readState(lines: string[], label: string, names: readonly string[]): void { + for (const name of names) { + lines.push(`R ${label}_${name} 0x${stateAddress(name).toString(16)} 4`); + } +} + +function value(label: string, name: string): number { + return runReads[`${label}_${name}`] as number; +} + +async function countPpmColors(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + const head = new TextDecoder().decode(bytes.slice(0, 64)); + const match = head.match(/^P6\n(\d+) (\d+)\n255\n/); + if (!match) throw new Error(`not a P6 screenshot: ${path}`); + const offset = match[0].length; + const colors = new Set(); + for (let i = offset; i + 2 < bytes.length; i += 3) { + colors.add(`${bytes[i]},${bytes[i + 1]},${bytes[i + 2]}`); + } + return colors.size; +} + +beforeAll(async () => { + source = await Bun.file(ENTRY).text(); + app = compileVaporApp(ENTRY, source, "VAPOR QUEST", "gba"); + await buildGbaRom(app, ROM); + if (!existsSync(RUNNER)) await $`bun ${join(HERE, "harness", "build.ts")}`.quiet(); + + const state = [ + "mode", + "playerX", + "playerY", + "facing", + "quest", + "dialog", + "choice", + "heroHp", + "enemyHp", + "battleCursor", + ] as const; + const worldShot = join(OUT, "rpg-world.ppm"); + const dialogShot = join(OUT, "rpg-dialog.ppm"); + const battleShot = join(OUT, "rpg-battle.ppm"); + const lines: string[] = ["A 8"]; + + readState(lines, "boot", state); + lines.push(`S ${worldShot}`); + lines.push("D world_bg1 0x06004800 2048"); + lines.push("D world_oam 0x07000000 24"); + + // Walk into the north wall, then restore the spawn row. + lines.push(press(Button.Up), press(Button.Up)); + readState(lines, "wall", ["playerX", "playerY", "facing"]); + lines.push(press(Button.Down)); + + // Stand west of the elder. Right is blocked by N but still turns the hero; + // A then talks to the facing cell. + lines.push(press(Button.Right), press(Button.Down), press(Button.Right), press(Button.A)); + readState(lines, "offer", ["mode", "playerX", "playerY", "facing", "quest", "dialog", "choice"]); + lines.push(`S ${dialogShot}`); + + // Exercise NO, close, talk again, then accept YES. + lines.push(press(Button.Down), press(Button.A)); + readState(lines, "declined", ["mode", "quest", "dialog", "choice"]); + lines.push(press(Button.A), press(Button.A), press(Button.A)); + readState(lines, "accepted", ["mode", "quest", "dialog", "choice"]); + lines.push(press(Button.A)); + + // Return to y=2 and walk east onto S at (8,2). + lines.push(press(Button.Up)); + for (let i = 0; i < 5; i++) lines.push(press(Button.Right)); + readState(lines, "battle", ["mode", "playerX", "playerY", "quest", "heroHp", "enemyHp", "battleCursor"]); + lines.push(`S ${battleShot}`); + lines.push("D battle_bg1 0x06004800 2048"); + lines.push("D battle_oam 0x07000000 24"); + + // HEAL once, then ATTACK three times. Victory is a reactive dialog state. + lines.push(press(Button.Down), press(Button.A)); + readState(lines, "healed", ["mode", "heroHp", "enemyHp", "battleCursor"]); + lines.push(press(Button.Up), press(Button.A), press(Button.A), press(Button.A)); + readState(lines, "won", ["mode", "quest", "dialog", "heroHp", "enemyHp"]); + lines.push(press(Button.A)); + + // Return to the elder, turn into the solid NPC, report, and close. + for (let i = 0; i < 5; i++) lines.push(press(Button.Left)); + lines.push(press(Button.Down), press(Button.Right), press(Button.A)); + readState(lines, "completeDialog", ["mode", "playerX", "playerY", "facing", "quest", "dialog"]); + lines.push(press(Button.A)); + readState(lines, "complete", ["mode", "quest", "dialog"]); + lines.push("R trips 0x0200000c 1"); + + const scenario = join(OUT, "rpg-play-tape.txt"); + await Bun.write(scenario, `${lines.join("\n")}\n`); + const output = await $`${RUNNER} ${ROM} ${scenario}`.text(); + const parsed = JSON.parse(output) as { ok: boolean; reads: Record }; + expect(parsed.ok).toBe(true); + runReads = parsed.reads; +}, 120000); + +describe("Pocket Vapor RPG host", () => { + test("JS host queries share the map's collision and event semantics", () => { + const map = defineRpgMap({ + rows: ["###", "#N#", "###"], + solid: "#N", + events: { N: 7 }, + dialogs: [], + }); + expect(rpgBlocked(map, 1, 1)).toBe(true); + expect(rpgBlocked(map, -1, 1)).toBe(true); + expect(rpgEventAt(map, 1, 1)).toBe(7); + expect(rpgEventAt(map, 1, 0)).toBe(0); + expect(() => + defineRpgMap({ rows: ["..."], solid: "#", events: { N: 1 }, dialogs: [] }), + ).toThrow("does not appear"); + }); + + test("compiler emits ROM assets, pure queries, and one reactive native effect", () => { + const again = compileVaporApp(ENTRY, source, "VAPOR QUEST", "gba"); + expect(again.c).toBe(app.c); + expect(app.rpgEnabled).toBe(true); + expect(app.c).toContain("const u8 vp_rpg_enabled = 1"); + expect(app.c).toContain("vp_rpg_blocked(&RPG_RPG_MAP"); + expect(app.c).toContain("vp_rpg_event_at(&RPG_RPG_MAP"); + expect(app.c).toContain("vp_rpg_render(&RPG_RPG_MAP"); + expect(app.c).toContain("SLIME BLOCKS THE EAST ROAD."); + expect(app.graph).toContain("mode (num)"); + expect(app.graph).toContain("questActive:"); + expect(app.plan).toContain("RPG host RAM: 3076 B"); + }); + + test("pixel RPG host is explicitly GBA-only", () => { + expect(() => compileVaporApp(ENTRY, source, "VAPOR QUEST", "gb")).toThrow(VaporCompileError); + expect(() => compileVaporApp(ENTRY, source, "VAPOR QUEST", "gb")).toThrow(/GBA/i); + }); + + test("compiler rejects malformed map assets and an incomplete screen contract", () => { + const ragged = source.replace( + '"##############################",', + '"#############################",', + ); + expect(() => compileVaporApp(ENTRY, ragged, "VAPOR QUEST", "gba")).toThrow(/equal width/); + + const missingEventTile = source.replace("N: Event.Elder,", "Z: Event.Elder,"); + expect(() => compileVaporApp(ENTRY, missingEventTile, "VAPOR QUEST", "gba")).toThrow( + /does not occur/, + ); + + const missingProp = source.replace(" battleCursor={battleCursor.value}\n", ""); + expect(() => compileVaporApp(ENTRY, missingProp, "VAPOR QUEST", "gba")).toThrow( + /missing required prop "battleCursor"/, + ); + }); +}); + +describe("native GBA RPG play tape", () => { + test("collision turns without moving and A opens the elder offer", () => { + expect([value("boot", "mode"), value("boot", "playerX"), value("boot", "playerY")]).toEqual([0, 2, 2]); + expect([value("wall", "playerX"), value("wall", "playerY"), value("wall", "facing")]).toEqual([2, 1, 1]); + expect([ + value("offer", "mode"), + value("offer", "playerX"), + value("offer", "playerY"), + value("offer", "facing"), + value("offer", "quest"), + value("offer", "dialog"), + ]).toEqual([1, 3, 3, 3, 0, 1]); + }); + + test("choice, quest gate, heal, battle, and report form one complete loop", () => { + expect([value("declined", "quest"), value("declined", "dialog")]).toEqual([0, 3]); + expect([value("accepted", "mode"), value("accepted", "quest"), value("accepted", "dialog")]).toEqual([1, 1, 2]); + expect([ + value("battle", "mode"), + value("battle", "playerX"), + value("battle", "playerY"), + value("battle", "quest"), + value("battle", "heroHp"), + value("battle", "enemyHp"), + ]).toEqual([2, 8, 2, 1, 30, 18]); + expect([value("healed", "heroHp"), value("healed", "enemyHp"), value("healed", "battleCursor")]).toEqual([26, 18, 1]); + expect([ + value("won", "mode"), + value("won", "quest"), + value("won", "dialog"), + value("won", "heroHp"), + value("won", "enemyHp"), + ]).toEqual([1, 2, 4, 18, 0]); + expect([ + value("completeDialog", "mode"), + value("completeDialog", "playerX"), + value("completeDialog", "playerY"), + value("completeDialog", "facing"), + value("completeDialog", "quest"), + value("completeDialog", "dialog"), + ]).toEqual([1, 3, 3, 3, 3, 5]); + expect([value("complete", "mode"), value("complete", "quest"), value("complete", "dialog")]).toEqual([0, 3, 0]); + expect(runReads.trips).toBe(0); + }); + + test("world, dialog, and battle produce distinct multi-color hardware frames", async () => { + const paths = ["rpg-world.ppm", "rpg-dialog.ppm", "rpg-battle.ppm"].map((name) => join(OUT, name)); + const frames = await Promise.all(paths.map((path) => Bun.file(path).arrayBuffer())); + expect(new Set(frames.map((bytes) => Bun.hash(new Uint8Array(bytes)).toString())).size).toBe(3); + for (const path of paths) expect(await countPpmColors(path)).toBeGreaterThanOrEqual(8); + expect(runReads.world_bg1).not.toBe(runReads.battle_bg1); + expect(runReads.world_oam).not.toBe(runReads.battle_oam); + }); +}); From f07de2cc398c04f3f60307fea4aa637d6caa4a79 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:11:48 +0800 Subject: [PATCH 2/5] fix(vapor): repeat held RPG movement --- vapor/DESIGN.md | 19 +++++---- vapor/README.md | 7 ++-- vapor/compiler/compile.ts | 42 +++++++++++++++++++ vapor/examples/rpg/rpg.tsx | 9 +++- vapor/host/input.ts | 25 ++++++++++-- vapor/oracle/boot.ts | 7 ++++ vapor/oracle/entry-playdate.ts | 5 +++ vapor/oracle/entry.ts | 5 +++ vapor/runtime/gba/vapor_gba.c | 24 +++++++++-- vapor/runtime/vapor.h | 1 + vapor/tests/compiler.test.ts | 38 +++++++++++++++++ vapor/tests/rpg.test.ts | 75 ++++++++++++++++++++++++++++++++-- 12 files changed, 236 insertions(+), 21 deletions(-) diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index 2656787f..5d97c002 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -237,22 +237,27 @@ it. Its responsibilities are split four ways: characters through OBJ, with its fixed dialogue/battle presentation; it does not expose GBA registers or SDK concepts to application code. -The current RPG movement contract consumes the same hardware-neutral -`onButton` press edges as the rest of Vapor and advances one cell per press. +The RPG movement contract consumes hardware-neutral `onButton` press edges +for immediate steps and a separate `onButtonRepeat` registration for held +D-pad movement. The GBA host waits 12 frames after the edge, then emits a +repeat every 6 frames. The app routes repeats only to its world keymap, so +dialogue and battle choices still advance once per physical press. The JS `RpgScreen` currently returns no pixels, so collision/event behavior can run in the JS/oracle path but pixel-frame acceptance is mGBA-only for -this POC. A browser pixel renderer, continuous held-button movement, saves, -audio and CJK text are follow-ups, not implied capabilities. +this POC. A browser pixel renderer, saves, audio and CJK text are follow-ups, +not implied capabilities. -Input is not DOM events. The host module exposes two explicit capabilities: +Input is not DOM events. The host module exposes three explicit capabilities: - `onButton((b: Button) => void)` for frame-latched press edges; +- `onButtonRepeat((b: Button) => void)` for normalized held-D-pad repeats + (currently supplied by the GBA target); - `onAxisDelta(RelativeAxis.Primary, (delta) => void)` for signed, hardware-neutral incremental movement in canonical units. Under the oracle the module executes and the test tape feeds it; under the -compiler registrations become `app_on_button()` and -`app_on_axis_delta(axis, delta)`. Physical hosts own normalization: +compiler registrations become `app_on_button()`, `app_on_button_repeat()` +and `app_on_axis_delta(axis, delta)`. Physical hosts own normalization: rotary adapters preserve signed motion as millidegrees, while applications own detents, acceleration, and sensitivity. Playdate forwards crank motion to Primary; a future ESP32 board can adapt an encoder without exposing pins diff --git a/vapor/README.md b/vapor/README.md index 30b915e8..fd939c4c 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -202,8 +202,9 @@ three attacks, then return to the Elder to complete the quest. Controls: -- World: D-pad moves one cell per press; A talks to the event cell directly - in front of the player. +- World: D-pad moves one cell immediately; holding it waits 12 frames, then + continues every 6 frames. A talks to the event cell directly in front of + the player. - Dialogue: Up/Down selects YES or NO; A confirms or advances. - Battle: Up/Down selects ATTACK or HEAL; A performs the action. @@ -213,7 +214,7 @@ bun run vapor:rpg:play # build and open the ROM in mGBA ``` The POC deliberately keeps a narrow evidence and product boundary. It is -GBA-only, step-based, and uses printable English ASCII; there is no save +GBA-only, tile-based, and uses printable English ASCII; there is no save data, audio, or CJK text yet. The JS collision/event helpers are real, but the browser `RpgScreen` is currently a stateless placeholder rather than a pixel oracle renderer. Validation is scoped to mGBA; this section does not diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index bb450a85..937a355c 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -266,12 +266,14 @@ class AppCompiler { private computeds: ComputedBinding[] = []; private fns: FnBinding[] = []; private handler: ts.ArrowFunction | null = null; + private repeatHandler: ts.ArrowFunction | null = null; private axisHandlers = new Map(); private template: ts.JsxFragment | null = null; private vueRef = ""; private vueComputed = ""; private hostOnButton = ""; + private hostOnButtonRepeat = ""; private hostButton = ""; private hostOnAxisDelta = ""; private hostDefineRpgMap = ""; @@ -349,6 +351,7 @@ class AppCompiler { else this.err(spec, `unsupported vue import: ${imported} (subset allows ref, computed)`); } else if (/\/host\/input(\.ts)?$/.test(from)) { if (imported === "onButton") this.hostOnButton = local; + else if (imported === "onButtonRepeat") this.hostOnButtonRepeat = local; else if (imported === "Button") this.hostButton = local; else if (imported === "onAxisDelta") this.hostOnAxisDelta = local; else if (imported === "RelativeAxis") @@ -618,6 +621,17 @@ class AppCompiler { const arg = call.arguments[0]; if (!arg || !ts.isArrowFunction(arg)) this.err(call, "onButton takes an arrow"); this.handler = arg; + } else if ( + ts.isCallExpression(call) && + ts.isIdentifier(call.expression) && + call.expression.text === this.hostOnButtonRepeat + ) { + if (this.target.name !== "gba") + this.err(call, "onButtonRepeat is currently supported only on the gba target"); + if (this.repeatHandler) this.err(call, "only one onButtonRepeat handler is supported"); + const arg = call.arguments[0]; + if (!arg || !ts.isArrowFunction(arg)) this.err(call, "onButtonRepeat takes an arrow"); + this.repeatHandler = arg; } else if ( ts.isCallExpression(call) && ts.isIdentifier(call.expression) && @@ -2293,6 +2307,24 @@ class AppCompiler { this.scope = saved; } + let repeatHandlerOut: string[] = []; + if (this.repeatHandler) { + const saved = new Map(this.scope); + const param = this.repeatHandler.parameters[0]?.name.getText(this.sf); + if (!param) this.err(this.repeatHandler, "onButtonRepeat arrow needs a (b) param"); + this.scope.set(param, { kind: "local", cName: "b_arg", ty: NUM }); + const repeatHandlerArrow = this.repeatHandler; + const { decls, body } = this.withHoist((out) => { + if (ts.isBlock(repeatHandlerArrow.body)) { + for (const stmt of repeatHandlerArrow.body.statements) this.compileStmt(stmt, out, " "); + } else { + this.compileExprStmt(repeatHandlerArrow.body, out, " "); + } + }); + repeatHandlerOut = [...decls, ...body]; + this.scope = saved; + } + const axisHandlerFns: string[] = []; const axisHandlerCases: string[] = []; for (const [axis, handler] of [...this.axisHandlers].sort(([a], [b]) => a - b)) { @@ -2456,6 +2488,15 @@ class AppCompiler { // handler c.push(`void app_on_button(u8 b) {\n s32 b_arg = (s32)b;\n${handlerOut.join("\n")}\n}\n`); + if (this.target.name === "gba") { + if (this.repeatHandler) { + c.push( + `void app_on_button_repeat(u8 b) {\n s32 b_arg = (s32)b;\n${repeatHandlerOut.join("\n")}\n}\n`, + ); + } else { + c.push("void app_on_button_repeat(u8 b) {\n (void)b;\n}\n"); + } + } c.push(axisHandlerFns.join("\n\n")); if (axisHandlerCases.length > 0) { c.push( @@ -2547,6 +2588,7 @@ class AppCompiler { .join(", ") || "none" }`, ); + graphLines.push(` button repeats: ${this.repeatHandler ? "directional (gba)" : "none"}`); graphLines.push( ` relative axes: ${ [...this.axisHandlers.keys()] diff --git a/vapor/examples/rpg/rpg.tsx b/vapor/examples/rpg/rpg.tsx index 72f4d8a2..6fc1786c 100644 --- a/vapor/examples/rpg/rpg.tsx +++ b/vapor/examples/rpg/rpg.tsx @@ -12,7 +12,7 @@ // Right, A to complete the quest. import { computed, ref } from "vue"; -import { Button, onButton } from "../../host/input.ts"; +import { Button, onButton, onButtonRepeat } from "../../host/input.ts"; import { defineRpgMap, RpgScreen, @@ -271,6 +271,13 @@ export default () => { : worldKeys)[button]?.(), ); + // The GBA host normalizes held D-pad input into delayed repeat events. + // Only the world consumes them: dialogue and battle remain one choice per + // physical press while map movement continues at a predictable cadence. + onButtonRepeat((button) => { + if (mode.value === Mode.World) worldKeys[button]?.(); + }); + return ( <> void; const handlers: ButtonHandler[] = []; +const repeatHandlers: ButtonHandler[] = []; export const RelativeAxis = { Primary: 0, @@ -66,6 +68,17 @@ export function onButton(handler: ButtonHandler): void { handlers.push(handler); } +/** + * Register for normalized D-pad repeat events after the initial press edge. + * + * The GBA host currently supplies this capability: after a short hold delay, + * it emits repeats at a fixed frame cadence. Applications decide which modes + * consume them, so a world can keep moving without making menus auto-repeat. + */ +export function onButtonRepeat(handler: ButtonHandler): void { + repeatHandlers.push(handler); +} + /** * Register a handler for signed, relative movement on one logical axis. * @@ -85,6 +98,11 @@ export function __dispatchButton(button: number): void { for (const handler of handlers) handler(button); } +/** Oracle-only: deliver one normalized held-D-pad repeat event. */ +export function __dispatchButtonRepeat(button: number): void { + for (const handler of repeatHandlers) handler(button); +} + /** Oracle-only: deliver one non-zero, integral relative-axis delta. */ export function __dispatchAxisDelta(axis: RelativeAxisId, delta: number): void { assertRelativeAxis(axis); @@ -97,5 +115,6 @@ export function __dispatchAxisDelta(axis: RelativeAxisId, delta: number): void { /** Oracle-only: drop all registered input handlers (fresh boot between tests). */ export function __resetButtons(): void { handlers.length = 0; + repeatHandlers.length = 0; axisHandlers.clear(); } diff --git a/vapor/oracle/boot.ts b/vapor/oracle/boot.ts index 062d8992..cd3e12df 100644 --- a/vapor/oracle/boot.ts +++ b/vapor/oracle/boot.ts @@ -42,6 +42,8 @@ export interface Oracle { root: VaporElement; /** Deliver one button edge and settle vapor's scheduler. */ press(button: number): Promise; + /** Deliver one normalized held-D-pad repeat and settle the scheduler. */ + repeat(button: number): Promise; /** Deliver one relative-axis delta and settle vapor's scheduler. */ axisDelta(axis: number, delta: number): Promise; /** Current rendered grid. */ @@ -69,6 +71,7 @@ export async function bootOracle(opts: OracleOptions = {}): Promise { const hooks = globalThis as Record; const boot = hooks.__vaporBoot as (container: unknown) => { unmount(): void }; const pressHook = hooks.__vaporPress as (button: number) => void; + const repeatHook = hooks.__vaporRepeat as (button: number) => void; const axisDeltaHook = hooks.__vaporAxisDelta as (axis: number, delta: number) => void; const tick = hooks.__vaporTick as () => Promise; @@ -82,6 +85,10 @@ export async function bootOracle(opts: OracleOptions = {}): Promise { pressHook(button); await tick(); }, + async repeat(button: number) { + repeatHook(button); + await tick(); + }, async axisDelta(axis: number, delta: number) { axisDeltaHook(axis, delta); await tick(); diff --git a/vapor/oracle/entry-playdate.ts b/vapor/oracle/entry-playdate.ts index ffffc013..d873e84e 100644 --- a/vapor/oracle/entry-playdate.ts +++ b/vapor/oracle/entry-playdate.ts @@ -9,6 +9,7 @@ import TodoApp from "../examples/todo/todo.playdate.tsx"; import { __dispatchAxisDelta, __dispatchButton, + __dispatchButtonRepeat, __resetButtons, } from "../host/input.ts"; @@ -29,6 +30,10 @@ hooks.__vaporPress = (button: number): void => { __dispatchButton(button); }; +hooks.__vaporRepeat = (button: number): void => { + __dispatchButtonRepeat(button); +}; + hooks.__vaporAxisDelta = (axis: number, delta: number): void => { __dispatchAxisDelta(axis as 0 | 1, delta); }; diff --git a/vapor/oracle/entry.ts b/vapor/oracle/entry.ts index 6076a58c..f8b6ee18 100644 --- a/vapor/oracle/entry.ts +++ b/vapor/oracle/entry.ts @@ -10,6 +10,7 @@ import TodoApp from "../examples/todo/todo.tsx"; import { __dispatchAxisDelta, __dispatchButton, + __dispatchButtonRepeat, __resetButtons, } from "../host/input.ts"; @@ -30,6 +31,10 @@ hooks.__vaporPress = (button: number): void => { __dispatchButton(button); }; +hooks.__vaporRepeat = (button: number): void => { + __dispatchButtonRepeat(button); +}; + hooks.__vaporAxisDelta = (axis: number, delta: number): void => { __dispatchAxisDelta(axis as 0 | 1, delta); }; diff --git a/vapor/runtime/gba/vapor_gba.c b/vapor/runtime/gba/vapor_gba.c index 4bb487a2..a8efd618 100644 --- a/vapor/runtime/gba/vapor_gba.c +++ b/vapor/runtime/gba/vapor_gba.c @@ -14,6 +14,9 @@ #define REG_BG0HOFS REG(0x04000010) #define REG_BG0VOFS REG(0x04000012) #define REG_KEYINPUT REG(0x04000130) +#define VP_DPAD_MASK 0x00f0 +#define VP_REPEAT_DELAY_FRAMES 12 +#define VP_REPEAT_INTERVAL_FRAMES 6 #define PAL_BG ((volatile u16 *)0x05000000) #define VRAM ((volatile u16 *)0x06000000) #define SB_MAP 8 @@ -91,6 +94,7 @@ static void vsync(void) { int main(void) { u16 i; u16 prev_keys = 0x03ff; + u8 repeat_frames[10] = { 0 }; u32 frame = 0, flushes = 0; for (i = 0; i < (u16)(vp_palette_count * 16); i++) PAL_BG[i] = vp_palettes[i]; @@ -116,7 +120,7 @@ int main(void) { #endif for (;;) { - u16 keys, edges; + u16 keys, edges, held; u8 b; vsync(); commit_rows(); @@ -128,9 +132,23 @@ int main(void) { keys = (u16)(REG_KEYINPUT & 0x03ff); edges = (u16)(prev_keys & ~keys); /* KEYINPUT is active-low */ + held = (u16)(~keys & 0x03ff); prev_keys = keys; - for (b = 0; b < 10; b++) - if (edges & (u16)(1 << b)) app_on_button(b); + for (b = 0; b < 10; b++) { + u16 bit = (u16)(1 << b); + if (edges & bit) { + app_on_button(b); + repeat_frames[b] = (bit & VP_DPAD_MASK) ? VP_REPEAT_DELAY_FRAMES : 0; + } else if ((held & bit) && repeat_frames[b]) { + repeat_frames[b]--; + if (!repeat_frames[b]) { + app_on_button_repeat(b); + repeat_frames[b] = VP_REPEAT_INTERVAL_FRAMES; + } + } else { + repeat_frames[b] = 0; + } + } if (app_flush()) flushes++; } } diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index 62236a7b..8ad43ebc 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -136,6 +136,7 @@ extern const u32 vp_bit32[32]; /* vp_bit32[n] == 1UL << n */ #define VP_RELATIVE_AXIS_SECONDARY 1 void app_init(void); /* seed state + first paint (all effects) */ void app_on_button(u8 b); /* one press edge, GBA key bit index */ +void app_on_button_repeat(u8 b); /* normalized held-D-pad repeat */ /* Signed physical motion on a hardware-neutral relative axis. Axis 0 is * RelativeAxis.Primary. Rotary hosts use millidegrees and preserve the * signed total; applications own detents and sensitivity. */ diff --git a/vapor/tests/compiler.test.ts b/vapor/tests/compiler.test.ts index 87c933b0..51408945 100644 --- a/vapor/tests/compiler.test.ts +++ b/vapor/tests/compiler.test.ts @@ -6,6 +6,14 @@ import { loadBoard } from "../compiler/boards.ts"; import { compileVaporApp, VAPOR_TARGETS, VaporCompileError } from "../compiler/compile.ts"; import { esp32BuildId } from "../compiler/esp32.ts"; import { FONT8 } from "../compiler/font.gen.ts"; +import { + __dispatchButton, + __dispatchButtonRepeat, + __resetButtons, + Button, + onButton, + onButtonRepeat, +} from "../host/input.ts"; const ENTRY = join(import.meta.dir, "..", "examples", "todo", "todo.tsx"); @@ -171,6 +179,36 @@ export default () => { expect(app.c).toContain("fn_closeEditor"); // bare fn reference as keymap value }); + test("GBA button repeat is explicit and lowers to a separate native hook", () => { + const source = minimal(` + onButtonRepeat((b) => { + if (b === Button.Right) count.value = count.value + 10; + });`).replace("{ Button, onButton }", "{ Button, onButton, onButtonRepeat }"); + const app = compileVaporApp("repeat.tsx", source, "REPEAT", "gba"); + expect(app.c).toContain("void app_on_button_repeat(u8 b)"); + expect(app.c).toMatch(/app_on_button_repeat[\s\S]*g_count \+ 10/); + expect(app.graph).toContain("button repeats: directional (gba)"); + expect(() => compileVaporApp("repeat.tsx", source, "REPEAT", "gb")).toThrow( + /onButtonRepeat is currently supported only on the gba target/, + ); + }); + + test("oracle repeat dispatch stays separate from physical press edges", () => { + let presses = 0; + let repeats = 0; + __resetButtons(); + try { + onButton(() => presses++); + onButtonRepeat(() => repeats++); + __dispatchButtonRepeat(Button.Right); + expect([presses, repeats]).toEqual([0, 1]); + __dispatchButton(Button.Right); + expect([presses, repeats]).toEqual([1, 1]); + } finally { + __resetButtons(); + } + }); + test("computed can yield a record reference (current todo)", async () => { const source = await Bun.file(ENTRY).text(); const app = compileVaporApp(ENTRY, source); diff --git a/vapor/tests/rpg.test.ts b/vapor/tests/rpg.test.ts index 25d2fd17..c305bfe1 100644 --- a/vapor/tests/rpg.test.ts +++ b/vapor/tests/rpg.test.ts @@ -19,6 +19,8 @@ const DEBUG_STATE = 0x02000010; let source = ""; let app: CompiledApp; let runReads: Record; +let holdReads: Record; +let repeatBoundaryX: number[]; function press(button: number): string { return `P ${(1 << button).toString(16)} 2 4`; @@ -92,8 +94,11 @@ beforeAll(async () => { readState(lines, "offer", ["mode", "playerX", "playerY", "facing", "quest", "dialog", "choice"]); lines.push(`S ${dialogShot}`); - // Exercise NO, close, talk again, then accept YES. - lines.push(press(Button.Down), press(Button.A)); + // Exercise NO with a long hold: repeats are world-only, so the two-choice + // dialog must not toggle back and forth. Close, talk again, then accept YES. + lines.push(`P ${(1 << Button.Down).toString(16)} 20 4`); + readState(lines, "offerHeld", ["choice"]); + lines.push(press(Button.A)); readState(lines, "declined", ["mode", "quest", "dialog", "choice"]); lines.push(press(Button.A), press(Button.A), press(Button.A)); readState(lines, "accepted", ["mode", "quest", "dialog", "choice"]); @@ -107,8 +112,9 @@ beforeAll(async () => { lines.push("D battle_bg1 0x06004800 2048"); lines.push("D battle_oam 0x07000000 24"); - // HEAL once, then ATTACK three times. Victory is a reactive dialog state. - lines.push(press(Button.Down), press(Button.A)); + // HEAL once after another long hold; battle selection also ignores repeats. + // Then ATTACK three times. Victory is a reactive dialog state. + lines.push(`P ${(1 << Button.Down).toString(16)} 25 4`, press(Button.A)); readState(lines, "healed", ["mode", "heroHp", "enemyHp", "battleCursor"]); lines.push(press(Button.Up), press(Button.A), press(Button.A), press(Button.A)); readState(lines, "won", ["mode", "quest", "dialog", "heroHp", "enemyHp"]); @@ -128,6 +134,56 @@ beforeAll(async () => { const parsed = JSON.parse(output) as { ok: boolean; reads: Record }; expect(parsed.ok).toBe(true); runReads = parsed.reads; + + // A single held direction must move immediately, wait for the normalized + // delay, then continue at the fixed repeat cadence. Releasing must stop it. + const holdScenario = join(OUT, "rpg-hold-tape.txt"); + await Bun.write( + holdScenario, + [ + "A 8", + `P ${(1 << Button.Right).toString(16)} 25 4`, + `R held_x 0x${stateAddress("playerX").toString(16)} 4`, + `R held_y 0x${stateAddress("playerY").toString(16)} 4`, + `R held_facing 0x${stateAddress("facing").toString(16)} 4`, + "A 30", + `R released_x 0x${stateAddress("playerX").toString(16)} 4`, + "R hold_trips 0x0200000c 1", + "", + ].join("\n"), + ); + const holdOutput = await $`${RUNNER} ${ROM} ${holdScenario}`.text(); + const holdParsed = JSON.parse(holdOutput) as { + ok: boolean; + reads: Record; + }; + expect(holdParsed.ok).toBe(true); + holdReads = holdParsed.reads; + + // Host video scheduling means setKeys() and the runtime input loop do not + // share a frame boundary. Adjacent holds pin the first two observed repeats. + repeatBoundaryX = []; + for (const hold of [14, 15, 22, 23]) { + const boundaryScenario = join(OUT, `rpg-hold-${hold}-tape.txt`); + await Bun.write( + boundaryScenario, + [ + "A 8", + `P ${(1 << Button.Right).toString(16)} ${hold} 4`, + `R x 0x${stateAddress("playerX").toString(16)} 4`, + "R trips 0x0200000c 1", + "", + ].join("\n"), + ); + const boundaryOutput = await $`${RUNNER} ${ROM} ${boundaryScenario}`.text(); + const boundaryParsed = JSON.parse(boundaryOutput) as { + ok: boolean; + reads: Record; + }; + expect(boundaryParsed.ok).toBe(true); + expect(boundaryParsed.reads.trips).toBe(0); + repeatBoundaryX.push(boundaryParsed.reads.x as number); + } }, 120000); describe("Pocket Vapor RPG host", () => { @@ -186,6 +242,16 @@ describe("Pocket Vapor RPG host", () => { }); describe("native GBA RPG play tape", () => { + test("holding a direction repeats world movement and release stops it", () => { + expect([holdReads.held_x, holdReads.held_y, holdReads.held_facing]).toEqual([5, 2, 3]); + expect(holdReads.released_x).toBe(5); + expect(holdReads.hold_trips).toBe(0); + }); + + test("libmGBA pacing crosses the first two held-movement repeat boundaries", () => { + expect(repeatBoundaryX).toEqual([3, 4, 4, 5]); + }); + test("collision turns without moving and A opens the elder offer", () => { expect([value("boot", "mode"), value("boot", "playerX"), value("boot", "playerY")]).toEqual([0, 2, 2]); expect([value("wall", "playerX"), value("wall", "playerY"), value("wall", "facing")]).toEqual([2, 1, 1]); @@ -200,6 +266,7 @@ describe("native GBA RPG play tape", () => { }); test("choice, quest gate, heal, battle, and report form one complete loop", () => { + expect(value("offerHeld", "choice")).toBe(1); expect([value("declined", "quest"), value("declined", "dialog")]).toEqual([0, 3]); expect([value("accepted", "mode"), value("accepted", "quest"), value("accepted", "dialog")]).toEqual([1, 1, 2]); expect([ From 5067aef2f801214c6c12d6495910bfef459797ce Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:38:12 +0800 Subject: [PATCH 3/5] feat(vapor): add PixelLab RPG art pipeline --- package.json | 3 + vapor/compiler/compile.ts | 1 + vapor/examples/rpg/assets/README.md | 47 + vapor/examples/rpg/assets/final/actors.png | Bin 0 -> 890 bytes .../examples/rpg/assets/final/background.png | Bin 0 -> 590 bytes .../rpg/assets/final/battle-actors.png | Bin 0 -> 712 bytes .../rpg/assets/final/palette-guide.png | Bin 0 -> 395 bytes vapor/examples/rpg/assets/generation.json | 145 +++ vapor/examples/rpg/assets/source/elder.png | Bin 0 -> 513 bytes vapor/examples/rpg/assets/source/flower.png | Bin 0 -> 315 bytes vapor/examples/rpg/assets/source/grass.png | Bin 0 -> 184 bytes .../examples/rpg/assets/source/hero-east.png | Bin 0 -> 505 bytes .../examples/rpg/assets/source/hero-north.png | Bin 0 -> 452 bytes .../assets/source/hero-south-reference.png | Bin 0 -> 423 bytes .../examples/rpg/assets/source/hero-south.png | Bin 0 -> 451 bytes .../examples/rpg/assets/source/hero-west.png | Bin 0 -> 461 bytes vapor/examples/rpg/assets/source/path.png | Bin 0 -> 86 bytes vapor/examples/rpg/assets/source/slime.png | Bin 0 -> 344 bytes .../rpg/assets/source/style-anchor.png | Bin 0 -> 1762 bytes vapor/examples/rpg/assets/source/tree.png | Bin 0 -> 621 bytes vapor/examples/rpg/assets/source/wall.png | Bin 0 -> 707 bytes vapor/examples/rpg/assets/source/water.png | Bin 0 -> 359 bytes vapor/examples/rpg/rpg.tsx | 6 +- vapor/runtime/gba/vapor_rpg.c | 289 +----- .../runtime/gba/vapor_rpg_assets.generated.h | 191 ++++ vapor/scripts/rpg-assets.ts | 966 ++++++++++++++++++ vapor/tests/rpg-assets.test.ts | 135 +++ vapor/tests/rpg.test.ts | 28 +- 28 files changed, 1539 insertions(+), 272 deletions(-) create mode 100644 vapor/examples/rpg/assets/README.md create mode 100644 vapor/examples/rpg/assets/final/actors.png create mode 100644 vapor/examples/rpg/assets/final/background.png create mode 100644 vapor/examples/rpg/assets/final/battle-actors.png create mode 100644 vapor/examples/rpg/assets/final/palette-guide.png create mode 100644 vapor/examples/rpg/assets/generation.json create mode 100644 vapor/examples/rpg/assets/source/elder.png create mode 100644 vapor/examples/rpg/assets/source/flower.png create mode 100644 vapor/examples/rpg/assets/source/grass.png create mode 100644 vapor/examples/rpg/assets/source/hero-east.png create mode 100644 vapor/examples/rpg/assets/source/hero-north.png create mode 100644 vapor/examples/rpg/assets/source/hero-south-reference.png create mode 100644 vapor/examples/rpg/assets/source/hero-south.png create mode 100644 vapor/examples/rpg/assets/source/hero-west.png create mode 100644 vapor/examples/rpg/assets/source/path.png create mode 100644 vapor/examples/rpg/assets/source/slime.png create mode 100644 vapor/examples/rpg/assets/source/style-anchor.png create mode 100644 vapor/examples/rpg/assets/source/tree.png create mode 100644 vapor/examples/rpg/assets/source/wall.png create mode 100644 vapor/examples/rpg/assets/source/water.png create mode 100644 vapor/runtime/gba/vapor_rpg_assets.generated.h create mode 100644 vapor/scripts/rpg-assets.ts create mode 100644 vapor/tests/rpg-assets.test.ts diff --git a/package.json b/package.json index 3b81e18f..386e064c 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,9 @@ "vapor": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx", "vapor:rpg": "bun vapor/compiler/cli.ts vapor/examples/rpg/rpg.tsx", "vapor:rpg:play": "bun vapor/scripts/play.ts vapor/examples/rpg/rpg.tsx", + "vapor:rpg:assets:generate": "bun vapor/scripts/rpg-assets.ts generate", + "vapor:rpg:assets:build": "bun vapor/scripts/rpg-assets.ts build", + "vapor:rpg:assets:check": "bun vapor/scripts/rpg-assets.ts check", "vapor:play": "bun vapor/scripts/play.ts", "vapor:test": "bun test vapor/tests/", "serve": "bun hosts/web/serve.ts", diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 937a355c..21cfee04 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -2632,6 +2632,7 @@ class AppCompiler { planLines.push( `RPG assets: ${this.rpgMaps.length} map(s), ${tileCount} tiles + collision bytes, ${eventCount} events, ${dialogCount} dialogs; ${this.rpgScreenCount} full-screen effect(s)`, ); + planLines.push("RPG art: 24 BG tiles + 6 world and 2 battle OBJ frames, 4 palette banks; 2688 B ROM copied to VRAM"); planLines.push("RPG host RAM: 3076 B fixed BG1 tilemap + OAM shadow buffers"); } diff --git a/vapor/examples/rpg/assets/README.md b/vapor/examples/rpg/assets/README.md new file mode 100644 index 00000000..b014c844 --- /dev/null +++ b/vapor/examples/rpg/assets/README.md @@ -0,0 +1,47 @@ +# Vapor Quest pixel art + +This folder is the reviewed visual source for the GBA RPG POC. PixelLab creates +the source PNGs; the offline asset compiler fixes their dimensions, transparent +anchors and GBA palettes before emitting the two sheets in `final/` and +`vapor/runtime/gba/vapor_rpg_assets.generated.h`. + +## Art bible + +- Bright field, deep-navy information layer, compact chibi silhouettes. +- Native hard pixels, no antialiasing, gradients, dithering or partial alpha. +- Lighting always comes from the top left; visible outlines use `#181821`. +- BG cells are 8x8. World OBJ frames are 16x16 with a shared y=15 foot + line; battle reuses the same source art as readable 32x32 frames. +- World/UI, hero, elder and slime each have one fixed 4bpp palette bank. +- Flowers remain low-contrast walkable decoration; walls, water and trees read + as barriers. UI texture remains quieter and higher contrast than the world. + +`generation.json` records the exact PixelLab endpoints, prompts, seeds, IDs and +source hashes. It intentionally contains no API token. + +## Rebuild + +Source generation is opt-in because it consumes PixelLab quota and replaces +reviewed art: + +```sh +set -a +source ~/code/.env +set +a +bun run vapor:rpg:assets:generate --force --only=all +``` + +The normal build and CI paths are entirely offline: + +```sh +bun run vapor:rpg:assets:build +bun run vapor:rpg:assets:check +``` + +Do not hand-edit the generated header. Review the two 1x PNG sheets and the +world, dialog and battle mGBA captures after changing any source image. + +Use `--only=style`, `--only=world`, `--only=tree`, `--only=flower`, +`--only=characters`, `--only=hero`, `--only=elder` or `--only=slime` with +`--force` for a targeted iteration. Without `--force`, generation only fills +missing sources. diff --git a/vapor/examples/rpg/assets/final/actors.png b/vapor/examples/rpg/assets/final/actors.png new file mode 100644 index 0000000000000000000000000000000000000000..99520376429d9aa88f104d94d62993b4b9cad61d GIT binary patch literal 890 zcmV-=1BLvFP)AHIP00004b3#c}2nYxW zdPx&BuPX;R9J=WS1o%YF%W+Dp8kkLE{?k5uv^zU&S6=% zxYiZPxz=qQSRBZ6tgWN2IFQLdsGJkKNyD-q$+kRa`Wi^GGn2eCnVA4T_(2^Q3yHCi zz9QdMmevzW)0AjE>8!&A!6lS37LuIw^TB`vI|;trL#bn{J$kw$4EX27P6B|leBB;+ zk^w-gjyl4Hyuoxj{dT@86*3l*6j46dNpk=f0?ff}5(_qAcTM!U+5NG z*@Kg7TLCE|@FXLjRPfzkEbMN7z@h&9ZI>M_fK7}f$s?chUa^pMGTVe-&R-U}xCxAf zjnUr*rH%n#?!9a}V<%hn&F3$sRtR|mA%t0zBJ!$1uDB{s@Z}z|D4ei=3tT$-ucrq9 zMgFa@-x*dRuET8hkpHowAD=hh&)#}XEO|7;I^NE1fH77+ydF0|sbkRi%}%yDaNCcY zmM@C=`~?YrgOE3Q+S$YqLU@tSSV&l}2>_IMh7!+>-=C2|Pj`6{8*tq27bk>xcOmd^ zLs8WYdA6cw#oWU_V4*WCZ0NZ{Ho@BV59`K$vZ$Ka5cE70m zq*Vt>MCf0=pKMm&i!&CIO}w^eliIp}c>w3VPx3dh*E@i*kW@50oy%Tx+tCnMtxj~D z)c`~M$NkCN_TwIP?*kh=$tv^swjVo@Uj-MtD9FubL7*cZ#ok?C!Aczin|z&uIPY1l zX!{<{=At1~G&GK|an=Rd5hF!x<^l)elsX2Cg;aF|Zubi)<@S;+in&Q;Ie~5jzzP_H zNgshr|8I3doZCD^`VrQyU_2(4y`t(CIHjOyPE@|O@N7SIDkw*y&kdlbJJN3gA6cD3 zbujqa)$(1>UzLL3;{f{rE(Ys1QP$B_uU*vthioSv#^c=XY84oVlybkuKZqGIXtIqU QPx$_(?=TR9J=Gn6G=nFc5}6cq)oxj!Nc=1GmXY=BPx@ z@+VAIPR>?NrjE+W;h3Y2%G5DO9TR)P+LES83ykhHE!I|F-qfP|6Pmx(KJ zH)$h#n-I{I1px4AfSEJR$-UiUqxQRvtV@WON$z(TM%ry~8$S6S)_^ZhZ^5!JSmTBW|Pil_KC8gkdCH_2Qm)9vJA#I_3j!*R0>O zIY4)t6ep}2@+vtY8{%0+TcoxFiWAnEJZk0zRaA2UbR3{NVNpbsg?W*zo=<~PJ)Z^u zX;ub1_B%NMKuQ?^z;yJ=PWn2O%GY!(l~0_{O8Y9Ex_yI3KuQ@*$3jXOynP?fy{}pw zil{sIb0Ql5ku0CfTL_ow&lr6`+ z;?S;xkzM`yl7SFgm;3_`M>QL)-YSD7{V}uwfK#77ALvqe?#ve6ft0Q-rJc< zsk$Vc-S$m-8A5|nwx(m)3%O2j>`Uc)|FU_tJz?vLopk?lTFQ%D?3bSE1y9Hdg~i c<=-T~0T`FIwY&8dbN~PV07*qoM6N<$g46vBdH?_b literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/final/battle-actors.png b/vapor/examples/rpg/assets/final/battle-actors.png new file mode 100644 index 0000000000000000000000000000000000000000..f47a08f148faa4973eeecf4127d82fd7419e0b04 GIT binary patch literal 712 zcmV;(0yq7MP)Px%a!Eu%RA_D=(>2YE zU5K$VawVvh0AKt3(AAPborBk}WjmU2mahP5Wx z*Fs2LakBXY#>&W5{-VSdKil91SiW*f-5^cSHR!tjNHcCjz8?h~Du6?E*)BT@q&6^C zMlL2c6hWQ^1PMA3{PD51U)S?Gv=Sh4xYNz&M4ki2M6}xtdS2VdwxMwrP-U(Le%}6u z?EQVr@2dZKCs56>X+B|^;3)-+;>QI#iA|A0Kui{C##y?s z?uW(xvijR?XZzlz@a*NwxJPK>l>`uKD8QNomM%bVFG#6t-$(#r%Gaa<>tk0W_1 ziy_3=Y9Ag0E{bU+fUz=yb;3be?c@4d_Ts)wjZc6oe+JO1!jKW9393Mhojl;GZ?OmH zIE^}QgQ^h<=*sANsqOnfkuw&1Cuz6ky$Wju%wE1=2>>7855}|QITggS==FW{+?T7H zF0QV@A$V~4CaC*Bok1SgiT7`ReN(`@0z}|)P|YBxfWGp55aeO#H({lKIw|S3_E=~t ugj9)R0j6R4^nfPR5inNqqrc17dwu{y*HV6xD-lru0000xEwNMGy;EU1z&au^Ef()aKSSJg+(8*nQ zl{c72$V9{mw6I8AG=FD$!AEU>=4BP7bA_(AWMj@}JW;>-Hc`uZcJ1qPyEZMKJ?+h& z3%~uRx$phIV8Y++r&F$X``@&;oq6$R)@i#nU)axH^efZV>SMmZWWwjbeZhi3*Woe4 z7KUDAfysCF@Oyl%{AF?d`Nx0noxXjm|KYWNdhk!-2k0tMwAR@CYTsjdo%``u;nI8l zPyHK^%|uZNvbFeot%K07+WI%)MyEH`?7CvT@(*VLLsY{t#tcR&_64jNG7Vx23Xui$ Z&TN(AJHGPwUtnl5c)I$ztaD0e0suvqm|Fk< literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/generation.json b/vapor/examples/rpg/assets/generation.json new file mode 100644 index 00000000..b5342416 --- /dev/null +++ b/vapor/examples/rpg/assets/generation.json @@ -0,0 +1,145 @@ +{ + "version": 1, + "provider": "PixelLab", + "apiBase": "https://api.pixellab.ai/v2", + "artDirection": "Vapor Quest: bright field, deep-navy information layer, one-pixel silhouettes", + "assets": { + "styleAnchor": { + "endpoint": "/create-image-bitforge", + "seed": 22051, + "size": { + "width": 96, + "height": 64 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, compact chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. A tiny forest village clearing: grass, a tan footpath, blue stream, gray stone wall, leafy tree, one blue-tunic adventurer, one white-haired elder, and one teal slime. Compose it as a clean asset style board, with no text or UI." + }, + "terrainSets": [ + { + "name": "field", + "endpoint": "/tilesets", + "seed": 22061, + "lower": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Seamless bright green meadow grass, quiet walkable ground, only two or three tiny dark grass clusters, no objects or border.", + "upper": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Seamless warm tan compacted village footpath, quiet walkable ground, only two or three tiny brown stone marks, no grass edge or border.", + "lowerFile": "grass.png", + "upperFile": "path.png" + }, + { + "name": "barriers", + "endpoint": "/tilesets", + "seed": 22062, + "lower": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Seamless deep blue stream water, clearly impassable, two restrained horizontal ripple marks, quiet even field, no shore or border.", + "upper": "Original cheerful early-2000s handheld cartridge RPG terrain pixel art. Strict high top-down orthographic view, crisp hard-edged native pixels. Small flat color clusters, fixed top-left lighting, restrained readable texture. No characters, items, icons, perspective, canvas border or imitation of an existing game. Seamless gray stone barrier, clearly solid and impassable, chunky rectangular stones, dark mortar and bright top-left edges, no grass or border.", + "lowerFile": "water.png", + "upperFile": "wall.png" + } + ], + "mapObjects": [ + { + "name": "tree", + "endpoint": "/map-objects", + "seed": 22069, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, compact chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. One centered top-down deciduous tree map object on transparent background. A single compact, solid, filled round dome canopy occupies most of the image and must never form a hollow ring, split crown, doorway or arch. Dense leaves read as impassable, with dark lower-right foliage, bright top-left leaf clusters and exactly one short centered trunk. No grass tile and no cast shadow." + }, + { + "name": "flower", + "endpoint": "/map-objects", + "seed": 22066, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, compact chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. One tiny gold-and-cream meadow flower map object on transparent background. Low-profile walkable decoration, delicate stem, no enclosing outline, no ground tile and no cast shadow." + } + ], + "heroSouth": { + "endpoint": "/create-image-bitforge", + "seed": 22053, + "size": { + "width": 32, + "height": 32 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, compact chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. One south-facing 32x32 source sprite designed to reduce cleanly to a 16x16 GBA character. Full-body compact chibi adventurer with a readable head, torso, two arms and two separated feet. Blue tunic is the largest color block, warm orange-red scarf is the identity accent, warm skin, deep-navy outline. Centered, feet at the bottom, strong silhouette, no weapon, no detached pixels." + }, + "heroRotations": { + "endpoint": "/create-character-v3", + "seed": 22054, + "prompt": "The same compact blue-tunic adventurer with orange-red scarf, rotated consistently for a high top-down RPG; preserve head height, shoulder width, palette, outline and foot anchor in every direction." + }, + "elder": { + "endpoint": "/create-image-bitforge", + "seed": 22055, + "size": { + "width": 32, + "height": 32 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, compact chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. One south-facing 32x32 source sprite designed to reduce cleanly to a 16x16 GBA character. Full-body chibi village elder with white hair, short white beard, broad gray robe, warm skin, deep-navy outline, two arms and a grounded hem. Same height, head ratio, lighting and foot anchor as the blue-tunic adventurer. No staff, centered, no detached pixels." + }, + "slime": { + "endpoint": "/create-image-bitforge", + "seed": 22056, + "size": { + "width": 32, + "height": 32 + }, + "prompt": "Original cheerful early-2000s handheld cartridge RPG pixel art. High top-down world view, compact chibi characters, crisp hard-edged native pixels. One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting. Readable silhouettes and restrained surface texture; no imitation of any existing game. One 32x32 source sprite designed to reduce cleanly to a 16x16 GBA monster. A low, wide, rounded slime whose body is at least ninety percent bright teal and dark teal, with a deep-navy outline and broad grounded base. White is allowed only for exactly two tiny separated eye highlights; never make the body, face or outline white. Cute but clearly an enemy, no limbs, no floating parts." + } + }, + "records": { + "styleAnchor": { + "hash": "ab120dde50b3a08e4e86333d487232a7b5b476acd50e888fb54d95abad061183" + }, + "heroSouth": { + "hash": "55bf3ce1e947a3f08912d19cb338a56927f759c69d7fb55aca635a9a83ef066f" + }, + "heroRotations": { + "backgroundJobId": "c5f0607e-43b4-4902-9143-5775f2a2afe0", + "characterId": "d1745cff-c0e0-49d3-83ab-83c5f181ea15", + "hashes": { + "south": "fd63f529e6145dc87a9cef5be49636debd049b3d79475f6a5ca3600b0b297f26", + "north": "5d4655028fe5aec7421ad2db926b3914791c2acf5d33e83764bb30c9f168673f", + "west": "049af787c8bbefeebd5a8aac8c49ceb771c0283b2eab758b6d8a183f75dcdfe5", + "east": "e58f4bb26908694014db51ce6448ed1339d2167aab0ca39fc32f577198be9fdc" + } + }, + "elder": { + "hash": "95bc2babbff0540f9d0502d15fec05488df8da677abc170292d0f9ade38fa7ac" + }, + "slime": { + "hash": "ab5626b6524a80f9ffbfe810d12ab1ab13cc7e09c1f8f9d23a99c773d4fc880c" + }, + "world": { + "field": { + "tilesetId": "d4f046c2-c51a-4320-9105-d57229be90e0", + "grass.png": "dea109cc2095b20ff667b1898c13c2c8bf04296a7107efc57ddd333d2a9f6752", + "path.png": "5d8b8931fb0531a2f707af44b49ec0a8556ec5eaf7a047d721d88c6022906ea3" + }, + "barriers": { + "tilesetId": "e198ed1e-9fa9-4c0a-98fd-d43ed4b89d4c", + "water.png": "ac4164f53771c6815d73eebef4a2abddf6046f741abc5a4f90fe27f57b05a736", + "wall.png": "aad4267bcc739461db3c8ffd4df7704726ce0e4a0eb9bd6365371098a8c393df" + }, + "tree": { + "backgroundJobId": "5a47e287-515e-44a4-92d2-05e84b1c338d", + "objectId": "2a08cbfa-921a-412d-8409-18537fc69b09", + "hash": "a7b53e141a9c77e7c9e844b593359f1f49f3138875c60303f8770c921034424a" + }, + "flower": { + "backgroundJobId": "8804c7ae-f599-40af-8436-cc7a817fa7cb", + "objectId": "8109e860-4c59-472c-acb7-78684633994e", + "hash": "00127e82c04bc84466ee9bd88ff3729728fd1cff93ec832deb4c2797d9bded1e" + } + } + }, + "sourceHashes": { + "style-anchor.png": "ab120dde50b3a08e4e86333d487232a7b5b476acd50e888fb54d95abad061183", + "grass.png": "dea109cc2095b20ff667b1898c13c2c8bf04296a7107efc57ddd333d2a9f6752", + "path.png": "5d8b8931fb0531a2f707af44b49ec0a8556ec5eaf7a047d721d88c6022906ea3", + "water.png": "ac4164f53771c6815d73eebef4a2abddf6046f741abc5a4f90fe27f57b05a736", + "wall.png": "aad4267bcc739461db3c8ffd4df7704726ce0e4a0eb9bd6365371098a8c393df", + "tree.png": "a7b53e141a9c77e7c9e844b593359f1f49f3138875c60303f8770c921034424a", + "flower.png": "00127e82c04bc84466ee9bd88ff3729728fd1cff93ec832deb4c2797d9bded1e", + "hero-south-reference.png": "55bf3ce1e947a3f08912d19cb338a56927f759c69d7fb55aca635a9a83ef066f", + "hero-south.png": "fd63f529e6145dc87a9cef5be49636debd049b3d79475f6a5ca3600b0b297f26", + "hero-north.png": "5d4655028fe5aec7421ad2db926b3914791c2acf5d33e83764bb30c9f168673f", + "hero-west.png": "049af787c8bbefeebd5a8aac8c49ceb771c0283b2eab758b6d8a183f75dcdfe5", + "hero-east.png": "e58f4bb26908694014db51ce6448ed1339d2167aab0ca39fc32f577198be9fdc", + "elder.png": "95bc2babbff0540f9d0502d15fec05488df8da677abc170292d0f9ade38fa7ac", + "slime.png": "ab5626b6524a80f9ffbfe810d12ab1ab13cc7e09c1f8f9d23a99c773d4fc880c" + } +} diff --git a/vapor/examples/rpg/assets/source/elder.png b/vapor/examples/rpg/assets/source/elder.png new file mode 100644 index 0000000000000000000000000000000000000000..f5d96ec183ad794b68b0cf89b5e0b98717c934e1 GIT binary patch literal 513 zcmV+c0{;DpP)M66W#<+Nl=snQz=fD8{HF!Q|F*ilvl3zBuQTpv}btZwAn|XJ2Si z?ON}=skIi?RbdfsxBnt3Fd4Gv;q%-nlgy(#p(xyQVA^-BcW~|<7>-b?#5mp(7iqY56X`v$f--k00000NkvXXu0mjf D-ZbaI literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/flower.png b/vapor/examples/rpg/assets/source/flower.png new file mode 100644 index 0000000000000000000000000000000000000000..6898d0c65a58b41b0780e9c299a472ab1a6ef4f5 GIT binary patch literal 315 zcmV-B0mS}^P)693PtbM-_qe8Ja_fmb>q~tt;+N%^QuhA8qKc8M^t4>J}?!ER#QoS-)ypG zgV(V}lQo*^5*)<02YmMZ(Og)gDbF30l8{Ql?{j57!uQyVm`(O*$?KbF6d!CLC)8@- zR}#(dKfs&=6FvJ`#w>yB;sRx-cL_OcUjP6O`xBBSe*^~!90Dx_hJ!D!FsMB5d;AL7T^hX3&z`+9G&x2qAiE-!^kx1mvxdATPe1@tl8?pcZ N002ovPDHLkV1oGkhe-ec literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/grass.png b/vapor/examples/rpg/assets/source/grass.png new file mode 100644 index 0000000000000000000000000000000000000000..3b15c19e77733bee361b6cd729f894d4dff7e991 GIT binary patch literal 184 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`^`0({Ar*6uP7dTcpuppNIiPHz zZ1VBDZ5-e!g)(%y8)8(uSE+o;}Rm8~^Ik>do5U z4%YtvZ>?}v+Hb*Axu33wOzt@ziP8T3jq#8=qu}Q?VG3#M!9~WdY$h%^A%k%=jC3(FXwt?w zxDdy4XiIw`fZ_)-ykF7)z2n~d`rf^J1q4A51VIp152akAlxr4k$63_ElbnyD0oCR@ zb1@)uJUrgD2Pzuqh0s0>z!O4!8cMmwa=fA^&edL*B7!DBDc6L}>hwvyF#b%0F+|W( zXr}W(7Lim4fi=b=^Hv*13Q+|7nu><@1IkkLDl`*f`T$FefcCoCcR!$&(d7u}h1n*b zQd;NXfA#WDFyerhfVkv%LW`+#DP1Uzg&}w4&i0`fS^j2soX~ zEmOPt1Q0u%l18CcXafOYZjwLPLjGo)wo;5SXZxrZTUIo>4F^iOqujs6*qGBYvKjgGz9M4ikzw1;QV zg7&LI@_tFv_U`iD`|iE#T>(K51VIpl{ls-!x^5rGY=b7~?6Ly@@M5e*D3v;Fe^i66 z+tMa*0D$l1_eBN(@Zy9nf|h}G@Fr-RqAf$75vB-=fYRV?e2*^Th+*VM_#Wi^4OJ|b+VKT*X+(x&*+8ATn zA3ImM<6cbh&)LW(!Pap~r2v31nO1fA2}%hSvx(^T1j~|f;oA^5!#gwW`%^*|Iv9O@ zMw>@Xyu3cqeKI$)Z-WPb=j(Hn_5T*80B1J%m|XKb8#tIpO@xgH(^dO_auu|7nRj#c u*TG2JeH7?xpu~1rpCAZ=AP9mW93by{b&n7)*MRc?0000kdk>fy+w)QXnz$ok`wH!EfLY z=>PjV9RfCNSP89nwC4NAwWKbKU`m9z?>i-#OZ2iQcZL0Y(75567@%=5YEd{s+=+NWfrqd}#4>exD7x!un-k{~;v;$Q}uO-{BO!;B?u~ z2IE5um6kYN_Mj?bi;yk>QI)~u^HB4IBOyJ^=QlsFp_a RmpT9d002ovPDHLkV1ibYyGQ^4 literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-south.png b/vapor/examples/rpg/assets/source/hero-south.png new file mode 100644 index 0000000000000000000000000000000000000000..8f6dc86e48a7d74990e0e0e4467d9561c1934023 GIT binary patch literal 451 zcmV;!0X+VRP)0pDjWCVBd2#f$DxJ`#kc>~HzAxqc^BqaItUW)rQ z`0R&t|4#xGMNt$*QIu>H#Y}Rpf1hK~vC_UOVH+g5?>ocXi@F#Pl=rnezi55pk8oqY4+Msx$C}!fTG6)NM^Zyu;phe)S;;{y< z$$x}rz$IG$qlo+lo&iIL8Oure3tndxGBpCecChP15Qesyo`JJb#5AH9(D$8*-cQ90 zOd~RqnX`af10Os)-yFY`8Sn?$H|>-;#DGjkiTFGoY^gTDJrA+lr~n~V=9?oP-d-mk zeeGfz(N3sL1H^3wecxeUK4E`aPCm{zN0?QE{b>o=RRGBdb_$8?D%kZQq|ay;m{k+^ zi;DllE;Nwl!NN{*f50F^?J*Fy6+wuC$gYy2n29_;%23!@Zf5iwncE7?DmmByrTD6Z tdFj|&(TQG`-1lHbQ4~c{6h%=eyaRG^sFu*zmrnoy002ovPDHLkV1m-H#oYh^ literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-west.png b/vapor/examples/rpg/assets/source/hero-west.png new file mode 100644 index 0000000000000000000000000000000000000000..62444e186b77d2a19559ee64f05ae565fad6d981 GIT binary patch literal 461 zcmV;;0W$uHP)YU6vn@biwE`polR5!t?V;{j$L4@EFbnVuR_l_c{lN^V% zm(*$r^t%dreh_k#3n%Bh=U)ItQ4~c{l>bX7mSJNOnXkRCqJ3wRWvJ;e0JuC2YdeAf zafDC|8k302Q=n&;H^(k4?Px+5v9>W|5^=+>>D5)YgScU-9j<0YY0NgNJ zO1E|3323CsPr{7=&`Jn13Tq39*&^FOiGVFi*wJM1<=DX%CBytmXerdY7|#|q+}$vX zUK=z4w|l1mfcJ-rWwI5tNxa00lV{EXY5{&b)FMt}dIyo4TmXDq!0fI?odchHd$&Ig z7XWx);o$m-HidzRHQ;>(mmUMCqtWe8(FGtc0003g`us`UV4g;yE+P<3fH6sM+!iGs zulB=UHsECuHHEydI<_dq{ literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/path.png b/vapor/examples/rpg/assets/source/path.png new file mode 100644 index 0000000000000000000000000000000000000000..6349c7edce51c39613e0d50bc4b2ada33b1cbec2 GIT binary patch literal 86 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`vYsxEAr*6yW1{3g&2MBn;L`al iklBq_!Zg7^l7YdgnrXV0VS_VJA%mx@pUXO@geCwLw-l2A literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/slime.png b/vapor/examples/rpg/assets/source/slime.png new file mode 100644 index 0000000000000000000000000000000000000000..8b2b237a6f2e73ba52d3711a23d6bedad8e65860 GIT binary patch literal 344 zcmV-e0jK_nP)CwFnD&?yxeJke?Ni^Z>~$xwP%(8C+;mrhpLk*B1%^ zfJN{Wxy}XwxiTb4uGObYKIB6GS=o6hz>5I3ad qJ?oye7g(D{?CjebPDMqL2ibr#3(_wE>E$4Ny#NfMRL` z6jK|ZnA!lv)CMS~Hb60ds{R50mY(OHZJ$N)7ypva@ofzd!Y^hH0HD*&4>to7jhFkn z8`1b~$v7eWPshR7U>Fqy2=ns~2PPUx0zh9QzLfzz&qa7kKq&#C6ac`o3;-Vixxq(+ zS#Z&F!{MJ_jL^}9Z()Gw_2DhSD6mfMkHak}B>)bgNSFa|L&o72!4IaL3EHoMdY+4) zPgKVGak!QDmJ3r}XBk75F^t1)MSQ_I<8(l41BCbhKq$qiAhM?mMg`&X002t)=V>sN z_eD4Y_y~G!)uak-4d{8U?De8Y=l2wyl=uAHaQHkB=2+TX{7yGVeorw9EKGx`6o0ue z0pP?u9njVQ!AUGPnEWj6XBn$Jn;Q;BfmI1t0G}TgGe@gYprrv~wF7XW#UINxFj=?I zNlPo77la9AC?y9I0uunxNq_s#{}5UlAP_?5h9d<`?hIJ0km8HKC+il>VJ6KwW@&?e z$7_2AMQCln*kDpn5qbb_LgOmKa$#b*FlB7FSOEm4YzMe8Ky`%Z6o#}+Ma-gDhM~h6 zpNA5$0b9%f=b^xs81lX3&Vcn$01TCOol!OddY=2PUMONdr371Iz#Q_OFQHV1Ba0RC zdG=___G88d0|Ha4c|eH)JdIvJ6ib z9>Tzf#fp=WFg92voT=u^3=lp3Sn$_Om_;#yA6%#%OQpaUPJzF05Kx4qrwb3>CWn_Y z_;GajqSB-*<_eus5?^9K&vP-0V(gzM*gsD`^sdqM}v|%pe%lx%-kM=L0XIX|$iWBj9#uRdHQ7u03h4p}*=fZY;c^%k$ z09=&!PKxDfSbl$g6qv&dV}nVN0UmP7*MJ`417BQ_DaYM%Ws7AQy1ln6Rz1%}li0W> z7Sd#nCb8uto8MC@{AAt2g<;`utBugJ-WBFlqO2k&1WoP?5K3Vd2^v?~S)s(U7ls9& zEYQ3O;m6U*W{W-AFozkef+FOX*{wbc&W|HR_igR!!de|m5lK3hWgwJ--0&QbNIN%@1mP`_ zIsQR=gHb_ngG;O!7g{{OV=0=%23j_@Mpjw2zGv0-RcKcamR)dx6 z1mc*9g~nAPPfjcsCY*-?Vc-Mc@xBRQd1pW*FU(;Ea!&!AR?SqP)d&C%dj+vX?R4|A zC)@lu!q{M-lvKi282E5ITbb?%!Z`q5pb`58olePVXMmu*PXMYrW|8j^BA2hrxuAH- zH)7@Rq)X@#XfX+&V;05Ou~hbMN(sVS0zZzV@BpgGw=;;`(~hNhy706TAJEtU;JgXv zN{z>u!wlvy!|pM_?lGulLp4Kd1AwXtS;jDEZ>klqBg`RR@#Oau&6^O-oAA|VbwGOq z1cUYllXdGPt6An;ehAR31bK}11|;#zdLDcw^V4xEh5270RzbR=?J5X3lqub zFF!u?Kn%7;;zRG5G3 zHk(G_&0bg$!*L=8fb`tpYRiWa_&POV8ih>P*rP^{uISQp7*9AdIcDH5TG zaclT4urQG5x}-P>r01qrBP5nOiR+5VA|yk%<}ykJMdN0Zo*;iNl$ATK=h8J2K%Qs1 zMkp(1l!%IxaN|Dma^r;TWDE&4>LRWy0&W54Fz`4x=zD;A%@mQa>c~)(3N^-=Z#{MfYzoccJ)m zM1Hj;yJyPIQ||%KNXtcL$?dmV04M0Z1QJaF3w2nb00000NkvXX Hu0mjf-{BZk literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/wall.png b/vapor/examples/rpg/assets/source/wall.png new file mode 100644 index 0000000000000000000000000000000000000000..0ddbf5b00ce2bca30fcf85e0ca18f08ef4916b50 GIT binary patch literal 707 zcmV;!0zCbRP)F3?ogv)On1d=9 zcteha3BW-W98`hRZF4#qv+_d>VQ?f&bgg2Sr&N2L;b@GeYZav`pLKsKBupKEWSvl| z5?!mBm`%9mNSJ8B+y~%d`ig@p062f$=VJPbZ7#X~^)H;RgLig9wbz-xpD=wt!LkLL z2Ok08bRB}l8q2m&Wyv2u-y+1G*WXR~{L5GTc@E?N%)Z>geheu(6wEYT<%h~ zxddQ&w*sg&5T=gZ>oU8#C0)n-^VR3`Z=YECA^qVoX}n+^Jy5BF4 zwqG>BblhWRSGV*$uVEpHVyeB4I~vy-Y;#E=Gg>c>Y1=k!+eT&z(-Hs}!oUzlBjz@j zgm;U2f?#$-&-3{3@g4Vzkj`X`%p|4qnO)6mM&6uZ*;Z3v~@j6yyKxV_E{s pwkV5=C|F|mx|~kN07Su(e*p$xI9HLuH!uJI002ovPDHLkV1mmSPvHOn literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/water.png b/vapor/examples/rpg/assets/source/water.png new file mode 100644 index 0000000000000000000000000000000000000000..5dcf999697ed791f5cc33abfec9c84fd5c76515a GIT binary patch literal 359 zcmV-t0hs=YP)njdwDHjP1uOD+v! zkSeEV&`-Y2_ML(A`7)`E{yO0SethK@2K_2_u%tcJeNzz9o=AJC)h4=+^ZCkP%J|XN z{kQdCoC||uFnK|2$r>-!IJ#Of55NZ>d}7doY(w4nZwA|G+m=UvatEANeP__Vg>4nS zCQ}>L^M7KX@-= 0 && x < stride && y >= 0 && y < stride) - pixels[(u16)y * stride + (u16)x] = color; -} - -static void pixel(u8 *pixels, u8 stride, s16 x, s16 y, u8 color) { - if (x >= 0 && x < stride && y >= 0 && y < stride) - pixels[(u16)y * stride + (u16)x] = color; -} - -static void upload_bg_tile(u8 tile, const u8 *pixels) { - volatile u16 *dst = RPG_BG_VRAM + (u16)tile * 16; - u16 i; - for (i = 0; i < 16; i++) { - u16 p = (u16)i * 4; - dst[i] = (u16)(pixels[p] | ((u16)pixels[p + 1] << 4) | - ((u16)pixels[p + 2] << 8) | - ((u16)pixels[p + 3] << 12)); - } -} - -static void upload_obj(u8 base, const u8 *pixels) { - u8 tx, ty, row, col; - for (ty = 0; ty < 2; ty++) { - for (tx = 0; tx < 2; tx++) { - volatile u16 *dst = RPG_OBJ_VRAM + - (u16)(base + ty * 2 + tx) * 16; - for (row = 0; row < 8; row++) { - for (col = 0; col < 8; col += 4) { - u16 at = (u16)(ty * 8 + row) * 16 + tx * 8 + col; - *dst++ = (u16)(pixels[at] | - ((u16)pixels[at + 1] << 4) | - ((u16)pixels[at + 2] << 8) | - ((u16)pixels[at + 3] << 12)); - } - } - } - } -} - -static void make_bg_tile(u8 tile, u8 *p) { - u8 x, y; - pixels_clear(p, 64, C_CLEAR); - switch (tile) { - case TILE_BLANK: - pixels_clear(p, 64, C_DARK); - break; - case TILE_GRASS_A: - case TILE_GRASS_B: - pixels_clear(p, 64, C_GRASS); - pixel(p, 8, tile == TILE_GRASS_A ? 1 : 5, 2, C_GRASS_DARK); - pixel(p, 8, tile == TILE_GRASS_A ? 6 : 2, 6, C_GRASS_DARK); - pixel(p, 8, tile == TILE_GRASS_A ? 2 : 6, 7, C_GRASS_DARK); - break; - case TILE_PATH_A: - case TILE_PATH_B: - pixels_clear(p, 64, C_PATH); - pixel(p, 8, tile == TILE_PATH_A ? 1 : 6, 1, C_PATH_DARK); - pixel(p, 8, tile == TILE_PATH_A ? 6 : 2, 5, C_PATH_DARK); - pixel(p, 8, 3, 7, C_PATH_DARK); - break; - case TILE_WALL: - pixels_clear(p, 64, C_WALL); - for (x = 0; x < 8; x++) { - p[3 * 8 + x] = C_WALL_DARK; - p[7 * 8 + x] = C_WALL_DARK; - } - for (y = 0; y < 3; y++) p[y * 8 + 3] = C_WALL_DARK; - for (y = 4; y < 7; y++) p[y * 8 + 6] = C_WALL_DARK; - break; - case TILE_WATER_A: - case TILE_WATER_B: - pixels_clear(p, 64, C_WATER); - y = tile == TILE_WATER_A ? 2 : 5; - for (x = 0; x < 3; x++) p[(u16)y * 8 + x] = C_WATER_DARK; - for (x = 5; x < 8; x++) p[(u16)(7 - y) * 8 + x] = C_WATER_DARK; - break; - case TILE_TREE: - pixels_clear(p, 64, C_GRASS); - pixels_rect(p, 8, 3, 4, 4, 7, C_PATH_DARK); - pixels_rect(p, 8, 1, 1, 6, 5, C_TREE_DARK); - pixels_rect(p, 8, 2, 0, 5, 4, C_TREE); - pixel(p, 8, 2, 2, C_GRASS); - pixel(p, 8, 5, 4, C_TREE_DARK); - break; - case TILE_FLOWER: - pixels_clear(p, 64, C_GRASS); - pixel(p, 8, 3, 2, C_BORDER); - pixel(p, 8, 2, 3, C_BORDER); - pixel(p, 8, 4, 3, C_BORDER); - pixel(p, 8, 3, 4, C_GOLD); - pixel(p, 8, 3, 5, C_TREE_DARK); - pixel(p, 8, 3, 6, C_TREE_DARK); - break; - case TILE_BOX_FILL: - pixels_clear(p, 64, C_BOX); - break; - case TILE_BOX_TOP: - case TILE_BOX_BOTTOM: - pixels_clear(p, 64, C_BOX); - y = tile == TILE_BOX_TOP ? 0 : 7; - for (x = 0; x < 8; x++) p[(u16)y * 8 + x] = C_BORDER; - break; - case TILE_BOX_LEFT: - case TILE_BOX_RIGHT: - pixels_clear(p, 64, C_BOX); - x = tile == TILE_BOX_LEFT ? 0 : 7; - for (y = 0; y < 8; y++) p[(u16)y * 8 + x] = C_BORDER; - break; - case TILE_BOX_TL: - case TILE_BOX_TR: - case TILE_BOX_BL: - case TILE_BOX_BR: - pixels_clear(p, 64, C_BOX); - if (tile == TILE_BOX_TL || tile == TILE_BOX_BL) x = 0; - else x = 7; - if (tile == TILE_BOX_TL || tile == TILE_BOX_TR) y = 0; - else y = 7; - pixels_rect(p, 8, x, 0, x, 7, C_BORDER); - pixels_rect(p, 8, 0, y, 7, y, C_BORDER); - break; - case TILE_BATTLE_SKY: - pixels_clear(p, 64, C_WATER_DARK); - pixel(p, 8, 1, 1, C_BORDER); - pixel(p, 8, 6, 4, C_WATER); - break; - case TILE_BATTLE_GROUND: - pixels_clear(p, 64, C_GRASS_DARK); - for (x = 0; x < 8; x += 2) p[6 * 8 + x] = C_TREE_DARK; - break; - case TILE_HP_EMPTY: - pixels_clear(p, 64, C_DARK); - pixels_rect(p, 8, 0, 2, 7, 5, C_WALL_DARK); - pixels_rect(p, 8, 1, 3, 6, 4, C_WALL); - break; - case TILE_HP_FULL: - pixels_clear(p, 64, C_DARK); - pixels_rect(p, 8, 0, 2, 7, 5, C_TREE_DARK); - pixels_rect(p, 8, 1, 3, 6, 4, C_GOLD); - break; - case TILE_HUD: - pixels_clear(p, 64, C_DARK); - for (x = 0; x < 8; x++) p[7 * 8 + x] = C_GOLD; - break; - } -} - -static void make_hero(u8 facing, u8 *p) { - pixels_clear(p, 256, 0); - pixels_rect(p, 16, 5, 1, 10, 6, 1); - pixels_rect(p, 16, 6, 2, 9, 6, 2); - pixels_rect(p, 16, 4, 7, 11, 12, 3); - pixels_rect(p, 16, 3, 8, 4, 11, 2); - pixels_rect(p, 16, 11, 8, 12, 11, 2); - pixels_rect(p, 16, 5, 13, 7, 15, 1); - pixels_rect(p, 16, 9, 13, 11, 15, 1); - pixels_rect(p, 16, 5, 7, 10, 8, 4); - if (facing == 0) { - pixel(p, 16, 7, 4, 1); - pixel(p, 16, 9, 4, 1); - } else if (facing == 1) { - pixels_rect(p, 16, 5, 1, 10, 4, 1); - pixel(p, 16, 6, 5, 4); - pixel(p, 16, 9, 5, 4); - } else if (facing == 2) { - pixels_rect(p, 16, 5, 2, 6, 5, 1); - pixel(p, 16, 6, 4, 2); - pixel(p, 16, 5, 4, 1); - } else { - pixels_rect(p, 16, 9, 2, 10, 5, 1); - pixel(p, 16, 9, 4, 2); - pixel(p, 16, 10, 4, 1); - } -} - -static void make_elder(u8 *p) { - pixels_clear(p, 256, 0); - pixels_rect(p, 16, 5, 1, 10, 6, 1); - pixels_rect(p, 16, 6, 2, 9, 5, 2); - pixel(p, 16, 7, 4, 1); - pixel(p, 16, 9, 4, 1); - pixels_rect(p, 16, 5, 6, 10, 9, 4); - pixels_rect(p, 16, 4, 8, 11, 14, 3); - pixels_rect(p, 16, 5, 15, 7, 15, 1); - pixels_rect(p, 16, 9, 15, 11, 15, 1); - pixels_rect(p, 16, 3, 9, 4, 12, 2); - pixels_rect(p, 16, 11, 9, 12, 12, 2); -} - -static void make_slime(u8 *p) { - pixels_clear(p, 256, 0); - pixels_rect(p, 16, 4, 8, 11, 13, 1); - pixels_rect(p, 16, 5, 6, 10, 14, 2); - pixels_rect(p, 16, 3, 10, 12, 13, 2); - pixels_rect(p, 16, 4, 13, 11, 14, 3); - pixel(p, 16, 6, 9, 1); - pixel(p, 16, 9, 9, 1); - pixel(p, 16, 7, 11, 4); - pixel(p, 16, 8, 11, 4); -} - static void upload_transparent_font(void) { volatile u16 *dst = FONT_VRAM + 16; /* tile zero remains transparent */ u16 i; @@ -328,6 +103,16 @@ static void show_object(u8 slot, s16 x, s16 y, u8 tile, u8 palette) { rpg_oam_dirty = 1; } +static void show_object_32(u8 slot, s16 x, s16 y, u8 tile, u8 palette) { + u16 at = (u16)slot * 4; + rpg_oam_shadow[at] = (u16)(y & 0x00ff); /* square, regular OBJ */ + rpg_oam_shadow[at + 1] = (u16)((x & 0x01ff) | 0x8000); /* 32x32 */ + rpg_oam_shadow[at + 2] = (u16)(tile | RPG_OBJ_PRIORITY | + ((u16)palette << 12)); + rpg_oam_shadow[at + 3] = 0; + rpg_oam_dirty = 1; +} + static void map_fill(u8 tile) { u16 i; u16 entry = RPG_ENTRY(tile); @@ -475,8 +260,8 @@ static void draw_battle(s32 hero_hp, s32 enemy_hp, s32 cursor) { for (x = 0; x < 10; x++) map_cell((u8)(2 + x), 13, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); draw_box(2, 14, 27, 19); - show_object(0, 40, 72, 12, 0); /* hero faces right */ - show_object(1, 184, 56, 20, 2); + show_object_32(0, 32, 56, VP_RPG_BATTLE_HERO_TILE, 0); + show_object_32(1, 176, 40, VP_RPG_BATTLE_SLIME_TILE, 2); line_text(1, 17, "WILD SLIME"); line_hp(2, 18, enemy_hp, 18); line_text(10, 2, "HERO"); @@ -508,46 +293,16 @@ u8 vp_rpg_event_at(const vp_rpg_map *map, s32 x, s32 y) { } void vp_rpg_video_init(void) { - static const u16 bg_colors[16] = { - 0x0000, 0x24a3, 0x21e4, 0x2f08, - 0x1570, 0x2a78, 0x3149, 0x5693, - 0x4923, 0x7247, 0x1544, 0x2285, - 0x235f, 0x30c4, 0x4b7d, 0x295f - }; - static const u16 hero_colors[16] = { - 0x0000, 0x1063, 0x32bf, 0x7e08, 0x25ff, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 - }; - static const u16 elder_colors[16] = { - 0x0000, 0x1063, 0x32bf, 0x4a52, 0x7fff, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 - }; - static const u16 slime_colors[16] = { - 0x0000, 0x1063, 0x56a0, 0x2d60, 0x7fff, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 - }; - u8 p8[64]; - u8 p16[256]; - u8 tile, facing, i; + u16 i; upload_transparent_font(); for (i = 0; i < 16; i++) { - PAL_BG[RPG_BG_BANK * 16 + i] = bg_colors[i]; - PAL_OBJ[i] = hero_colors[i]; - PAL_OBJ[16 + i] = elder_colors[i]; - PAL_OBJ[32 + i] = slime_colors[i]; - } - for (tile = 0; tile < TILE_COUNT; tile++) { - make_bg_tile(tile, p8); - upload_bg_tile(tile, p8); - } - for (facing = 0; facing < 4; facing++) { - make_hero(facing, p16); - upload_obj((u8)(facing * 4), p16); + PAL_BG[RPG_BG_BANK * 16 + i] = vp_rpg_bg_palette[i]; } - make_elder(p16); - upload_obj(16, p16); - make_slime(p16); - upload_obj(20, p16); + for (i = 0; i < 3 * 16; i++) PAL_OBJ[i] = vp_rpg_obj_palettes[i]; + for (i = 0; i < VP_RPG_BG_TILE_COUNT * 16; i++) + RPG_BG_VRAM[i] = vp_rpg_bg_tiles[i]; + for (i = 0; i < VP_RPG_OBJ_TILE_COUNT * 16; i++) + RPG_OBJ_VRAM[i] = vp_rpg_obj_tiles[i]; map_fill(TILE_BLANK); hide_objects(); REG_BG1CNT = (u16)(2 | (2 << 2) | (9 << 8)); diff --git a/vapor/runtime/gba/vapor_rpg_assets.generated.h b/vapor/runtime/gba/vapor_rpg_assets.generated.h new file mode 100644 index 00000000..87caf68f --- /dev/null +++ b/vapor/runtime/gba/vapor_rpg_assets.generated.h @@ -0,0 +1,191 @@ +/* Generated by vapor/scripts/rpg-assets.ts. Do not edit by hand. */ +#ifndef VP_RPG_ASSETS_GENERATED_H +#define VP_RPG_ASSETS_GENERATED_H + +#define VP_RPG_BG_TILE_COUNT 24 +#define VP_RPG_OBJ_SMALL_FRAME_COUNT 6 +#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_OBJ_SMALL_FRAME_COUNT * 4) +#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 16) +#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 16) + +static const u16 vp_rpg_bg_palette[16] = { + 0x0000, 0x24a3, 0x21e4, 0x2f08, 0x1570, 0x2a78, 0x3149, 0x5693, + 0x4923, 0x7247, 0x1544, 0x2285, 0x235f, 0x30c4, 0x4b7d, 0x295f, +}; + +static const u16 vp_rpg_obj_palettes[48] = { + 0x0000, 0x1063, 0x32bf, 0x7e08, 0x25ff, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1063, 0x32bf, 0x4a52, 0x7fff, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1063, 0x56a0, 0x2d60, 0x7fff, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, +}; + +static const u16 vp_rpg_bg_tiles[384] = { + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3323, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3233, 0x3233, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3323, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3233, 0x3333, 0x3333, 0x3333, 0x3333, 0x3233, + 0x5555, 0x5555, 0x5545, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5455, 0x5555, 0x5555, 0x4555, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5455, 0x5555, 0x5555, 0x5555, 0x5555, + 0x5455, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5545, + 0x677e, 0x7777, 0x6777, 0x7777, 0x6777, 0x7777, 0x6666, 0x6666, + 0x7777, 0x767e, 0x7777, 0x7677, 0x7777, 0x7677, 0x6666, 0x6666, + 0x9999, 0x9999, 0x9999, 0x9999, 0x9888, 0x9999, 0x9999, 0x9999, + 0x9999, 0x9999, 0x9999, 0x8889, 0x9999, 0x9999, 0x9999, 0x9999, + 0x9999, 0x9999, 0x9999, 0x9999, 0x9999, 0x8889, 0x9999, 0x9999, + 0x9999, 0x9999, 0x9888, 0x9999, 0x9999, 0x9999, 0x9999, 0x9999, + 0xaabb, 0xbbbb, 0xabab, 0xbbb5, 0xaaa2, 0xbbba, 0xaabb, 0xb25a, + 0xaaaa, 0xbaaa, 0x4bbb, 0xbbb5, 0x4333, 0x3334, 0x4333, 0x3334, + 0xbbbb, 0xbbbb, 0xbbbb, 0xbbbb, 0xbbbb, 0xbbbb, 0xcbbb, 0xbbbb, + 0x1bbb, 0xbbbb, 0x23bb, 0xbbbb, 0x2bbb, 0xbbbb, 0xabbb, 0xbbbb, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xeeee, 0xeeee, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, + 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xeeee, 0xeeee, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xeeee, 0xeeee, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xeeee, 0xeeee, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, + 0xddde, 0xdddd, 0xddde, 0xdddd, 0xddde, 0xdddd, 0xeeee, 0xeeee, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, + 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xdddd, 0xeddd, 0xeeee, 0xeeee, + 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, + 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, 0x8888, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x1111, 0x1111, 0x1111, 0x1111, 0x6666, 0x6666, 0x7776, 0x6777, + 0x7776, 0x6777, 0x6666, 0x6666, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0xaaaa, 0xaaaa, 0xccca, 0xaccc, + 0xccca, 0xaccc, 0xaaaa, 0xaaaa, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0xcccc, 0xcccc, +}; + +static const u16 vp_rpg_obj_tiles[896] = { + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x2210, + 0x0000, 0x2221, 0x1000, 0x2224, 0x1000, 0x2224, 0x1000, 0x2421, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0124, 0x0000, + 0x1222, 0x0000, 0x2222, 0x0001, 0x2422, 0x0001, 0x2424, 0x0000, + 0x1000, 0x4212, 0x0000, 0x3244, 0x0000, 0x2241, 0x0000, 0x3311, + 0x0000, 0x3413, 0x0000, 0x2121, 0x0000, 0x1213, 0x0000, 0x0011, + 0x4222, 0x0000, 0x1222, 0x0000, 0x0122, 0x0000, 0x1312, 0x0000, + 0x2432, 0x0000, 0x0012, 0x0000, 0x0011, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x4410, 0x0000, 0x2241, + 0x0000, 0x2224, 0x0000, 0x2224, 0x1000, 0x2222, 0x1000, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0142, 0x0000, 0x1422, 0x0000, + 0x2222, 0x0001, 0x2222, 0x0001, 0x2222, 0x0001, 0x2222, 0x0001, + 0x0000, 0x2224, 0x0000, 0x4410, 0x0000, 0x1111, 0x1000, 0x3332, + 0x1000, 0x3321, 0x0000, 0x3331, 0x0000, 0x1310, 0x0000, 0x0110, + 0x4222, 0x0000, 0x0142, 0x0000, 0x1111, 0x0000, 0x3333, 0x0001, + 0x1233, 0x0000, 0x0333, 0x0000, 0x1311, 0x0000, 0x1100, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2224, 0x1000, 0x2242, + 0x1000, 0x2224, 0x1000, 0x2224, 0x0000, 0x4221, 0x0000, 0x2223, + 0x0000, 0x0000, 0x0001, 0x0000, 0x0144, 0x0000, 0x1222, 0x0000, + 0x2222, 0x0000, 0x2222, 0x0001, 0x2222, 0x0001, 0x2224, 0x0001, + 0x0000, 0x2222, 0x0000, 0x4210, 0x0000, 0x1200, 0x0000, 0x1210, + 0x0000, 0x1210, 0x0000, 0x1400, 0x0000, 0x1400, 0x0000, 0x0000, + 0x2224, 0x0001, 0x1442, 0x0000, 0x0111, 0x0000, 0x3312, 0x0000, + 0x3322, 0x0000, 0x1112, 0x0000, 0x0131, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x4100, 0x0000, 0x2410, + 0x0000, 0x2222, 0x0000, 0x2222, 0x0000, 0x2222, 0x0000, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1124, 0x0000, 0x2222, 0x0000, + 0x4222, 0x0001, 0x2222, 0x0000, 0x4422, 0x0000, 0x1222, 0x0000, + 0x0000, 0x2222, 0x0000, 0x2410, 0x0000, 0x3311, 0x1000, 0x3333, + 0x0000, 0x3310, 0x0000, 0x4430, 0x0000, 0x1011, 0x0000, 0x1000, + 0x1222, 0x0000, 0x0142, 0x0000, 0x0113, 0x0000, 0x1121, 0x0000, + 0x0113, 0x0000, 0x0044, 0x0000, 0x0013, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x3200, 0x0000, 0x2222, 0x0000, 0x2244, + 0x3000, 0x3422, 0x3000, 0x4242, 0x1000, 0x3341, 0x3000, 0x4423, + 0x0000, 0x0000, 0x0002, 0x0000, 0x0322, 0x0000, 0x0442, 0x0000, + 0x3222, 0x0000, 0x1114, 0x0000, 0x0214, 0x0000, 0x0124, 0x0000, + 0x1000, 0x2132, 0x0000, 0x1431, 0x1000, 0x4112, 0x1000, 0x4414, + 0x0000, 0x1441, 0x0000, 0x2143, 0x0000, 0x1211, 0x0000, 0x1100, + 0x0113, 0x0000, 0x0121, 0x0000, 0x1444, 0x0000, 0x0442, 0x0000, + 0x0112, 0x0000, 0x0321, 0x0000, 0x0011, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1200, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, + 0x0000, 0x2221, 0x2000, 0x2222, 0x0000, 0x2422, 0x0000, 0x3331, + 0x0000, 0x3330, 0x0000, 0x3313, 0x0000, 0x3331, 0x0000, 0x0330, + 0x0012, 0x0000, 0x0122, 0x0000, 0x0124, 0x0000, 0x3113, 0x0000, + 0x1333, 0x0000, 0x0133, 0x0000, 0x0133, 0x0000, 0x0031, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x1100, + 0x0000, 0x1100, 0x1000, 0x4411, 0x4100, 0x2444, 0x2210, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1111, 0x0001, + 0x1111, 0x0001, 0x2444, 0x0114, 0x2222, 0x1222, 0x2222, 0x2244, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0012, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x2220, 0x2222, 0x2220, 0x2222, 0x2221, 0x2222, 0x2221, 0x2222, + 0x2221, 0x2222, 0x2221, 0x2222, 0x2221, 0x2222, 0x2221, 0x2222, + 0x2222, 0x4422, 0x2222, 0x4422, 0x2222, 0x4422, 0x2222, 0x1222, + 0x2222, 0x1442, 0x1222, 0x0142, 0x1222, 0x0142, 0x2442, 0x0122, + 0x0014, 0x0000, 0x0014, 0x0000, 0x0004, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x3100, 0x0000, 0x1000, + 0x2221, 0x2222, 0x2210, 0x2222, 0x4100, 0x2244, 0x4100, 0x2244, + 0x3110, 0x1133, 0x3111, 0x3333, 0x3333, 0x3333, 0x1331, 0x3111, + 0x2442, 0x0122, 0x4222, 0x0122, 0x4442, 0x0012, 0x4442, 0x0012, + 0x2111, 0x0012, 0x1333, 0x0012, 0x2441, 0x0111, 0x3331, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1331, 0x3111, 0x4100, 0x3333, 0x3300, 0x4444, 0x3310, 0x3311, + 0x1110, 0x1100, 0x1110, 0x1100, 0x0000, 0x1000, 0x0000, 0x1000, + 0x3331, 0x0122, 0x1113, 0x0011, 0x4334, 0x0004, 0x1333, 0x0000, + 0x1333, 0x0000, 0x1333, 0x0000, 0x1333, 0x0000, 0x1111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1111, 0x0000, 0x2221, 0x0000, 0x2210, + 0x0000, 0x0011, 0x1000, 0x1122, 0x2110, 0x2222, 0x2110, 0x2222, + 0x2221, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x0000, 0x0000, 0x0111, 0x0011, 0x1222, 0x0012, 0x1222, 0x0012, + 0x2222, 0x0001, 0x2222, 0x0012, 0x2222, 0x0122, 0x2222, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x2210, 0x0000, 0x1100, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x3331, 0x3333, 0x3310, 0x3333, 0x3310, 0x3333, 0x3310, 0x3333, + 0x4222, 0x0122, 0x2222, 0x0122, 0x2222, 0x1012, 0x2222, 0x2112, + 0x3333, 0x3101, 0x3333, 0x3313, 0x3333, 0x3333, 0x3333, 0x3333, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, 0x0122, 0x0000, + 0x0133, 0x0000, 0x0013, 0x0000, 0x0013, 0x0000, 0x0013, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3331, 0x3333, 0x3133, 0x3333, 0x3133, 0x1133, 0x3331, 0x3333, + 0x3331, 0x1333, 0x3331, 0x1333, 0x3310, 0x0133, 0x1100, 0x0011, + 0x3333, 0x1333, 0x3333, 0x0133, 0x3113, 0x0013, 0x3333, 0x0133, + 0x3331, 0x0133, 0x3331, 0x0133, 0x3110, 0x0013, 0x0000, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, +}; + +#endif diff --git a/vapor/scripts/rpg-assets.ts b/vapor/scripts/rpg-assets.ts new file mode 100644 index 00000000..c3e0ecf1 --- /dev/null +++ b/vapor/scripts/rpg-assets.ts @@ -0,0 +1,966 @@ +// PixelLab -> deterministic GBA 4bpp assets for the Vapor Quest POC. +// +// `generate` is the only networked step. It reads PIXELLAB_API_KEY from the +// environment and commits no credential. `build` and `check` are offline and +// turn the reviewed source PNGs into exact palette-indexed sheets plus a C +// header consumed by the GBA runtime. + +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { createCanvas, loadImage, type SKRSContext2D } from "@napi-rs/canvas"; + +const ROOT = join(import.meta.dir, "..", "examples", "rpg", "assets"); +const SOURCE = join(ROOT, "source"); +const FINAL = join(ROOT, "final"); +const HEADER = join(import.meta.dir, "..", "runtime", "gba", "vapor_rpg_assets.generated.h"); +const MANIFEST = join(ROOT, "generation.json"); +const API = "https://api.pixellab.ai/v2"; + +type Rgb = readonly [number, number, number]; +type Palette = readonly Rgb[]; +type ImageDataLike = { data: Uint8ClampedArray; width: number; height: number }; + +const BG: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0x21, 0x7b, 0x42], [0x42, 0xc6, 0x5a], + [0x84, 0x5a, 0x29], [0xc6, 0x9c, 0x52], [0x4a, 0x52, 0x63], [0x9c, 0xa5, 0xad], + [0x18, 0x4a, 0x94], [0x39, 0x94, 0xe7], [0x21, 0x52, 0x29], [0x29, 0xa5, 0x42], + [0xff, 0xd6, 0x42], [0x21, 0x31, 0x63], [0xef, 0xde, 0x94], [0xff, 0x52, 0x52], +] as const; + +const HERO: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x18, 0x21], [0xff, 0xad, 0x63], [0x42, 0x84, 0xff], + [0xff, 0x7b, 0x4a], +] as const; +const ELDER: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x18, 0x21], [0xff, 0xad, 0x63], [0x94, 0x94, 0x94], + [0xff, 0xff, 0xff], +] as const; +const SLIME: Palette = [ + [0x00, 0x00, 0x00], [0x18, 0x18, 0x21], [0x00, 0xad, 0xad], [0x00, 0x5a, 0x5a], + [0xff, 0xff, 0xff], +] as const; + +const TILE_NAMES = [ + "blank", "grass-a", "grass-b", "path-a", "path-b", "wall", "water-a", "water-b", + "tree", "flower", "box-fill", "box-top", "box-bottom", "box-left", "box-right", "box-tl", + "box-tr", "box-bl", "box-br", "battle-sky", "battle-ground", "hp-empty", "hp-full", "hud", +] as const; +const ACTOR_NAMES = ["hero-south", "hero-north", "hero-west", "hero-east", "elder", "slime"] as const; + +const STYLE = [ + "Original cheerful early-2000s handheld cartridge RPG pixel art.", + "High top-down world view, compact chibi characters, crisp hard-edged native pixels.", + "One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting.", + "Readable silhouettes and restrained surface texture; no imitation of any existing game.", +].join(" "); + +const TERRAIN_STYLE = [ + "Original cheerful early-2000s handheld cartridge RPG terrain pixel art.", + "Strict high top-down orthographic view, crisp hard-edged native pixels.", + "Small flat color clusters, fixed top-left lighting, restrained readable texture.", + "No characters, items, icons, perspective, canvas border or imitation of an existing game.", +].join(" "); + +const EXCLUSIONS = [ + "antialiasing", "blur", "gradients", "dithering", "soft shadows", "bloom", "photorealism", + "painterly texture", "isometric view", "perspective", "text", "letters", "numbers", "labels", + "watermark", "logo", "mockup", "enlarged preview", +].join(", "); + +const GENERATION = { + version: 1, + provider: "PixelLab", + apiBase: API, + artDirection: "Vapor Quest: bright field, deep-navy information layer, one-pixel silhouettes", + assets: { + styleAnchor: { + endpoint: "/create-image-bitforge", + seed: 22051, + size: { width: 96, height: 64 }, + prompt: `${STYLE} A tiny forest village clearing: grass, a tan footpath, blue stream, gray stone wall, leafy tree, one blue-tunic adventurer, one white-haired elder, and one teal slime. Compose it as a clean asset style board, with no text or UI.`, + }, + terrainSets: [ + { + name: "field", + endpoint: "/tilesets", + seed: 22061, + lower: `${TERRAIN_STYLE} Seamless bright green meadow grass, quiet walkable ground, only two or three tiny dark grass clusters, no objects or border.`, + upper: `${TERRAIN_STYLE} Seamless warm tan compacted village footpath, quiet walkable ground, only two or three tiny brown stone marks, no grass edge or border.`, + lowerFile: "grass.png", + upperFile: "path.png", + }, + { + name: "barriers", + endpoint: "/tilesets", + seed: 22062, + lower: `${TERRAIN_STYLE} Seamless deep blue stream water, clearly impassable, two restrained horizontal ripple marks, quiet even field, no shore or border.`, + upper: `${TERRAIN_STYLE} Seamless gray stone barrier, clearly solid and impassable, chunky rectangular stones, dark mortar and bright top-left edges, no grass or border.`, + lowerFile: "water.png", + upperFile: "wall.png", + }, + ], + mapObjects: [ + { + name: "tree", + endpoint: "/map-objects", + seed: 22069, + prompt: `${STYLE} One centered top-down deciduous tree map object on transparent background. A single compact, solid, filled round dome canopy occupies most of the image and must never form a hollow ring, split crown, doorway or arch. Dense leaves read as impassable, with dark lower-right foliage, bright top-left leaf clusters and exactly one short centered trunk. No grass tile and no cast shadow.`, + }, + { + name: "flower", + endpoint: "/map-objects", + seed: 22066, + prompt: `${STYLE} One tiny gold-and-cream meadow flower map object on transparent background. Low-profile walkable decoration, delicate stem, no enclosing outline, no ground tile and no cast shadow.`, + }, + ], + heroSouth: { + endpoint: "/create-image-bitforge", + seed: 22053, + size: { width: 32, height: 32 }, + prompt: `${STYLE} One south-facing 32x32 source sprite designed to reduce cleanly to a 16x16 GBA character. Full-body compact chibi adventurer with a readable head, torso, two arms and two separated feet. Blue tunic is the largest color block, warm orange-red scarf is the identity accent, warm skin, deep-navy outline. Centered, feet at the bottom, strong silhouette, no weapon, no detached pixels.`, + }, + heroRotations: { + endpoint: "/create-character-v3", + seed: 22054, + prompt: "The same compact blue-tunic adventurer with orange-red scarf, rotated consistently for a high top-down RPG; preserve head height, shoulder width, palette, outline and foot anchor in every direction.", + }, + elder: { + endpoint: "/create-image-bitforge", + seed: 22055, + size: { width: 32, height: 32 }, + prompt: `${STYLE} One south-facing 32x32 source sprite designed to reduce cleanly to a 16x16 GBA character. Full-body chibi village elder with white hair, short white beard, broad gray robe, warm skin, deep-navy outline, two arms and a grounded hem. Same height, head ratio, lighting and foot anchor as the blue-tunic adventurer. No staff, centered, no detached pixels.`, + }, + slime: { + endpoint: "/create-image-bitforge", + seed: 22056, + size: { width: 32, height: 32 }, + prompt: `${STYLE} One 32x32 source sprite designed to reduce cleanly to a 16x16 GBA monster. A low, wide, rounded slime whose body is at least ninety percent bright teal and dark teal, with a deep-navy outline and broad grounded base. White is allowed only for exactly two tiny separated eye highlights; never make the body, face or outline white. Cute but clearly an enemy, no limbs, no floating parts.`, + }, + }, +} as const; + +function ensureDirs(): void { + mkdirSync(SOURCE, { recursive: true }); + mkdirSync(FINAL, { recursive: true }); +} + +function hex([r, g, b]: Rgb): string { + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +function paletteText(palette: Palette): string { + return palette.map(hex).join(", "); +} + +function palettePng(palette: Palette): Buffer { + const canvas = createCanvas(64, 16); + const ctx = canvas.getContext("2d"); + for (let i = 0; i < 16; i++) { + const color = palette[Math.min(i, palette.length - 1)]; + ctx.fillStyle = hex(color); + ctx.fillRect(i * 4, 0, 4, 16); + } + return canvas.toBuffer("image/png"); +} + +function paletteGuidePng(): Buffer { + const canvas = createCanvas(64, 64); + const ctx = canvas.getContext("2d"); + for (const [row, palette] of [BG, HERO, ELDER, SLIME].entries()) { + for (let i = 0; i < 16; i++) { + const color = palette[Math.min(i, palette.length - 1)]; + ctx.fillStyle = hex(color); + ctx.fillRect(i * 4, row * 16, 4, 16); + } + } + return canvas.toBuffer("image/png"); +} + +function base64Image(bytes: Buffer): { type: "base64"; base64: string; format: "png" } { + return { type: "base64", base64: bytes.toString("base64"), format: "png" }; +} + +async function resizePng(bytes: Buffer, width: number, height: number): Promise { + const image = await loadImage(bytes); + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, 0, 0, width, height); + return canvas.toBuffer("image/png"); +} + +async function pixelLab(path: string, body: unknown): Promise { + const key = process.env.PIXELLAB_API_KEY; + if (!key) throw new Error("PIXELLAB_API_KEY is not set; source ~/code/.env first"); + const response = await fetch(`${API}${path}`, { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const detail = (await response.text()).slice(0, 1000); + throw new Error(`PixelLab ${path} failed (${response.status}): ${detail}`); + } + return await response.json() as T; +} + +function imageBytes(image: { base64: string }): Buffer { + const encoded = image.base64.includes("base64,") + ? image.base64.slice(image.base64.indexOf("base64,") + 7) + : image.base64; + return Buffer.from(encoded, "base64"); +} + +async function generateBitforge( + file: string, + spec: { prompt: string; seed: number; size: { width: number; height: number } }, + palette: Palette, + styleImage?: Buffer, + transparent = false, +): Promise<{ hash: string }> { + console.log(`PixelLab: generating ${file}`); + const styleReference = styleImage + ? await resizePng(styleImage, spec.size.width, spec.size.height) + : undefined; + const response = await pixelLab<{ image: { base64: string } }>("/create-image-bitforge", { + description: `${spec.prompt} Use only these palette colors: ${paletteText(palette)}.`, + negative_description: EXCLUSIONS, + image_size: spec.size, + text_guidance_scale: 10, + style_strength: styleImage ? 45 : 0, + outline: "lineless", + shading: "basic shading", + detail: "medium detail", + view: "high top-down", + direction: transparent ? "south" : undefined, + no_background: transparent, + coverage_percentage: transparent ? 76 : 100, + color_image: base64Image(palettePng(palette)), + style_image: styleReference ? base64Image(styleReference) : undefined, + seed: spec.seed, + }); + const bytes = imageBytes(response.image); + writeFileSync(join(SOURCE, file), bytes); + return { hash: sha256(bytes) }; +} + +async function generateHeroRotations(reference: Buffer): Promise<{ + backgroundJobId: string; + characterId: string; + hashes: Record; +}> { + const spec = GENERATION.assets.heroRotations; + console.log("PixelLab: generating consistent hero rotations"); + const created = await pixelLab<{ + background_job_id: string; + character_id: string; + }>("/create-character-v3", { + description: spec.prompt, + reference_image: base64Image(reference), + view: "high top-down", + template_id: "mannequin", + name: "Vapor Quest Hero", + seed: spec.seed, + no_background: true, + }); + + for (;;) { + await Bun.sleep(5000); + const response = await fetch(`${API}/background-jobs/${created.background_job_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!response.ok) throw new Error(`PixelLab job poll failed (${response.status})`); + const job = await response.json() as { status: string; last_response?: unknown }; + console.log(`PixelLab: hero rotations ${job.status}`); + if (job.status === "failed") throw new Error(`PixelLab hero rotation job failed: ${JSON.stringify(job.last_response)}`); + if (job.status === "completed") break; + } + + const detailResponse = await fetch(`${API}/characters/${created.character_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detailResponse.ok) throw new Error(`PixelLab character fetch failed (${detailResponse.status})`); + const detail = await detailResponse.json() as { + rotation_urls: Record | null; + }; + if (!detail.rotation_urls) throw new Error("PixelLab returned no hero rotations"); + const hashes: Record = {}; + for (const direction of ["south", "north", "west", "east"] as const) { + const url = detail.rotation_urls[direction]; + if (!url) throw new Error(`PixelLab returned no ${direction} hero rotation`); + const response = await fetch(url); + if (!response.ok) throw new Error(`PixelLab ${direction} rotation download failed (${response.status})`); + const bytes = Buffer.from(await response.arrayBuffer()); + writeFileSync(join(SOURCE, `hero-${direction}.png`), bytes); + hashes[direction] = sha256(bytes); + } + return { + backgroundJobId: created.background_job_id, + characterId: created.character_id, + hashes, + }; +} + +async function waitForJob(jobId: string, label: string): Promise { + for (;;) { + await Bun.sleep(5000); + const response = await fetch(`${API}/background-jobs/${jobId}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!response.ok) throw new Error(`PixelLab ${label} job poll failed (${response.status})`); + const job = await response.json() as { status: string; last_response?: unknown }; + console.log(`PixelLab: ${label} ${job.status}`); + if (job.status === "failed") throw new Error(`PixelLab ${label} failed: ${JSON.stringify(job.last_response)}`); + if (job.status === "completed") return; + } +} + +async function generateTileset(spec: typeof GENERATION.assets.terrainSets[number]): Promise> { + console.log(`PixelLab: generating ${spec.name} terrain tileset`); + const created = await pixelLab<{ background_job_id: string; tileset_id: string }>("/tilesets", { + lower_description: spec.lower, + upper_description: spec.upper, + transition_description: "clean hard pixel boundary", + tile_size: { width: 16, height: 16 }, + mode: "standard", + text_guidance_scale: 10, + outline: "selective outline", + shading: "basic shading", + detail: "low detail", + view: "high top-down", + tile_strength: 1.3, + tileset_adherence_freedom: 400, + tileset_adherence: 120, + transition_size: 0.25, + seed: spec.seed, + }); + await waitForJob(created.background_job_id, `${spec.name} terrain`); + const response = await fetch(`${API}/tilesets/${created.tileset_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!response.ok) throw new Error(`PixelLab ${spec.name} tileset fetch failed (${response.status})`); + const result = await response.json() as { + tileset: { tiles: Array<{ + corners: Record; + image: { type: "base64"; base64: string; format: string }; + }> }; + }; + const hashes: Record = {}; + for (const [terrain, file] of [["lower", spec.lowerFile], ["upper", spec.upperFile]] as const) { + const tile = result.tileset.tiles.find((candidate) => + Object.values(candidate.corners).every((corner) => corner === terrain)); + if (!tile) throw new Error(`PixelLab ${spec.name} tileset has no all-${terrain} base tile`); + const bytes = imageBytes(tile.image); + writeFileSync(join(SOURCE, file), bytes); + hashes[file] = sha256(bytes); + } + return { backgroundJobId: created.background_job_id, tilesetId: created.tileset_id, ...hashes }; +} + +async function generateMapObject(spec: typeof GENERATION.assets.mapObjects[number]): Promise> { + console.log(`PixelLab: generating ${spec.name} map object`); + const created = await pixelLab<{ background_job_id: string; object_id: string }>("/map-objects", { + description: `${spec.prompt} Use only these palette colors: ${paletteText(BG)}.`, + image_size: { width: 32, height: 32 }, + view: "high top-down", + outline: "selective outline", + shading: "basic shading", + detail: "low detail", + text_guidance_scale: 10, + color_image: base64Image(await resizePng(palettePng(BG), 32, 32)), + seed: spec.seed, + }); + await waitForJob(created.background_job_id, `${spec.name} object`); + const detail = await fetch(`${API}/map-objects/${created.object_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detail.ok) throw new Error(`PixelLab ${spec.name} object fetch failed (${detail.status})`); + const object = await detail.json() as { download_url?: string | null }; + if (!object.download_url) throw new Error(`PixelLab ${spec.name} object has no download URL`); + const download = await fetch(object.download_url); + if (!download.ok) throw new Error(`PixelLab ${spec.name} object download failed (${download.status})`); + const bytes = Buffer.from(await download.arrayBuffer()); + writeFileSync(join(SOURCE, `${spec.name}.png`), bytes); + return { backgroundJobId: created.background_job_id, objectId: created.object_id, hash: sha256(bytes) }; +} + +function sha256(bytes: Uint8Array | string): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function generate(force: boolean): Promise { + ensureDirs(); + const onlyArg = process.argv.find((arg) => arg.startsWith("--only=")); + const only = onlyArg?.slice("--only=".length) ?? "missing"; + if (!["missing", "all", "style", "world", "tree", "flower", "characters", "hero", "elder", "slime"].includes(only)) { + throw new Error("--only must be one of missing, all, style, world, tree, flower, characters, hero, elder, slime"); + } + const previous = existsSync(MANIFEST) + ? JSON.parse(readFileSync(MANIFEST, "utf8")) as { records?: Record } + : {}; + const records: Record = { ...previous.records }; + const selected = (group: string, files: string[]): boolean => + only === "all" || only === group || files.some((file) => !existsSync(join(SOURCE, file))); + + if (selected("style", ["style-anchor.png"]) && (force || !existsSync(join(SOURCE, "style-anchor.png")))) { + records.styleAnchor = await generateBitforge( + "style-anchor.png", + GENERATION.assets.styleAnchor, + BG, + ); + } + const anchor = readFileSync(join(SOURCE, "style-anchor.png")); + const worldFiles = [ + ...GENERATION.assets.terrainSets.flatMap((asset) => [asset.lowerFile, asset.upperFile]), + ...GENERATION.assets.mapObjects.map((asset) => `${asset.name}.png`), + ]; + const worldSelected = only === "all" || only === "world" || only === "tree" || only === "flower" + || worldFiles.some((file) => !existsSync(join(SOURCE, file))); + if (worldSelected) { + records.world = { + ...(typeof records.world === "object" && records.world !== null + ? records.world as Record + : {}), + }; + for (const asset of GENERATION.assets.terrainSets) { + const forceTerrain = force && (only === "all" || only === "world"); + if (forceTerrain || !existsSync(join(SOURCE, asset.lowerFile)) || !existsSync(join(SOURCE, asset.upperFile))) { + (records.world as Record)[asset.name] = await generateTileset(asset); + } + } + for (const asset of GENERATION.assets.mapObjects) { + const file = `${asset.name}.png`; + const forceObject = force && (only === "all" || only === "world" || only === asset.name); + if (forceObject || !existsSync(join(SOURCE, file))) { + (records.world as Record)[asset.name] = await generateMapObject(asset); + } + } + } + + const characterFiles = [ + "hero-south-reference.png", "hero-south.png", "hero-north.png", "hero-west.png", "hero-east.png", + "elder.png", "slime.png", + ]; + const charactersMissing = characterFiles.some((file) => !existsSync(join(SOURCE, file))); + const charactersSelected = only === "all" || only === "characters" || only === "hero" + || only === "elder" || only === "slime" || charactersMissing; + if (charactersSelected) { + const forceHero = force && (only === "all" || only === "characters" || only === "hero"); + if (forceHero || !existsSync(join(SOURCE, "hero-south-reference.png"))) { + records.heroSouth = await generateBitforge( + "hero-south-reference.png", GENERATION.assets.heroSouth, HERO, anchor, true, + ); + } + const rotationsMissing = ["hero-south.png", "hero-north.png", "hero-west.png", "hero-east.png"] + .some((file) => !existsSync(join(SOURCE, file))); + if (forceHero || rotationsMissing) { + records.heroRotations = await generateHeroRotations(readFileSync(join(SOURCE, "hero-south-reference.png"))); + } + const forceElder = force && (only === "all" || only === "characters" || only === "elder"); + if (forceElder || !existsSync(join(SOURCE, "elder.png"))) { + records.elder = await generateBitforge("elder.png", GENERATION.assets.elder, ELDER, anchor, true); + } + const forceSlime = force && (only === "all" || only === "characters" || only === "slime"); + if (forceSlime || !existsSync(join(SOURCE, "slime.png"))) { + records.slime = await generateBitforge("slime.png", GENERATION.assets.slime, SLIME, undefined, true); + } + } + delete records.backgroundAtlas; + const sourceHashes = Object.fromEntries( + ["style-anchor.png", ...worldFiles, ...characterFiles].map((file) => [file, sha256(readFileSync(join(SOURCE, file)))]), + ); + writeFileSync(MANIFEST, `${JSON.stringify({ ...GENERATION, records, sourceHashes }, null, 2)}\n`); + await build(false); +} + +function nearestColor(r: number, g: number, b: number, palette: Palette, start = 0): number { + let best = start; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = start; i < palette.length; i++) { + const color = palette[i]; + const distance = (r - color[0]) ** 2 + (g - color[1]) ** 2 + (b - color[2]) ** 2; + if (distance < bestDistance) { + best = i; + bestDistance = distance; + } + } + return best; +} + +function indicesFromContext(ctx: SKRSContext2D, width: number, height: number, palette: Palette, transparent: boolean): Uint8Array { + const raw = ctx.getImageData(0, 0, width, height) as ImageDataLike; + const result = new Uint8Array(width * height); + for (let i = 0; i < result.length; i++) { + const alpha = raw.data[i * 4 + 3]; + if (transparent && alpha < 128) { + result[i] = 0; + } else { + result[i] = nearestColor(raw.data[i * 4], raw.data[i * 4 + 1], raw.data[i * 4 + 2], palette, transparent ? 1 : 0); + } + } + return result; +} + +function drawIndices(ctx: SKRSContext2D, pixels: Uint8Array, width: number, height: number, palette: Palette, dx: number, dy: number, transparent: boolean): void { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = pixels[y * width + x]; + if (transparent && index === 0) continue; + ctx.fillStyle = hex(palette[index]); + ctx.fillRect(dx + x, dy + y, 1, 1); + } + } +} + +async function loadExact(path: string, width: number, height: number) { + if (!existsSync(path)) throw new Error(`missing source asset: ${path}; run vapor:rpg:assets:generate`); + const image = await loadImage(path); + if (image.width !== width || image.height !== height) { + throw new Error(`${path} must be ${width}x${height}, got ${image.width}x${image.height}`); + } + return image; +} + +async function buildBackground(): Promise<{ png: Buffer; pixels: Uint8Array }> { + const canvas = createCanvas(64, 24); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + const images = Object.fromEntries(await Promise.all([ + ...["grass", "path", "wall", "water"].map(async (name) => [name, await loadExact(join(SOURCE, `${name}.png`), 16, 16)]), + ...["tree", "flower"].map(async (name) => [name, await loadExact(join(SOURCE, `${name}.png`), 32, 32)]), + ])); + const drawTile = (name: string, tile: number, flip = false) => { + const dx = (tile % 8) * 8; + const dy = Math.floor(tile / 8) * 8; + ctx.save(); + if (flip) { + ctx.translate(dx + 8, dy); + ctx.scale(-1, 1); + ctx.drawImage(images[name], 0, 0, 16, 16, 0, 0, 8, 8); + } else { + ctx.drawImage(images[name], 0, 0, 16, 16, dx, dy, 8, 8); + } + ctx.restore(); + }; + const drawObject = (name: string, tile: number, maxWidth: number, maxHeight: number) => { + const image = images[name]; + const scratch = createCanvas(image.width, image.height); + const scratchCtx = scratch.getContext("2d"); + scratchCtx.drawImage(image, 0, 0); + const raw = scratchCtx.getImageData(0, 0, image.width, image.height) as ImageDataLike; + let minX = image.width; + let minY = image.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + if (raw.data[(y * image.width + x) * 4 + 3] >= 128) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + if (maxX < minX || maxY < minY) throw new Error(`${name}.png has no opaque pixels`); + const sourceWidth = maxX - minX + 1; + const sourceHeight = maxY - minY + 1; + const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight); + const width = Math.max(1, Math.round(sourceWidth * scale)); + const height = Math.max(1, Math.round(sourceHeight * scale)); + const ox = (tile % 8) * 8 + Math.floor((8 - width) / 2); + const oy = Math.floor(tile / 8) * 8 + 8 - height; + ctx.imageSmoothingEnabled = true; + ctx.drawImage(image, minX, minY, sourceWidth, sourceHeight, ox, oy, width, height); + ctx.imageSmoothingEnabled = false; + }; + drawTile("grass", 1); + drawTile("grass", 2, true); + drawTile("path", 3); + drawTile("path", 4, true); + drawTile("wall", 5); + drawTile("water", 6); + drawTile("water", 7, true); + drawTile("grass", 8); + drawTile("grass", 9, true); + drawObject("tree", 8, 7, 8); + drawObject("flower", 9, 5, 6); + const indices = indicesFromContext(ctx, 64, 24, BG, false); + + // Hardware semantics override generative ambiguity for the non-art blank and + // guarantee that all nine dialog pieces tile without seams. + const setTile = (tile: number, fill: number, border: "none" | "top" | "bottom" | "left" | "right" | "tl" | "tr" | "bl" | "br") => { + const ox = (tile % 8) * 8; + const oy = Math.floor(tile / 8) * 8; + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) indices[(oy + y) * 64 + ox + x] = fill; + const edge = 14; + if (border === "top" || border === "tl" || border === "tr") for (let x = 0; x < 8; x++) indices[oy * 64 + ox + x] = edge; + if (border === "bottom" || border === "bl" || border === "br") for (let x = 0; x < 8; x++) indices[(oy + 7) * 64 + ox + x] = edge; + if (border === "left" || border === "tl" || border === "bl") for (let y = 0; y < 8; y++) indices[(oy + y) * 64 + ox] = edge; + if (border === "right" || border === "tr" || border === "br") for (let y = 0; y < 8; y++) indices[(oy + y) * 64 + ox + 7] = edge; + }; + setTile(0, 1, "none"); + setTile(10, 13, "none"); + setTile(11, 13, "top"); + setTile(12, 13, "bottom"); + setTile(13, 13, "left"); + setTile(14, 13, "right"); + setTile(15, 13, "tl"); + setTile(16, 13, "tr"); + setTile(17, 13, "bl"); + setTile(18, 13, "br"); + setTile(19, 8, "none"); + setTile(20, 2, "none"); + setTile(21, 1, "none"); + setTile(22, 1, "none"); + setTile(23, 1, "none"); + const paint = (tile: number, x: number, y: number, color: number) => { + const ox = (tile % 8) * 8; + const oy = Math.floor(tile / 8) * 8; + indices[(oy + y) * 64 + ox + x] = color; + }; + setTile(1, 3, "none"); + setTile(2, 3, "none"); + setTile(3, 5, "none"); + setTile(4, 5, "none"); + setTile(5, 7, "none"); + setTile(6, 9, "none"); + setTile(7, 9, "none"); + for (const [tile, marks] of [ + [1, [[1, 2], [6, 6], [2, 7]]], + [2, [[5, 1], [2, 5], [6, 7]]], + ] as const) { + for (const [x, y] of marks) paint(tile, x, y, 2); + } + for (const [tile, marks] of [ + [3, [[1, 1], [6, 5], [3, 7]]], + [4, [[6, 1], [2, 4], [5, 7]]], + ] as const) { + for (const [x, y] of marks) paint(tile, x, y, 4); + } + for (let x = 0; x < 8; x++) { + paint(5, x, 3, 6); + paint(5, x, 7, 6); + } + for (let y = 0; y < 3; y++) paint(5, 3, y, 6); + for (let y = 4; y < 7; y++) paint(5, 6, y, 6); + paint(5, 0, 0, 14); + paint(5, 4, 4, 14); + for (const [tile, y0] of [[6, 2], [7, 5]] as const) { + for (let x = 0; x < 3; x++) paint(tile, x, y0, 8); + for (let x = 5; x < 8; x++) paint(tile, x, 7 - y0, 8); + } + // Preserve the PixelLab tree silhouette while collapsing its source grays + // into the shared foliage ramp. The tiny target needs a closed canopy and a + // deliberate two-pixel trunk to remain a tree rather than a dark arch. + for (let y = 0; y <= 5; y++) { + for (let x = 0; x < 8; x++) { + const ox = (8 % 8) * 8; + const oy = Math.floor(8 / 8) * 8; + const at = (oy + y) * 64 + ox + x; + if (indices[at] === 1 || indices[at] === 6 || indices[at] === 13) indices[at] = 10; + else if (indices[at] === 7 || indices[at] === 14) indices[at] = 11; + } + } + for (let x = 0; x < 8; x++) { + if (x !== 3 && x !== 4) { + paint(8, x, 6, 3); + paint(8, x, 7, 3); + } + } + paint(8, 3, 5, 4); + paint(8, 4, 5, 5); + paint(8, 3, 6, 4); + paint(8, 4, 6, 4); + paint(8, 3, 7, 4); + paint(8, 4, 7, 4); + paint(8, 2, 1, 11); + paint(8, 5, 2, 11); + paint(8, 1, 3, 11); + for (let x = 0; x < 8; x++) { + for (let y = 2; y <= 5; y++) { + paint(21, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 6 : 7); + paint(22, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 10 : 12); + } + paint(23, x, 7, 12); + } + + const finalCanvas = createCanvas(64, 24); + const finalCtx = finalCanvas.getContext("2d"); + drawIndices(finalCtx, indices, 64, 24, BG, 0, 0, false); + return { png: finalCanvas.toBuffer("image/png"), pixels: indices }; +} + +async function normalizeSprite( + path: string, + palette: Palette, + size: number, + maxWidth: number, + maxHeight: number, +): Promise { + if (!existsSync(path)) throw new Error(`missing source asset: ${path}; run vapor:rpg:assets:generate`); + const image = await loadImage(path); + const source = createCanvas(image.width, image.height); + const sourceCtx = source.getContext("2d"); + sourceCtx.drawImage(image, 0, 0); + const raw = sourceCtx.getImageData(0, 0, image.width, image.height) as ImageDataLike; + let minX = image.width; + let minY = image.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + if (raw.data[(y * image.width + x) * 4 + 3] >= 128) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + if (maxX < minX || maxY < minY) throw new Error(`${path} has no opaque actor pixels`); + const sourceWidth = maxX - minX + 1; + const sourceHeight = maxY - minY + 1; + const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight); + const width = Math.max(1, Math.round(sourceWidth * scale)); + const height = Math.max(1, Math.round(sourceHeight * scale)); + const x = Math.floor((size - width) / 2); + const y = size - height; + const target = createCanvas(size, size); + const ctx = target.getContext("2d"); + ctx.clearRect(0, 0, size, size); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, minX, minY, sourceWidth, sourceHeight, x, y, width, height); + return indicesFromContext(ctx, size, size, palette, true); +} + +function polishSlime(slime: Uint8Array, size: number): void { + for (let i = 0; i < slime.length; i++) { + if (slime[i] === 4) slime[i] = 2; + } + let slimeMinX = size; + let slimeMinY = size; + let slimeMaxX = -1; + let slimeMaxY = -1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (slime[y * size + x] !== 0) { + slimeMinX = Math.min(slimeMinX, x); + slimeMinY = Math.min(slimeMinY, y); + slimeMaxX = Math.max(slimeMaxX, x); + slimeMaxY = Math.max(slimeMaxY, y); + } + } + } + const slimeWidth = slimeMaxX - slimeMinX + 1; + const slimeHeight = slimeMaxY - slimeMinY + 1; + const eyeY = slimeMinY + Math.max(1, Math.floor(slimeHeight * 0.36)); + slime[eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.34)] = 4; + slime[eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.66)] = 4; + const tealRows: number[] = []; + for (let y = 0; y < size; y++) { + if (slime.slice(y * size, y * size + size).some((pixel) => pixel === 2 || pixel === 3)) tealRows.push(y); + } + const shadeFrom = tealRows.length > 0 + ? Math.floor((tealRows[0] + tealRows[tealRows.length - 1] + 1) / 2) + : size; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const at = y * size + x; + if (y < shadeFrom && slime[at] === 3) slime[at] = 2; + if (y >= shadeFrom && slime[at] === 2) slime[at] = 3; + } + } +} + +async function buildActors(): Promise<{ + png: Buffer; + pixels: Uint8Array[]; + battlePng: Buffer; + battlePixels: Uint8Array[]; +}> { + const specs = [ + ["hero-south.png", HERO, "person"], + ["hero-north.png", HERO, "person"], + ["hero-west.png", HERO, "person"], + ["hero-east.png", HERO, "person"], + ["elder.png", ELDER, "person"], + ["slime.png", SLIME, "slime"], + ] as const; + const pixels: Uint8Array[] = []; + for (const [file, palette, kind] of specs) { + pixels.push(await normalizeSprite( + join(SOURCE, file), palette, 16, kind === "slime" ? 11 : 10, kind === "slime" ? 9 : 15, + )); + } + // The PixelLab slime source intentionally uses a very small cyan ramp. + // Reserve the lower body for the fixed dark teal so it stays grounded after + // RGB555 quantization and on the dimmer original GBA LCD. + const slime = pixels[5]; + polishSlime(slime, 16); + const canvas = createCanvas(96, 16); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, 96, 16); + for (let i = 0; i < pixels.length; i++) { + const palette = i < 4 ? HERO : i === 4 ? ELDER : SLIME; + drawIndices(ctx, pixels[i], 16, 16, palette, i * 16, 0, true); + } + const battlePixels = [ + await normalizeSprite(join(SOURCE, "hero-east.png"), HERO, 32, 24, 30), + await normalizeSprite(join(SOURCE, "slime.png"), SLIME, 32, 26, 24), + ]; + polishSlime(battlePixels[1], 32); + const battleCanvas = createCanvas(64, 32); + const battleCtx = battleCanvas.getContext("2d"); + battleCtx.clearRect(0, 0, 64, 32); + drawIndices(battleCtx, battlePixels[0], 32, 32, HERO, 0, 0, true); + drawIndices(battleCtx, battlePixels[1], 32, 32, SLIME, 32, 0, true); + return { + png: canvas.toBuffer("image/png"), + pixels, + battlePng: battleCanvas.toBuffer("image/png"), + battlePixels, + }; +} + +function bgr555([r, g, b]: Rgb): number { + return (r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10); +} + +function packTile(pixels: Uint8Array, stride: number, ox: number, oy: number): number[] { + const words: number[] = []; + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x += 4) { + words.push( + pixels[(oy + y) * stride + ox + x] + | (pixels[(oy + y) * stride + ox + x + 1] << 4) + | (pixels[(oy + y) * stride + ox + x + 2] << 8) + | (pixels[(oy + y) * stride + ox + x + 3] << 12), + ); + } + } + return words; +} + +function wordsForBackground(pixels: Uint8Array): number[] { + const words: number[] = []; + for (let tile = 0; tile < TILE_NAMES.length; tile++) { + words.push(...packTile(pixels, 64, (tile % 8) * 8, Math.floor(tile / 8) * 8)); + } + return words; +} + +function wordsForActors(actors: Uint8Array[], battleActors: Uint8Array[]): number[] { + const words: number[] = []; + for (const pixels of actors) { + for (let ty = 0; ty < 2; ty++) for (let tx = 0; tx < 2; tx++) words.push(...packTile(pixels, 16, tx * 8, ty * 8)); + } + for (const pixels of battleActors) { + for (let ty = 0; ty < 4; ty++) for (let tx = 0; tx < 4; tx++) words.push(...packTile(pixels, 32, tx * 8, ty * 8)); + } + return words; +} + +function formatWords(name: string, words: readonly number[], columns = 8): string { + const lines: string[] = []; + for (let i = 0; i < words.length; i += columns) { + lines.push(` ${words.slice(i, i + columns).map((word) => `0x${word.toString(16).padStart(4, "0")}`).join(", ")},`); + } + return `static const u16 ${name}[${words.length}] = {\n${lines.join("\n")}\n};`; +} + +function generatedHeader(bgPixels: Uint8Array, actorPixels: Uint8Array[], battlePixels: Uint8Array[]): string { + const paddedPalette = (palette: Palette): number[] => [ + ...palette.map(bgr555), + ...Array(Math.max(0, 16 - palette.length)).fill(0), + ]; + const palettes = [paddedPalette(HERO), paddedPalette(ELDER), paddedPalette(SLIME)].flat(); + return [ + "/* Generated by vapor/scripts/rpg-assets.ts. Do not edit by hand. */", + "#ifndef VP_RPG_ASSETS_GENERATED_H", + "#define VP_RPG_ASSETS_GENERATED_H", + "", + `#define VP_RPG_BG_TILE_COUNT ${TILE_NAMES.length}`, + `#define VP_RPG_OBJ_SMALL_FRAME_COUNT ${ACTOR_NAMES.length}`, + "#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_OBJ_SMALL_FRAME_COUNT * 4)", + "#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 16)", + "#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 16)", + "", + formatWords("vp_rpg_bg_palette", paddedPalette(BG)), + "", + formatWords("vp_rpg_obj_palettes", palettes), + "", + formatWords("vp_rpg_bg_tiles", wordsForBackground(bgPixels)), + "", + formatWords("vp_rpg_obj_tiles", wordsForActors(actorPixels, battlePixels)), + "", + "#endif", + "", + ].join("\n"); +} + +function assertAssetSemantics(bg: Uint8Array, actors: Uint8Array[], battleActors: Uint8Array[]): void { + if (bg.length !== 64 * 24) throw new Error("background sheet must contain 24 8x8 tiles"); + if (actors.length !== 6 || actors.some((frame) => frame.length !== 256)) throw new Error("actor sheet must contain six 16x16 frames"); + for (const [index, frame] of actors.entries()) { + const visible = frame.filter((pixel) => pixel !== 0).length; + if (visible < (index === 5 ? 20 : 30)) throw new Error(`${ACTOR_NAMES[index]} silhouette is too sparse (${visible} pixels)`); + if (!frame.slice(15 * 16).some((pixel) => pixel !== 0)) throw new Error(`${ACTOR_NAMES[index]} does not touch the shared y=15 foot line`); + const max = index < 4 ? HERO.length - 1 : index === 4 ? ELDER.length - 1 : SLIME.length - 1; + if (frame.some((pixel) => pixel > max)) throw new Error(`${ACTOR_NAMES[index]} exceeds its OBJ palette`); + } + const heroHashes = actors.slice(0, 4).map((pixels) => sha256(pixels)); + if (new Set(heroHashes).size !== 4) throw new Error("hero directions must be visually distinct"); + if (!actors[5].some((pixel) => pixel === 2) || !actors[5].some((pixel) => pixel === 3)) throw new Error("slime must retain both teal shades"); + if (battleActors.length !== 2 || battleActors.some((frame) => frame.length !== 1024)) { + throw new Error("battle actor sheet must contain two 32x32 frames"); + } + for (const [index, frame] of battleActors.entries()) { + if (frame.filter((pixel) => pixel !== 0).length < 100) throw new Error(`battle actor ${index} silhouette is too sparse`); + if (!frame.slice(31 * 32).some((pixel) => pixel !== 0)) throw new Error(`battle actor ${index} does not touch y=31`); + const max = index === 0 ? HERO.length - 1 : SLIME.length - 1; + if (frame.some((pixel) => pixel > max)) throw new Error(`battle actor ${index} exceeds its OBJ palette`); + } +} + +async function build(check: boolean): Promise { + ensureDirs(); + const background = await buildBackground(); + const actors = await buildActors(); + assertAssetSemantics(background.pixels, actors.pixels, actors.battlePixels); + const header = generatedHeader(background.pixels, actors.pixels, actors.battlePixels); + const targets: Array<[string, Buffer | string]> = [ + [join(FINAL, "background.png"), background.png], + [join(FINAL, "actors.png"), actors.png], + [join(FINAL, "battle-actors.png"), actors.battlePng], + [join(FINAL, "palette-guide.png"), paletteGuidePng()], + [HEADER, header], + ]; + if (check) { + for (const [path, expected] of targets) { + if (!existsSync(path)) throw new Error(`generated asset missing: ${path}`); + const actual = readFileSync(path); + const bytes = typeof expected === "string" ? Buffer.from(expected) : expected; + if (!actual.equals(bytes)) throw new Error(`generated asset is stale: ${path}`); + } + console.log(`RPG assets OK: ${TILE_NAMES.length} BG tiles, ${ACTOR_NAMES.length} world actors + 2 battle actors, four fixed 4bpp palettes`); + return; + } + for (const [path, contents] of targets) writeFileSync(path, contents); + console.log(`Built ${join(FINAL, "background.png")}`); + console.log(`Built ${join(FINAL, "actors.png")}`); + console.log(`Built ${join(FINAL, "battle-actors.png")}`); + console.log(`Built ${HEADER}`); +} + +const command = process.argv[2] ?? "check"; +if (command === "generate") await generate(process.argv.includes("--force")); +else if (command === "build") await build(false); +else if (command === "check") await build(true); +else throw new Error(`usage: bun vapor/scripts/rpg-assets.ts [--force]`); diff --git a/vapor/tests/rpg-assets.test.ts b/vapor/tests/rpg-assets.test.ts new file mode 100644 index 00000000..2cfaae85 --- /dev/null +++ b/vapor/tests/rpg-assets.test.ts @@ -0,0 +1,135 @@ +// Deterministic art-pipeline checks for the PixelLab-backed GBA RPG assets. + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { $ } from "bun"; +import { describe, expect, test } from "bun:test"; +import { createCanvas, loadImage } from "@napi-rs/canvas"; + +const ROOT = join(import.meta.dir, "..", "examples", "rpg", "assets"); +const SOURCE = join(ROOT, "source"); +const FINAL = join(ROOT, "final"); +const SCRIPT = join(import.meta.dir, "..", "scripts", "rpg-assets.ts"); +const HEADER = join(import.meta.dir, "..", "runtime", "gba", "vapor_rpg_assets.generated.h"); + +const BG = [ + "0,0,0", "24,41,74", "33,123,66", "66,198,90", "132,90,41", "198,156,82", + "74,82,99", "156,165,173", "24,74,148", "57,148,231", "33,82,41", "41,165,66", + "255,214,66", "33,49,99", "239,222,148", "255,82,82", +]; +const HERO = ["24,24,33", "255,173,99", "66,132,255", "255,123,74"]; +const ELDER = ["24,24,33", "255,173,99", "148,148,148", "255,255,255"]; +const SLIME = ["24,24,33", "0,173,173", "0,90,90", "255,255,255"]; + +async function rgba(path: string): Promise<{ width: number; height: number; data: Uint8ClampedArray }> { + const image = await loadImage(path); + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + return { width: image.width, height: image.height, data: context.getImageData(0, 0, image.width, image.height).data }; +} + +function generatedWordCount(name: string): number { + const header = readFileSync(HEADER, "utf8"); + const match = header.match(new RegExp(`static const u16 ${name}\\[(\\d+)\\] = \\{([\\s\\S]*?)\\n\\};`)); + if (!match) throw new Error(`missing generated array ${name}`); + expect([...match[2].matchAll(/0x[0-9a-f]{4}/g)]).toHaveLength(Number(match[1])); + return Number(match[1]); +} + +describe("Vapor Quest GBA art pipeline", () => { + test("reviewed PNG inputs reproduce every committed output offline", async () => { + await $`bun ${SCRIPT} check`.quiet(); + expect(generatedWordCount("vp_rpg_bg_palette")).toBe(16); + expect(generatedWordCount("vp_rpg_obj_palettes")).toBe(48); + expect(generatedWordCount("vp_rpg_bg_tiles")).toBe(24 * 16); + expect(generatedWordCount("vp_rpg_obj_tiles")).toBe(56 * 16); + }); + + test("generation provenance is complete and contains no credential", () => { + const text = readFileSync(join(ROOT, "generation.json"), "utf8"); + expect(text).not.toMatch(/PIXELLAB_API_KEY|Authorization|Bearer\s/i); + const manifest = JSON.parse(text) as { + provider: string; + apiBase: string; + records: { world: Record; heroRotations: { characterId: string } }; + sourceHashes: Record; + }; + expect(manifest.provider).toBe("PixelLab"); + expect(manifest.apiBase).toBe("https://api.pixellab.ai/v2"); + expect(Object.keys(manifest.records.world).sort()).toEqual(["barriers", "field", "flower", "tree"]); + expect(manifest.records.heroRotations.characterId).toMatch(/^[0-9a-f-]{36}$/); + for (const [file, expected] of Object.entries(manifest.sourceHashes)) { + const actual = createHash("sha256").update(readFileSync(join(SOURCE, file))).digest("hex"); + expect(actual, file).toBe(expected); + } + }); + + test("the background sheet is exactly 24 opaque 8x8 tiles in one fixed bank", async () => { + const image = await rgba(join(FINAL, "background.png")); + expect([image.width, image.height]).toEqual([64, 24]); + const allowed = new Set(BG); + for (let at = 0; at < image.data.length; at += 4) { + expect(image.data[at + 3]).toBe(255); + expect(allowed.has(`${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`)).toBe(true); + } + }); + + test("six actor frames share a foot line and stay inside their OBJ banks", async () => { + const image = await rgba(join(FINAL, "actors.png")); + expect([image.width, image.height]).toEqual([96, 16]); + const frameHashes: string[] = []; + const frameColors: Set[] = []; + for (let frame = 0; frame < 6; frame++) { + const allowed = new Set(frame < 4 ? HERO : frame === 4 ? ELDER : SLIME); + const colors = new Set(); + let visible = 0; + let grounded = false; + const bytes: number[] = []; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const at = (y * 96 + frame * 16 + x) * 4; + const alpha = image.data[at + 3]; + expect(alpha === 0 || alpha === 255).toBe(true); + bytes.push(image.data[at], image.data[at + 1], image.data[at + 2], alpha); + if (alpha === 0) continue; + visible++; + if (y === 15) grounded = true; + const color = `${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`; + colors.add(color); + expect(allowed.has(color)).toBe(true); + } + } + expect(visible).toBeGreaterThanOrEqual(frame === 5 ? 20 : 30); + expect(grounded).toBe(true); + frameHashes.push(createHash("sha256").update(Uint8Array.from(bytes)).digest("hex")); + frameColors.push(colors); + } + expect(new Set(frameHashes.slice(0, 4)).size).toBe(4); + expect(frameColors[5]).toEqual(new Set(SLIME)); + }); + + test("battle reuses the same actors and palettes at a readable 32x32 scale", async () => { + const image = await rgba(join(FINAL, "battle-actors.png")); + expect([image.width, image.height]).toEqual([64, 32]); + for (let frame = 0; frame < 2; frame++) { + const allowed = new Set(frame === 0 ? HERO : SLIME); + let visible = 0; + let grounded = false; + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const at = (y * 64 + frame * 32 + x) * 4; + const alpha = image.data[at + 3]; + expect(alpha === 0 || alpha === 255).toBe(true); + if (alpha === 0) continue; + visible++; + if (y === 31) grounded = true; + expect(allowed.has(`${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`)).toBe(true); + } + } + expect(visible).toBeGreaterThan(100); + expect(grounded).toBe(true); + } + }); +}); diff --git a/vapor/tests/rpg.test.ts b/vapor/tests/rpg.test.ts index c305bfe1..b708f904 100644 --- a/vapor/tests/rpg.test.ts +++ b/vapor/tests/rpg.test.ts @@ -1,7 +1,7 @@ // Pocket Vapor RPG POC: compiler contract + a complete native GBA play tape. import { beforeAll, describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { $ } from "bun"; import { compileVaporApp, type CompiledApp, VaporCompileError } from "../compiler/compile.ts"; @@ -14,6 +14,7 @@ const ENTRY = join(HERE, "..", "examples", "rpg", "rpg.tsx"); const OUT = join(HERE, "..", "..", "dist", "vapor"); const ROM = join(OUT, "rpg.gba"); const RUNNER = join(HERE, "harness", "mgba_runner"); +const ASSET_HEADER = join(HERE, "..", "runtime", "gba", "vapor_rpg_assets.generated.h"); const DEBUG_STATE = 0x02000010; let source = ""; @@ -42,6 +43,17 @@ function value(label: string, name: string): number { return runReads[`${label}_${name}`] as number; } +function generatedWords(name: string): number[] { + const header = readFileSync(ASSET_HEADER, "utf8"); + const match = header.match(new RegExp(`static const u16 ${name}\\[\\d+\\] = \\{([\\s\\S]*?)\\n\\};`)); + if (!match) throw new Error(`missing generated asset array ${name}`); + return [...match[1].matchAll(/0x([0-9a-f]{4})/g)].map((word) => Number.parseInt(word[1], 16)); +} + +function littleEndianHex(words: number[]): string { + return words.map((word) => `${(word & 0xff).toString(16).padStart(2, "0")}${(word >> 8).toString(16).padStart(2, "0")}`).join(""); +} + async function countPpmColors(path: string): Promise { const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); const head = new TextDecoder().decode(bytes.slice(0, 64)); @@ -82,6 +94,10 @@ beforeAll(async () => { lines.push(`S ${worldShot}`); lines.push("D world_bg1 0x06004800 2048"); lines.push("D world_oam 0x07000000 24"); + lines.push("D asset_bg_tiles 0x06008000 768"); + lines.push("D asset_obj_tiles 0x06010000 1792"); + lines.push("D asset_bg_palette 0x050001e0 32"); + lines.push("D asset_obj_palettes 0x05000200 96"); // Walk into the north wall, then restore the spawn row. lines.push(press(Button.Up), press(Button.Up)); @@ -211,10 +227,11 @@ describe("Pocket Vapor RPG host", () => { expect(app.c).toContain("vp_rpg_blocked(&RPG_RPG_MAP"); expect(app.c).toContain("vp_rpg_event_at(&RPG_RPG_MAP"); expect(app.c).toContain("vp_rpg_render(&RPG_RPG_MAP"); - expect(app.c).toContain("SLIME BLOCKS THE EAST ROAD."); + expect(app.c).toContain("SLIME BLOCKS EAST ROAD."); expect(app.graph).toContain("mode (num)"); expect(app.graph).toContain("questActive:"); expect(app.plan).toContain("RPG host RAM: 3076 B"); + expect(app.plan).toContain("RPG art: 24 BG tiles + 6 world and 2 battle OBJ frames, 4 palette banks; 2688 B ROM"); }); test("pixel RPG host is explicitly GBA-only", () => { @@ -305,4 +322,11 @@ describe("native GBA RPG play tape", () => { expect(runReads.world_bg1).not.toBe(runReads.battle_bg1); expect(runReads.world_oam).not.toBe(runReads.battle_oam); }); + + test("generated 4bpp art and palettes arrive in the exact GBA VRAM banks", () => { + expect(runReads.asset_bg_tiles).toBe(littleEndianHex(generatedWords("vp_rpg_bg_tiles"))); + expect(runReads.asset_obj_tiles).toBe(littleEndianHex(generatedWords("vp_rpg_obj_tiles"))); + expect(runReads.asset_bg_palette).toBe(littleEndianHex(generatedWords("vp_rpg_bg_palette"))); + expect(runReads.asset_obj_palettes).toBe(littleEndianHex(generatedWords("vp_rpg_obj_palettes"))); + }); }); From c8f04e860c77d38be054dff954b676dbbe5188e7 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:29:41 +0800 Subject: [PATCH 4/5] feat(vapor): zoom RPG world presentation --- vapor/README.md | 4 + vapor/compiler/compile.ts | 2 +- vapor/examples/rpg/assets/README.md | 21 +- vapor/examples/rpg/assets/final/actors.png | Bin 890 -> 3301 bytes .../examples/rpg/assets/final/background.png | Bin 590 -> 1241 bytes .../rpg/assets/final/battle-actors.png | Bin 712 -> 2119 bytes .../rpg/assets/final/palette-guide.png | Bin 395 -> 450 bytes vapor/examples/rpg/assets/generation.json | 140 ++-- vapor/examples/rpg/assets/source/elder.png | Bin 513 -> 3783 bytes vapor/examples/rpg/assets/source/flower.png | Bin 315 -> 728 bytes vapor/examples/rpg/assets/source/grass.png | Bin 184 -> 563 bytes .../examples/rpg/assets/source/hero-east.png | Bin 505 -> 1537 bytes .../examples/rpg/assets/source/hero-north.png | Bin 452 -> 1353 bytes .../assets/source/hero-south-reference.png | Bin 423 -> 1374 bytes .../examples/rpg/assets/source/hero-south.png | Bin 451 -> 1449 bytes .../examples/rpg/assets/source/hero-west.png | Bin 461 -> 1443 bytes vapor/examples/rpg/assets/source/path.png | Bin 86 -> 392 bytes vapor/examples/rpg/assets/source/slime.png | Bin 344 -> 1408 bytes .../rpg/assets/source/style-anchor.png | Bin 1762 -> 2075 bytes vapor/examples/rpg/assets/source/tree.png | Bin 621 -> 934 bytes vapor/examples/rpg/assets/source/wall.png | Bin 707 -> 186 bytes vapor/examples/rpg/assets/source/water.png | Bin 359 -> 322 bytes vapor/runtime/gba/vapor_rpg.c | 188 +++-- .../runtime/gba/vapor_rpg_assets.generated.h | 647 +++++++++++++--- vapor/scripts/rpg-assets.ts | 707 ++++++++++++------ vapor/tests/rpg-assets.test.ts | 106 ++- vapor/tests/rpg.test.ts | 62 +- 27 files changed, 1373 insertions(+), 504 deletions(-) diff --git a/vapor/README.md b/vapor/README.md index fd939c4c..9efe36a4 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -190,10 +190,14 @@ a second gameplay language or VM: - One stateless `` receives that complete reactive state. On GBA the compiler lowers it to the fixed tile/sprite RPG renderer rather than shipping Vue or JavaScript in the ROM. +- The world uses 16×16 logical metatiles with a clamped 15×10 following + camera, detailed 32×32 world actors, and 64×64 battle portraits. HUD, + dialogue, font, and command UI remain on the native 8×8 screen grid. The demo's ten reactive refs occupy 40 bytes. The fixed GBA host adds 3,076 bytes of BG1 tilemap and OAM shadow buffers so all VRAM commits stay inside vblank; map, collision, event, dialogue, tile and sprite assets remain in ROM. +The fixed four-bank art payload is 9,024 bytes. The demo is one complete loop: start at `(2,2)`, walk next to the solid Elder, face them and press A, choose whether to accept the quest, walk onto diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 21cfee04..8a7507d3 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -2632,7 +2632,7 @@ class AppCompiler { planLines.push( `RPG assets: ${this.rpgMaps.length} map(s), ${tileCount} tiles + collision bytes, ${eventCount} events, ${dialogCount} dialogs; ${this.rpgScreenCount} full-screen effect(s)`, ); - planLines.push("RPG art: 24 BG tiles + 6 world and 2 battle OBJ frames, 4 palette banks; 2688 B ROM copied to VRAM"); + planLines.push("RPG art: 54 BG tiles + 6 32x32 world and 2 64x64 battle OBJ frames, 4 palette banks; 9024 B ROM copied to VRAM"); planLines.push("RPG host RAM: 3076 B fixed BG1 tilemap + OAM shadow buffers"); } diff --git a/vapor/examples/rpg/assets/README.md b/vapor/examples/rpg/assets/README.md index b014c844..e57bd5e8 100644 --- a/vapor/examples/rpg/assets/README.md +++ b/vapor/examples/rpg/assets/README.md @@ -2,16 +2,22 @@ This folder is the reviewed visual source for the GBA RPG POC. PixelLab creates the source PNGs; the offline asset compiler fixes their dimensions, transparent -anchors and GBA palettes before emitting the two sheets in `final/` and +anchors and GBA palettes before emitting the reviewed sheets in `final/` and `vapor/runtime/gba/vapor_rpg_assets.generated.h`. ## Art bible -- Bright field, deep-navy information layer, compact chibi silhouettes. +- Bright field, deep-navy information layer, expressive close-up chibi silhouettes. - Native hard pixels, no antialiasing, gradients, dithering or partial alpha. -- Lighting always comes from the top left; visible outlines use `#181821`. -- BG cells are 8x8. World OBJ frames are 16x16 with a shared y=15 foot - line; battle reuses the same source art as readable 32x32 frames. +- Lighting always comes from the top left; visible outlines use `#18294a`. +- Logical world cells are 16x16 metatiles, so the 240x160 screen shows a + camera-followed 15x10 slice of the map. UI pieces remain screen-space 8x8. +- World OBJ frames are 32x32 with a shared y=31 foot line. Battle derives + readable 64x64 close-up frames from the same reviewed PixelLab sources. +- The SLIME gameplay role uses a compact teal tentacled puddle-ooze silhouette; + its short pseudopods, two white eyes and broad ground contact are intentional. +- Generated terrain luminance clusters remain the base texture; deterministic + material ramps and sparse accents make them legible in one fixed 4bpp bank. - World/UI, hero, elder and slime each have one fixed 4bpp palette bank. - Flowers remain low-contrast walkable decoration; walls, water and trees read as barriers. UI texture remains quieter and higher contrast than the world. @@ -38,8 +44,9 @@ bun run vapor:rpg:assets:build bun run vapor:rpg:assets:check ``` -Do not hand-edit the generated header. Review the two 1x PNG sheets and the -world, dialog and battle mGBA captures after changing any source image. +Do not hand-edit the generated header. Review `background.png`, `actors.png`, +`battle-actors.png`, the palette guide, and the world/dialog/battle mGBA +captures after changing any source image. Use `--only=style`, `--only=world`, `--only=tree`, `--only=flower`, `--only=characters`, `--only=hero`, `--only=elder` or `--only=slime` with diff --git a/vapor/examples/rpg/assets/final/actors.png b/vapor/examples/rpg/assets/final/actors.png index 99520376429d9aa88f104d94d62993b4b9cad61d..d964dd789756abad168378f783144494df1ab63f 100644 GIT binary patch literal 3301 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 delta 695 zcmV;o0!aPG5Xc1~iBL{Q4GJ0x0000DNk~Le0000$0000W2nGNE0HU3Kgpna1e*$tz zL_t(&f$dl;a>FnXU3W@)gB7${1H(ccSk^1FOohHeSzruvgoL%MreT38tD$v+LKP%Z z6v-dSmTPsUZzdCuEy>!aw@)hr{No?JfEaVcm`^>Ap@{l+Yzv;TviG>Zjhm?_Fg0#n z8mp^bJ>8oP<^U}_-7oaL+2HO-7&h_NzqC8(7EU;F&f)sjJ-gV(QR zJDPEpuK;OgJ@$ACm=kG&A!8f@Jm$YD`!0ypcTt?zLP%Y4viStY%E(pzf1<<|Kil91 zSiW*f-5^cSHR!tjNHcCjz8?h~Du6?E*)BT@q&6^CMlL2c6hWQ^1PMA3{PD51U)S?G zv=Sh4xYNz&M4ki2M6}xtdS2VdwxMwrP-U(Le%}6u?EQVr@2dZKCs56>X+B|^;3)-+ z;>QI#iA|(1ePv9Z!buxYu`u!V$AD;)Nx!VmyaWPDvKe+*lHgh11^ebB!ICpf_1_{ zS?%NcTK3|;OpQ-~Dt`vhs=|;FqzS4(jGa8-s&BCe=s1l!Z-c54e+uZz=z6K``#_O1 z7JDaYx8=PGYX!_+zF-LeAKwqgv*kGz#Ixx2ee~RytD7#auE8OAaQP;v`#_yR9@mNY zZ-0GLz`FuO;BrvSAg6%7@_i8GVdyturGPpq>9zJ)XexwMiDLn#VfyrdCe#ryR`H|1 d%h!8;07BPNev&H@4N(99002ovPDHLkV1jVtJ-q+` diff --git a/vapor/examples/rpg/assets/final/palette-guide.png b/vapor/examples/rpg/assets/final/palette-guide.png index e75ebb6d5f8a504554de1fc861b5044fe26696eb..a5d3f46aab871cb45caef0df459f08c83d333628 100644 GIT binary patch delta 336 zcmV-W0k8gx1HuE4L4RyXL_t(|ob8z*SHb`oMZb9r&|DjFYm3@uT*S6TBb8`0VzluW zh!~0c3EDIop-Gdbl}BK~bG%{iw#UY}=bja|BqDy|h^(9*&looc+4)^2yI#KOu1ndA zO)9%-_4<#itzLiH%HDOg>`^sn8^dgx?d!~(!^lUV^CGW$+g?R8h% zvFzfoklpPX+4W4nKY6~9y{+{B{azmzL^}V_4_10JH#&1JD9A zz0U(~zp|50^aXyk(D(a!uIJB2`hP!3`rhdOFN41W9Do*}aR6F?#sO#n8V8^SXdHkR ipm6|NfW`r60h(XaLn*3cF* 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 D-ZbaI diff --git a/vapor/examples/rpg/assets/source/flower.png b/vapor/examples/rpg/assets/source/flower.png index 6898d0c65a58b41b0780e9c299a472ab1a6ef4f5..30cb9c73733cd40f9f6797b3611f4dccf4ef9c06 100644 GIT binary patch delta 705 zcmV;y0zUn_0@wwRBYy&)NklOfLg9FdXCQO7w>f&&NcCPLEDu0UYFne}tI+}-=$_g>x?c*FmWi&GD}qHIMB zYwZKD(8)=6y*uTQ3PP(#ih&Y_-yQTw5jVyDGXjxXqSKT#)qkX^2B5z=<)#w|xT37o z>!hjXCh`E#Y0512P(fH)h4*C-EOc_w>Uee(dwh)MOm5o%tN?f{d)-;E%URbhXI%@} zQ?f4wSCn-{?F8`yd9hX&2TPE5cRN-9v^t&zNQV83(1A!N=fpn@6@+#<>)Jfq<*Zv| zeQx$}#8)uUbAPIvAp<2`UmA3pqDPq;6)`(|?5L8uYjI(gaFP!ipFMN{Z&&j$eCy zX)r?#TXR!ExI^@>5ddfLzejorRo6@UzE`R&;%;Ki(yx*#GG>Kz8+>Uzz ztt4~3(0Ot`p+?Oa6W!}VfgD1OWY|A=|K1X82T)!rUzI ze77L=V}E+bJ`%4=Gcsy^c;(AZtXY;n81LX(gE&d5N5c$Mh0@j;jSp z-AZ4LwXa521tF$k=&!5A;+NWruPc|iSrqG1&&FgIq1`qbC)c`;R n;UD*Gvqc5rQJ>%&-T?3mSoT83{kYtP00000NkvXXu0mjfPt#Ue delta 289 zcmV++0p9-D1-k-}BYyz`Nkl693PtbM-_qe8Ja_fmb>q~tt;+N% z^QuhA8qKc8M^t4>J}?!ER#QoS-)ypGgV(V}lQo*^5*)<02Y-C_{n1=lqbbiFl#-B2 z!S8crKEn6di*4}sr*{cCY+nEX z4*L_5C4U453FPCgfI9;22;lw`xH9|-?3_64oB)7rl09Tv6R6W&_uyq(C-g@ISHQsn n;Ln3#0f}+w6&jI9tJhcn@tC%Wr@ z*@IXpHunos<$t)hS7BZycsIp*!FpNoBzvI5t@J6{u(h*}l##Q8mT~lPNStVTq9`d^ zfS#yaRMX&+pqr)32Ob;>r!Sxx7)Hl6E(x>H=IB{S%tAt^c6fBt%ALF!WPqB6R5|)D zHT{S1>)Qk+4keB}T2W0YDF{G0Dj1Ld!gEhJxl36VY=7?-xJ!e+5%J|>?6U`*f;iFq zeA^Hn-{JhpI;Y>KxJ$#}XvE~p70!g*e+qW73n6*}Ah#2n`vt$NF#y}UHyIpV(F_cK zK6&^tW@~31O+~0!r!vMnJrGS}~=HY3*U+U7Uvn%$&egFSEAk7<2pyeL0HUqX2GnIh4=4G^YrlR1P5!gS-5Zx((5*mg!gt+Se000000 LNkvXXu0mjfc#=wZ diff --git a/vapor/examples/rpg/assets/source/hero-east.png b/vapor/examples/rpg/assets/source/hero-east.png index 9c8e564c984a09040e204b22b24d06f9bd17390f..816d98ddad39a2ff2b7fca7aed05391caac9095f 100644 GIT binary patch literal 1537 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 RmpT9d002ovPDHLkV1ibYyGQ^4 diff --git a/vapor/examples/rpg/assets/source/hero-south.png b/vapor/examples/rpg/assets/source/hero-south.png index 8f6dc86e48a7d74990e0e0e4467d9561c1934023..1efc3c968dbbbeaa7c9454434c6452e64141ae26 100644 GIT binary patch literal 1449 zcmV;a1y=frP)6Q8SK4hKzqJEY0EyoL!1Y|Zt+Sdzy4=v4XVp_y-ivL6QUGce0BEZN zJR|Wt1l=~v?T&8ijC8pH&|=$mKm5}404k`&@0eK&u(*wIZpzK|+*%vJa=RnwwoBWA z53jqFcgL0|t`-BIu75%LurenPT!0a{dVVh~MPA@~F4qrpu;;$47>H7%fNdDiic+AQ znMGb48EO|%-S5pa76TveV;?AIy-n_-X&0fO6ck_^24>&&TwKpJhC#P&1ch=oyPnHp z;B);jHxK9V+D+56z~&WiW!Wi_2)b=t&tV!KThH7f?ez80)w z+p0Tw3#}-{xlgOfhb{MI&p_C(_~%~0rq7#1Ze^+D2!=e1KQWyQ1I`@KF9D6FZt^Ou{NBLu=#!1;9 z3u50YHq2l(P891^eAG~_{T|kVtH-);81@+_<#*HnWTgLqru~)DuXY&r&B?o-QxmZI zspyd2Jkz4qxa&F4{QX%mgO51(f?9FsC2_+|n{&^u;?5zBe$hDh0-o}eaiW-hDl@X- zq|DIt@l&223EZ1!M%Y99;UuZ`ph$#Jmu+;&;Y^Us|iekdB&v24xUwTAUz$hvgxx1e8T<8f0O!I)JJXJI} ziyNE4vA<)qyEVtF$%e(iXC2=F&!T;gD2kb9u#Rtt{avk!Hrm}1`@8S-ejltTxF2$H z)n~nmjmXYTWD*Je0Dcr2UF6g(#JPz~o!7Zf&Eln9z&gIEeL8|taDQCJ&foHg4_;W> zRj#A2jRm1vlc?&ZGc(HML-}J>e&v{jwT&`c9q`L~J)qua{krk=&Ay&BvfT&&n#@5SEnmK^dJ{y4ci?)qo)zjW{7+!sE zjDN>g5ClOG1VIo4K@bE%5ClOG1VIo4K@bE%_!s#LPO{r^9m+0pDjWCVBd2#f$DxJ`#kc>~HzAxqc^BqaItUW)rQ z`0R&t|4#xGMNt$*QIu>H#Y}Rpf1hK~vC_UOVH+g5?>ocXi@F#Pl=rnezi55pk8oqY4+Msx$C}!fTG6)NM^Zyu;phe)S;;{y< z$$x}rz$IG$qlo+lo&iIL8Oure3tndxGBpCecChP15Qesyo`JJb#5AH9(D$8*-cQ90 zOd~RqnX`af10Os)-yFY`8Sn?$H|>-;#DGjkiTFGoY^gTDJrA+lr~n~V=9?oP-d-mk zeeGfz(N3sL1H^3wecxeUK4E`aPCm{zN0?QE{b>o=RRGBdb_$8?D%kZQq|ay;m{k+^ zi;DllE;Nwl!NN{*f50F^?J*Fy6+wuC$gYy2n29_;%23!@Zf5iwncE7?DmmByrTD6Z tdFj|&(TQG`-1lHbQ4~c{6h%=eyaRG^sFu*zmrnoy002ovPDHLkV1m-H#oYh^ diff --git a/vapor/examples/rpg/assets/source/hero-west.png b/vapor/examples/rpg/assets/source/hero-west.png index 62444e186b77d2a19559ee64f05ae565fad6d981..6e141b40a452464b3e086e451be03b4c84e42fb8 100644 GIT binary patch literal 1443 zcmV;U1zh@xP)`l0>uP6ZRg<=>YhAmuhj>qOlT@@1-c$K~pm6WW zNqWvNKYsV*+zUboA%qY@2qAdsm(v5tL8TqLa-PY}4wL~<{^}Opxo>C!fQk#(tR|D@qM0@*6%F`p4VCW{v*A#LSVKU! zZTfqb-9=uK!D;|*pZt>7&d02mO`DVBRIb0+fwHXQSVKU?8UhMXW1%WF7OGOQhJZ>8 zhgD)Yoc zH_g@GZu5TE2exvNE)h>qUA_(Av0sb{L0;wH|5lKyt2f4y3=Z5U5l>KY;Tn~@ULyYC zc}g4i<$R;B)JNaY1cS44&vd}c;bedTNc=UQzQnTe6^AHo+-D24b-ukIpOU=wOMh-W zVbyC#R~2g<{NFA>rPtD$o&i)Oh^wws|G4lKhln)hAWcCPsUk zSU9tv)rY?Y7K8jWoDQ(lHF!yT51ydgyN#*Ze@WC<;wugT*nVIvC*aSTDFN2mYuVym zoz=-#xQ4`V7|+cSrgyx8uQ>GBAUmDCV9-S_(?oKCc~^lxSCOFJ`*r>Dhf~9PYNlkc zO)8t)X)0g-XPA(^U`__RtQwokx+}eOw_VrgQ;%x$Qsu#v;DR{;24Lp12)^PF?#UTG z-}ecgnWJm7`7|M>Yv($>KWU~^P)MpZq*V_zUtOqmZV`IW9LT~tNJa#E(Nm!p>2rLG+ zuxNN+vNnz@gJoecXQboP#4-K2rm1tHbSQ z&oOqnUZ0;keG*u#!6q{`z(8ulCq1~<1@GaVe!@p;oE+M7K?RzX0+fmb_48+eW~B~) zF|W3kR;x`#i_fo>?Cmh_xim0y6`^e{n8|93yxYc}#Kb?@$a<`GzNb;CNKlb_mN@-2u?(s?s#J4SshP+} zwm?S%eiaS)jjg>Fqib-wi@p$3E!VR)gVJv+nP?@{ES0{K*4f#`I?rNIA5Pz8q^k}L zor{r!%0aEqFJwpEH@3#f{!h6R?*@bCVeG&%s9)c!U~Pe$!2$bI@TT1&%4F=oa#-p@ xwvy%`gb+dqA%qY@2qAYU6vn@biwE`polR5!t?V;{j$L4@EFbnVuR_l_c{lN^V% zm(*$r^t%dreh_k#3n%Bh=U)ItQ4~c{l>bX7mSJNOnXkRCqJ3wRWvJ;e0JuC2YdeAf zafDC|8k302Q=n&;H^(k4?Px+5v9>W|5^=+>>D5)YgScU-9j<0YY0NgNJ zO1E|3323CsPr{7=&`Jn13Tq39*&^FOiGVFi*wJM1<=DX%CBytmXerdY7|#|q+}$vX zUK=z4w|l1mfcJ-rWwI5tNxa00lV{EXY5{&b)FMt}dIyo4TmXDq!0fI?odchHd$&Ig z7XWx);o$m-HidzRHQ;>(mmUMCqtWe8(FGtc0003g`us`UV4g;yE+P<3fH6sM+!iGs zulB=UHsECuHHEydI<_dq{ diff --git a/vapor/examples/rpg/assets/source/path.png b/vapor/examples/rpg/assets/source/path.png index 6349c7edce51c39613e0d50bc4b2ada33b1cbec2..882b1cc3c76d86dd96e7d1cbe32661532d23ec30 100644 GIT binary patch delta 365 zcmV-z0h0b!hy#!#e*sTPL_t(|oOP4SZo)teMGx4d2t-MwR8+9&|MgG$W&H>ihy_%A zNM#es!-g)V8S?7g#PL|y*Pc7Q*O%w3iUMG6lyN7-Z?Q|L&u0U^-p{Bg`D{?D-+f(4 z^Ij#RF%OW>2J`_==IoEhRzq{^6k=}iOqZ*IY?J`7Ubjh#e@5n6-+_u!94`CY7 diff --git a/vapor/examples/rpg/assets/source/slime.png b/vapor/examples/rpg/assets/source/slime.png index 8b2b237a6f2e73ba52d3711a23d6bedad8e65860..f126281a35c168021760e8f14ce9181d706c9735 100644 GIT binary patch literal 1408 zcmV-`1%LX9P))`^eN>Yho`+hYF-_*D1s7yTcEsZ-VfN>`g z{;t&=nei&fG(~h#loI*}wNK!>c-+qr{;t>gpo+0u$0KGxe}Z`*oyy}*3V`cUlLGerng_5kszO=mKrAjy6uCWOCBkE+`J zp}}sS5UaUbOEhq;IyB7!30$|h$^@lgX7dF?jmeUdI#@@Yi&=q(c;ol(8$MM4<$Bp? zL;nEV+vCb@tfR2REd93v4_9s%KB@^(Cm_3hLijr*-@jwAd{f50f5q5Z3{*LY=%RL@ zTPUNE3&_G>xt(8=@VXgR)#^-9YKL_crj=jCnn2MmYnz&Zh6lLz9X4NpaVIr(Z47Nlay+K%q$P4=G`4`yKL9~c(mn~#xC6Lu z`qP-8IVRU20Z1VxC0x0k7DcX`)6n_IEwg2r{4@xP=rTiuMD{c383fXX1qnbqf8}=R zC5H|Qrq4ja+1@_QHm4Kdy15eiwB4RY%ltK0RbZ%o;ED@A6sDmwsBBDQJga+)hQr=eKY2g82n0jCof%vMQjFz!6<8iTVHm(rsFdR?Kcm(dohdC8&#)YBkEf7{#R7>o3N zCc|vI1v4!GLlCSAdiMQVFd?}?o}VkS1dYiB(gGm)xJ+BA> zFwU*?Y3Xc1*jfzx%T2bb(G;Kr%8KA;46b_cGv#`dPiF;9t_VuAq{u!mNk{!okWAO+ z3&o9q%$1(|bCS^lFwECuqkfb~J6Tmojs212gb5QSOqeiX!h{KbG5!aG?>`G~2oszD O0000CwFnD&?yxeJke?Ni^Z>~$xwP%(8C+;mrhpLk*B1%^ zfJN{Wxy}XwxiTb4uGObYKIB6GS=o6hz>5I3ad qJ?oye7g(D{?CjebPDMqWi2<1Rn?$1PjZB1 zHx9>5FX0c8r>;=d;0OTo0%y5H>bK-Kx)&Phs)3XI#(l4L6DJbHJtXAX1^wq(n9+?K zk$uhQGSZ)&JZRjT2?2x?A*S(=@yiF^BX_d@mPN1mTowY%Fvt){g8dDLjNjlniULt@ zUWcBi038wDpyPrsg=TmDZA3uG_}^w80pP4(`i~^&#+wb-Vd)lvNQkA|$HoTFQ6oJ& zMZKl*0C!Fv9M$+J2pTT!BOs)pZrFQ%?S;)w8C~xVp9?uc0mXEg>s1cCW1E_hu+;-L zc#a~YxxW4jnlbuih7ys^59Q~_AWa04Y->pwiR$kPRuc4IU1pZ+$dAYu4ck1RVDJJR zK9|Lf&C&g=UotKQEwGur#9SsO1jGS*2*@l^^%-7=&qasNMQxxsYEq*UQyVC@$RZ6_ zAZlJ9Dh}A@0cK+x$IYXw+B(WA`F(CX zeVv_#OaY$lFQsWh5$}IzENPW%aDQo@5jP@(VbpY3AnYMvlu638!&v}4(_cz$pqMMm z+Uec&876+^K>%FS^XOV7QQNoJ78O1&Z1(_wKmOCtuYR@~(3ZVj)j{rB_avVDBIL4q z<4SLE7$oSPpetnQQUX1~J_2qWmYOCMAxRDZrcRv#z^glcNLu9@qgZD}z)~%}!9i!` zht!7R!PY=A%KjRZ{WWnR!B!7Y+TW5?HOPYx+{5Ap^GVKI%H6p+mNLn?xd6QR>nAiX zQAVQLxw2)mkxoMFq{#j*U@~aZ@;P*QeU@xbei6xeOR3t@Ms-yM%E3oS@@P^2n$HEw z2&s2{+9+??AQ7-2vTr5`kXfQu4V(n#Gk!}s?PV*0uBc*@md}Z<8zQdq!_z#Ms-!nK!g9eoLPAD?^j+3|d$sOquGT%x?|$&O zMXr{uqr^Q9Wk!&+IMDfae7EX_wVYX^?)>N!^Rfb*zU~>d$h@rZoFLb` zgbNuh{N-Qcsq*#3jRavqP{+)58Bpit^mUKL;G6<$xwCD@dGmuLo|;mH&>@e z7tkA>@sK($w;Wnd@z**pIR!9tZZePzK=56!QTBYB#38?f%`=`w%AP_J;LMf?v<21M zF9-t8S!-2ib}6ZaDR}Gm@xt}J%uMZP&Qj~haORjq%a8-V;@}slE6Mn_wn*w}?PcH( zFKKP1WifQVUMLAdJRr3&#YjK8q=9WM)m(cmY{ymCAb!Qc_JX~d;NpHGZ=h{Eo_n>x zIb@QHf~^F&E1FbUw!W~`!W3wMub1C6WLuk)*j}*LAj*3Y-fuob|G7il@%Sb{^8{y3 z8AIpm5eeHTLQQog^tATkt>0(JCdw*~xfwX-W*9QFmpF6E=s$l9&w?65mUiDn6h+0E zS0i*aWE%nFvsW03zW!V(&TQ?9gqZ{#ilS8=`p1c=JZE+h&RJ`Pkzikd$t+P(MvVRo zS|5JOzlSBxAbW;WC>+rC2{-=ubxYNr)?Q>;BGC3C?wkdXdbg3cY|R%qw(}x^#&RLS z(AF5B9DD>AqNi~ON00oSYD#(1{Qv;tW|gxW?QP2^{FW|Y#qqKpr^H0iw&~GrN!RpMn~BNlCqYo!`&Txv%eL95hQJ2FwPLx z1@es}F$0|SOHo9q*PA7I>I(JM2}{GW;0Q#VAr=iW1Slhx6Cc-nu9zP;EBf9h3DxrU z<9R+n2m#|g$!s+pgTW2s-DA0V`7Mi&%kC8DgRf?X&j~hNc8gFcCWH_WX(+(W(F(?b zz?okp;vaP)kTl~VHF&ef`u~AA{$C)01QJLffdu}K@o!a|`O`yyWZD1#002ovPDHLk FV1gg@^!oq+ literal 1762 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 delta 597 zcmV-b0;>I{2kiuqBYy%kNklkR z0zZBy$jePnWRl9ZI0*nilFElsnN*m6>o%K4;muxH5yNpJ27vV3;A+c<5%@YaVH$-@ z*Vv;*j;`p^bK{72GVVnq({(4j+>C_HflUDwlpkIIIBqM}`%Kr!%Z($aHGbVpfeq2H z+Q~Z-<&%dZhJQ>j5=DYdqW}P9@ss5m4RY1)gFKuI^Ko0b?(n0+i5R88FB}Wwh~DhQ z8B)Tyf-6qK2@OT}C9kPw{Nfy9vPvlup@?y7_%5(8km$OkI0>ZZrdJ~*mO6>+ipe4* zL$~HKN(DvZW|N*Ee=d}jJFe%_H4;FcXSzlxD`%95ihq-E<393o1QJaF3w2nb00000NkvXXu0mjfzl;}Y diff --git a/vapor/examples/rpg/assets/source/wall.png b/vapor/examples/rpg/assets/source/wall.png index 0ddbf5b00ce2bca30fcf85e0ca18f08ef4916b50..adae5aea4c8944c030629df480b3faabae05a845 100644 GIT binary patch delta 159 zcmV;Q0AT;a1-b!{BYyyaNkli_JkOIL zqyz2PX+GKki^)BK>wZ^kM?Q7%mOs9OP?Vg`2|_v;#bCZOWf%znU;~E^Ku)z^oy`CM N002ovPDHLkV1kn?LMZ?M delta 684 zcmV;d0#p6E0mB85BYy&lNklF3?ogv)On1d=9cteha3BW-W98`hRZGUq*8ME?33}J92OmwYc zm#0*Fo#AMVrfU_YDxY;zSoID;OAR0yMLk_}xwTTRz?$lM@}7g)B1)9;`G zsG?%!hjiRymWvqCGTDSFWhKzHU6WkyQntASV0pI!s5KC#j@|1rySgP^$Nclv=ksr$ zSotCS;W25vU>!YBse%t5-|_bQDMIWC<~~~WC|sUyNq?dk{qX{)=ijhg+>xv{xZZ$P zOG6hXe}1~=ei7pIJN2c!i%XzBk9T%Lvf9wLUo^mU++$`}xAZ)(VIhfPs=bao8rK?Z zb4ejHS}%@i+cs_6MrI1r5&#&&zz{|w<~EmvcZ+(0V0J^#^Z4-b9rufn&SZ?tB&G71 zUCnDo-hZ57*;Z3v~@j6yyKxV_E{swk0TwiYQoO_qv=;#sEaYl79gPUpQBh S!8b4f0000T22B!3A>L_t(|oTZarO2jY_#(!o~0^%u@h1~}cZ?!k^g0jMv)-#C9 zLTD%MgH4*G?Tda-fyvDGXOg?u$dI$;m@^OyLGfMxD>o>JEiOk3U+*8uy9Py! zV8ux3uWGK90qAsAnBPHMW8-XMs^RvB?RNm>p4WWm^=sR1zk|A2bVOxcS&=~uOE*G{ tgObAhFXZz6oFG9i`Cl1qt+M_L05`8x!Xe*_eUtzI002ovPDHLkV1ni=kT?JU delta 332 zcmV-S0ki(X0_OsdB!4bRL_t(|oQ;#qa)U4oMUVWT8R&vdf3$y*zhy%vU>kQ~eZShh1CX91}77=&>aQV<-#b4j!jgD^~)?;itDn-7y= zUk{wmJQjdl8pI$tpBW~D@nza`Ni}~g8Rx-x;Wpb^kE%c?^?#v~ou?|%0pnjdwDHjP1uOD+v!kSeEV&`-Y2_ML(A`7)`E{yO0SethK@2K_2_ zu%tcJeNzz9o=AJC)h4=+^ZCkP%J|XN{kQdCoC||uFnK|2$r>-!IJ#Of55NZ>d}7do zY(w4nZwA|G+eMZ~e{u($R()sCzJ+ZSz9v%})$@O1pz<|W2G$p_N62s*%0YRq#=pOZ eUTzCk8T maximum) return maximum; + return camera; +} + static void draw_world(const vp_rpg_map *map, s32 player_x, s32 player_y, u8 facing, s32 quest, u8 hud) { u8 x, y, ox, oy, slot; - u16 at; - map_fill(TILE_BLANK); + s32 cam_x, cam_y, world_y, end_y; + map_fill(0); hide_objects(); if (!map || !map->tiles || !map->width || !map->height) return; - ox = map->width < 30 ? (u8)((30 - map->width) >> 1) : 0; - oy = map->height < 20 ? (u8)((20 - map->height) >> 1) : 0; - for (y = 0; y < map->height && (u8)(oy + y) < 20; y++) { - for (x = 0; x < map->width && (u8)(ox + x) < 30; x++) { + cam_x = camera_axis(player_x, map->width, RPG_VIEW_W, RPG_FOCUS_X); + cam_y = camera_axis(player_y, map->height, RPG_VIEW_H, RPG_FOCUS_Y); + ox = map->width < RPG_VIEW_W ? (u8)((RPG_VIEW_W - map->width) >> 1) : 0; + oy = map->height < RPG_VIEW_H ? (u8)((RPG_VIEW_H - map->height) >> 1) : 0; + for (y = (u8)cam_y; y < map->height; y++) { + u8 screen_y = (u8)(oy + y - cam_y); + if (screen_y >= RPG_VIEW_H) break; + for (x = (u8)cam_x; x < map->width; x++) { + u8 screen_x = (u8)(ox + x - cam_x); + u16 at; + if (screen_x >= RPG_VIEW_W) break; at = (u16)y * map->width + x; - map_cell((u8)(ox + x), (u8)(oy + y), - world_tile(map->tiles[at], x, y)); + map_world_cell((u8)(screen_x * 2), (u8)(screen_y * 2), + world_tile(map->tiles[at], x, y)); } } - slot = 1; - for (y = 0; y < map->height && slot < 127; y++) { - for (x = 0; x < map->width && slot < 127; x++) { - u8 ch = map->tiles[(u16)y * map->width + x]; + slot = 0; + end_y = cam_y + RPG_VIEW_H - oy; + if (end_y > map->height) end_y = map->height; + for (world_y = end_y; world_y > cam_y && slot < 127;) { + s16 screen_y; + world_y--; + screen_y = (s16)(oy + world_y - cam_y); + if (player_x >= 0 && player_x < map->width && + player_y >= 0 && player_y < map->height && + player_y == world_y && player_x >= cam_x && + player_x < cam_x + RPG_VIEW_W - ox) { + u16 hero_tile = facing < 4 + ? (u16)(facing * VP_RPG_WORLD_ACTOR_FRAME_TILES) + : 0; + s16 screen_x = (s16)(ox + player_x - cam_x); + show_object_32(slot++, (s16)(screen_x * RPG_WORLD_CELL_PX - 8), + (s16)(screen_y * RPG_WORLD_CELL_PX - 16), + hero_tile, 0); + } + for (x = (u8)cam_x; x < map->width && slot < 127; x++) { + s16 screen_x = (s16)(ox + x - cam_x); + u8 ch; + if (screen_x >= RPG_VIEW_W) break; + ch = map->tiles[(u16)world_y * map->width + x]; if (ch == 'N') { - show_object(slot++, (s16)((ox + x) * 8 - 4), - (s16)((oy + y) * 8 - 8), 16, 1); + s16 actor_x = (s16)(screen_x * RPG_WORLD_CELL_PX - 8); + if (player_y == world_y && player_x + 1 == x) actor_x += 4; + else if (player_y == world_y && player_x - 1 == x) actor_x -= 4; + show_object_32(slot++, actor_x, + (s16)(screen_y * RPG_WORLD_CELL_PX - 16), + VP_RPG_WORLD_ELDER_TILE, 1); } else if (ch == 'S' && quest == 1) { - show_object(slot++, (s16)((ox + x) * 8 - 4), - (s16)((oy + y) * 8 - 8), 20, 2); + s16 actor_x = (s16)(screen_x * RPG_WORLD_CELL_PX - 8); + if (player_y == world_y && player_x + 1 == x) actor_x += 4; + else if (player_y == world_y && player_x - 1 == x) actor_x -= 4; + show_object_32(slot++, actor_x, + (s16)(screen_y * RPG_WORLD_CELL_PX - 16), + VP_RPG_WORLD_SLIME_TILE, 2); } } } - if (player_x >= 0 && player_y >= 0 && player_x < map->width && - player_y < map->height) { - u8 hero_tile = facing < 4 ? (u8)(facing * 4) : 0; - show_object(0, (s16)((ox + player_x) * 8 - 4), - (s16)((oy + player_y) * 8 - 8), hero_tile, 0); - } if (hud) { for (x = 0; x < 30; x++) map_cell(x, 0, TILE_HUD); if (quest == 0) line_text(0, 1, "QUEST: TALK TO THE ELDER"); @@ -255,17 +319,17 @@ static void draw_battle(s32 hero_hp, s32 enemy_hp, s32 cursor) { rpg_bg_dirty = 1; full = hp_tiles(enemy_hp, 18); for (x = 0; x < 10; x++) - map_cell((u8)(18 + x), 4, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); + map_cell((u8)(2 + x), 4, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); full = hp_tiles(hero_hp, 30); for (x = 0; x < 10; x++) - map_cell((u8)(2 + x), 13, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); + map_cell((u8)(18 + x), 13, x < full ? TILE_HP_FULL : TILE_HP_EMPTY); draw_box(2, 14, 27, 19); - show_object_32(0, 32, 56, VP_RPG_BATTLE_HERO_TILE, 0); - show_object_32(1, 176, 40, VP_RPG_BATTLE_SLIME_TILE, 2); - line_text(1, 17, "WILD SLIME"); - line_hp(2, 18, enemy_hp, 18); - line_text(10, 2, "HERO"); - line_hp(11, 2, hero_hp, 30); + show_object_64(0, 8, 40, VP_RPG_BATTLE_HERO_TILE, 0); + show_object_64(1, 168, 16, VP_RPG_BATTLE_SLIME_TILE, 2); + line_text(1, 2, "WILD SLIME"); + line_hp(2, 2, enemy_hp, 18); + line_text(10, 18, "HERO"); + line_hp(11, 18, hero_hp, 30); line_choice(15, 4, "ATTACK", cursor == 0); line_choice(17, 4, "HEAL", cursor == 1); } @@ -303,7 +367,7 @@ void vp_rpg_video_init(void) { RPG_BG_VRAM[i] = vp_rpg_bg_tiles[i]; for (i = 0; i < VP_RPG_OBJ_TILE_COUNT * 16; i++) RPG_OBJ_VRAM[i] = vp_rpg_obj_tiles[i]; - map_fill(TILE_BLANK); + map_fill(0); hide_objects(); REG_BG1CNT = (u16)(2 | (2 << 2) | (9 << 8)); REG_BG1HOFS = 0; @@ -318,12 +382,22 @@ void vp_rpg_render(const vp_rpg_map *map, u8 mode, s32 player_x, s32 battle_cursor) { if (!rpg_ready) return; vp_row_clear(0, VP_GRID_H); - if (mode == 1) + if (mode == 1) { + /* Keep enlarged OBJ heads visible above the dialog, but clip their lower + * pixels at its y=88 edge so arbitrary maps cannot draw actors over UI. */ + REG_WIN0H = (u16)((0 << 8) | 240); + REG_WIN0V = (u16)((88 << 8) | 160); + REG_WININ = 0x0003; /* inside: BG0 text + BG1 box, no OBJ */ + REG_WINOUT = 0x0013; /* outside: BG0 + BG1 + OBJ */ + REG_DISPCNT = 0x3340; /* mode 0, 1D OBJ, BG0/BG1/OBJ + WIN0 */ draw_dialog(map, player_x, player_y, facing, quest, dialog, choice); - else if (mode == 2) + } else if (mode == 2) { + REG_DISPCNT = 0x1340; draw_battle(hero_hp, enemy_hp, battle_cursor); - else + } else { + REG_DISPCNT = 0x1340; draw_world(map, player_x, player_y, facing, quest, 1); + } } void vp_rpg_video_commit(void) { diff --git a/vapor/runtime/gba/vapor_rpg_assets.generated.h b/vapor/runtime/gba/vapor_rpg_assets.generated.h index 87caf68f..9ecdb1c2 100644 --- a/vapor/runtime/gba/vapor_rpg_assets.generated.h +++ b/vapor/runtime/gba/vapor_rpg_assets.generated.h @@ -2,11 +2,18 @@ #ifndef VP_RPG_ASSETS_GENERATED_H #define VP_RPG_ASSETS_GENERATED_H -#define VP_RPG_BG_TILE_COUNT 24 -#define VP_RPG_OBJ_SMALL_FRAME_COUNT 6 -#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_OBJ_SMALL_FRAME_COUNT * 4) -#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 16) -#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 16) +#define VP_RPG_WORLD_TILE_FRAME_COUNT 10 +#define VP_RPG_WORLD_TILE_FRAME_TILES 4 +#define VP_RPG_UI_TILE_COUNT 14 +#define VP_RPG_UI_TILE_BASE (VP_RPG_WORLD_TILE_FRAME_COUNT * VP_RPG_WORLD_TILE_FRAME_TILES) +#define VP_RPG_BG_TILE_COUNT (VP_RPG_UI_TILE_BASE + VP_RPG_UI_TILE_COUNT) +#define VP_RPG_WORLD_ACTOR_FRAME_COUNT 6 +#define VP_RPG_WORLD_ACTOR_FRAME_TILES 16 +#define VP_RPG_WORLD_ELDER_TILE (4 * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_WORLD_SLIME_TILE (5 * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_WORLD_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 64) +#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 64) static const u16 vp_rpg_bg_palette[16] = { 0x0000, 0x24a3, 0x21e4, 0x2f08, 0x1570, 0x2a78, 0x3149, 0x5693, @@ -14,35 +21,95 @@ static const u16 vp_rpg_bg_palette[16] = { }; static const u16 vp_rpg_obj_palettes[48] = { - 0x0000, 0x1063, 0x32bf, 0x7e08, 0x25ff, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x1063, 0x32bf, 0x4a52, 0x7fff, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x1063, 0x56a0, 0x2d60, 0x7fff, 0x0000, 0x0000, 0x0000, + 0x0000, 0x24a3, 0x21b8, 0x32bf, 0x5544, 0x7e08, 0x7ed0, 0x1916, + 0x25ff, 0x4b7d, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x24a3, 0x21b8, 0x32bf, 0x3149, 0x45ee, 0x5693, 0x6f58, + 0x7fff, 0x7e08, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x24a3, 0x2d60, 0x56a0, 0x6347, 0x6fd2, 0x7fff, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, }; -static const u16 vp_rpg_bg_tiles[384] = { +static const u16 vp_rpg_bg_tiles[864] = { + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, - 0x3333, 0x3333, 0x3333, 0x3333, 0x3323, 0x3333, 0x3333, 0x3333, - 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3233, 0x3233, 0x3333, - 0x3333, 0x3333, 0x3333, 0x3323, 0x3333, 0x3333, 0x3333, 0x3333, - 0x3333, 0x3333, 0x3233, 0x3333, 0x3333, 0x3333, 0x3333, 0x3233, - 0x5555, 0x5555, 0x5545, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, - 0x5555, 0x5555, 0x5555, 0x5455, 0x5555, 0x5555, 0x4555, 0x5555, - 0x5555, 0x5555, 0x5555, 0x5455, 0x5555, 0x5555, 0x5555, 0x5555, - 0x5455, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5545, - 0x677e, 0x7777, 0x6777, 0x7777, 0x6777, 0x7777, 0x6666, 0x6666, - 0x7777, 0x767e, 0x7777, 0x7677, 0x7777, 0x7677, 0x6666, 0x6666, - 0x9999, 0x9999, 0x9999, 0x9999, 0x9888, 0x9999, 0x9999, 0x9999, - 0x9999, 0x9999, 0x9999, 0x8889, 0x9999, 0x9999, 0x9999, 0x9999, - 0x9999, 0x9999, 0x9999, 0x9999, 0x9999, 0x8889, 0x9999, 0x9999, - 0x9999, 0x9999, 0x9888, 0x9999, 0x9999, 0x9999, 0x9999, 0x9999, - 0xaabb, 0xbbbb, 0xabab, 0xbbb5, 0xaaa2, 0xbbba, 0xaabb, 0xb25a, - 0xaaaa, 0xbaaa, 0x4bbb, 0xbbb5, 0x4333, 0x3334, 0x4333, 0x3334, - 0xbbbb, 0xbbbb, 0xbbbb, 0xbbbb, 0xbbbb, 0xbbbb, 0xcbbb, 0xbbbb, - 0x1bbb, 0xbbbb, 0x23bb, 0xbbbb, 0x2bbb, 0xbbbb, 0xabbb, 0xbbbb, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3233, 0x3333, + 0xa333, 0x3333, 0x3333, 0xa333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x2333, 0x3333, 0x3333, 0x333a, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3a33, 0x3333, 0xaaa3, + 0x3333, 0x3323, 0x3333, 0x3a33, 0x3333, 0x3333, 0x3333, 0x3333, + 0xa333, 0x3333, 0xa333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x33b3, 0x3a3b, 0x3333, 0x3333, 0x323b, 0x3333, 0xb333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3332, 0x3333, 0x33a3, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3332, 0xa333, 0x3333, + 0x3333, 0x3333, 0x333a, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x333a, 0x3333, 0x333a, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3a33, 0xb3a3, 0xb323, 0x3333, 0x333b, 0x3333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x33a3, 0x3333, 0x3a2a, 0x3333, + 0x3333, 0x333b, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x5555, 0x5555, 0x5555, 0x5555, 0x4555, 0x5555, 0x4554, 0x4444, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5554, 0x5554, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x554e, 0x5554, + 0x4455, 0x5554, 0x5555, 0x5555, 0x5555, 0x5554, 0x5555, 0x4555, + 0x5555, 0x5555, 0x5e55, 0x5555, 0x5555, 0x5555, 0x4555, 0x5455, + 0x5555, 0x5555, 0x55ee, 0xe555, 0x555e, 0xe554, 0x5545, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5544, 0x5555, + 0x5555, 0x55e5, 0x55ee, 0xe555, 0x55ee, 0x5455, 0x5555, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x555e, 0x4555, 0x4444, + 0x4555, 0x5544, 0x5555, 0x5545, 0x5555, 0x5555, 0x5554, 0x5555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5554, 0x5444, 0x4555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x4555, 0x4555, + 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5455, 0x4455, + 0x5555, 0x5555, 0x555e, 0xee55, 0x5555, 0xee55, 0x5555, 0x5555, + 0xe555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5555, 0x5554, + 0x5555, 0x5555, 0x555e, 0xee55, 0x445e, 0xe555, 0x5555, 0x5455, + 0x6666, 0x6666, 0x77e6, 0x6667, 0x7766, 0x6667, 0x7777, 0x6777, + 0x7777, 0x6777, 0x6666, 0x6666, 0x6777, 0x7777, 0x6777, 0x7777, + 0x7666, 0x6666, 0x7666, 0x6677, 0x7776, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x6666, 0x6666, 0x7777, 0x7777, 0x77e7, 0x7777, + 0x6777, 0x7777, 0x6777, 0x7777, 0x6777, 0x7777, 0x6666, 0x6666, + 0x7777, 0x7777, 0xee77, 0x7777, 0xee77, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x77ee, 0x6666, 0x6666, + 0x7777, 0x7776, 0xe777, 0x7ee6, 0xe777, 0x76e6, 0x7777, 0x7776, + 0x8899, 0x9988, 0x9999, 0x9999, 0x9999, 0x9999, 0x8889, 0x9888, + 0x9899, 0x9999, 0x9999, 0x9999, 0x9998, 0x9889, 0x9999, 0x9999, + 0x9999, 0x9899, 0x9999, 0x9999, 0x9989, 0x999d, 0x9989, 0x9999, + 0x8999, 0x9899, 0x9999, 0x9999, 0x9989, 0x9999, 0x9999, 0x9999, + 0x8899, 0x9999, 0x9999, 0x9999, 0x8889, 0x9888, 0x9999, 0x9889, + 0x9899, 0x999d, 0x9999, 0x9999, 0xdd99, 0x988d, 0x9999, 0x9999, + 0x8899, 0x9888, 0x9999, 0x9999, 0x9999, 0x9999, 0x9989, 0x9999, + 0x8999, 0x9899, 0x9999, 0x99dd, 0xd999, 0x99dd, 0x8899, 0x9888, + 0x8989, 0x9888, 0x9999, 0x9999, 0x9999, 0x9899, 0x9999, 0x9899, + 0xd989, 0x9998, 0x9999, 0x9999, 0x8889, 0x9888, 0x9999, 0x9999, + 0x9999, 0x9999, 0x9999, 0x9999, 0x8899, 0x9888, 0x9999, 0x9999, + 0x9999, 0x9989, 0x9999, 0x9999, 0x9889, 0x8999, 0x9999, 0x9999, + 0x8989, 0x9998, 0x9999, 0x9999, 0x9999, 0x9999, 0x9999, 0x9899, + 0x9989, 0x9998, 0x8889, 0x9888, 0xdd99, 0x999d, 0x9999, 0x9999, + 0x9999, 0x9988, 0x9999, 0x9999, 0x9989, 0x9999, 0x8889, 0x988d, + 0x9999, 0x9989, 0x9999, 0x99dd, 0xd889, 0x99dd, 0x9999, 0x9999, + 0x3333, 0x3333, 0x3333, 0xa333, 0x3333, 0xaaa3, 0x3333, 0xabaa, + 0xa333, 0xaaba, 0xb333, 0xbaaa, 0xa333, 0xaaba, 0xba33, 0xbaaa, + 0x33ab, 0x3333, 0x3baa, 0x3333, 0xaaab, 0x3333, 0xabaa, 0x333a, + 0xaaab, 0x33ab, 0xabaa, 0x3baa, 0xaaba, 0x3aab, 0xbaaa, 0x3baa, + 0xaba3, 0xaaba, 0xa333, 0xbaab, 0x3333, 0xab33, 0x3333, 0xaaa3, + 0x3333, 0x5333, 0x3333, 0x4433, 0x3333, 0x44a3, 0x3333, 0x44ba, + 0xaaba, 0x3aba, 0xbaaa, 0x33aa, 0x3aba, 0x3333, 0x3aab, 0x3333, + 0x3355, 0x33b3, 0x3a44, 0x3333, 0xab44, 0x333b, 0xaa44, 0xb33b, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x3333, 0xe333, 0x3333, 0xc333, 0x3333, 0xc333, + 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, 0x3333, + 0x3333, 0x3333, 0x333c, 0x3333, 0x333c, 0x3333, 0x33ce, 0x3333, + 0x3333, 0x33ca, 0x3333, 0x3bbb, 0x3333, 0x3bbb, 0x3333, 0x3b33, + 0x3333, 0x3b3b, 0x3333, 0xbbbb, 0xb333, 0xbbbb, 0x333b, 0xbbbb, + 0x3333, 0x3333, 0x3333, 0x3333, 0x33b3, 0x3333, 0x3bab, 0x3333, + 0x3b3b, 0x333b, 0xbbbb, 0x3333, 0xbbbb, 0x3333, 0xbbbb, 0x3333, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xeeee, 0xeeee, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, 0xdddd, @@ -73,118 +140,454 @@ static const u16 vp_rpg_bg_tiles[384] = { 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0xcccc, 0xcccc, }; -static const u16 vp_rpg_obj_tiles[896] = { - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x2210, - 0x0000, 0x2221, 0x1000, 0x2224, 0x1000, 0x2224, 0x1000, 0x2421, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0124, 0x0000, - 0x1222, 0x0000, 0x2222, 0x0001, 0x2422, 0x0001, 0x2424, 0x0000, - 0x1000, 0x4212, 0x0000, 0x3244, 0x0000, 0x2241, 0x0000, 0x3311, - 0x0000, 0x3413, 0x0000, 0x2121, 0x0000, 0x1213, 0x0000, 0x0011, - 0x4222, 0x0000, 0x1222, 0x0000, 0x0122, 0x0000, 0x1312, 0x0000, - 0x2432, 0x0000, 0x0012, 0x0000, 0x0011, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x4410, 0x0000, 0x2241, - 0x0000, 0x2224, 0x0000, 0x2224, 0x1000, 0x2222, 0x1000, 0x2222, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0142, 0x0000, 0x1422, 0x0000, - 0x2222, 0x0001, 0x2222, 0x0001, 0x2222, 0x0001, 0x2222, 0x0001, - 0x0000, 0x2224, 0x0000, 0x4410, 0x0000, 0x1111, 0x1000, 0x3332, - 0x1000, 0x3321, 0x0000, 0x3331, 0x0000, 0x1310, 0x0000, 0x0110, - 0x4222, 0x0000, 0x0142, 0x0000, 0x1111, 0x0000, 0x3333, 0x0001, - 0x1233, 0x0000, 0x0333, 0x0000, 0x1311, 0x0000, 0x1100, 0x0000, - 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2224, 0x1000, 0x2242, - 0x1000, 0x2224, 0x1000, 0x2224, 0x0000, 0x4221, 0x0000, 0x2223, - 0x0000, 0x0000, 0x0001, 0x0000, 0x0144, 0x0000, 0x1222, 0x0000, - 0x2222, 0x0000, 0x2222, 0x0001, 0x2222, 0x0001, 0x2224, 0x0001, - 0x0000, 0x2222, 0x0000, 0x4210, 0x0000, 0x1200, 0x0000, 0x1210, - 0x0000, 0x1210, 0x0000, 0x1400, 0x0000, 0x1400, 0x0000, 0x0000, - 0x2224, 0x0001, 0x1442, 0x0000, 0x0111, 0x0000, 0x3312, 0x0000, - 0x3322, 0x0000, 0x1112, 0x0000, 0x0131, 0x0000, 0x0111, 0x0000, - 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x4100, 0x0000, 0x2410, - 0x0000, 0x2222, 0x0000, 0x2222, 0x0000, 0x2222, 0x0000, 0x2222, - 0x0000, 0x0000, 0x0000, 0x0000, 0x1124, 0x0000, 0x2222, 0x0000, - 0x4222, 0x0001, 0x2222, 0x0000, 0x4422, 0x0000, 0x1222, 0x0000, - 0x0000, 0x2222, 0x0000, 0x2410, 0x0000, 0x3311, 0x1000, 0x3333, - 0x0000, 0x3310, 0x0000, 0x4430, 0x0000, 0x1011, 0x0000, 0x1000, - 0x1222, 0x0000, 0x0142, 0x0000, 0x0113, 0x0000, 0x1121, 0x0000, - 0x0113, 0x0000, 0x0044, 0x0000, 0x0013, 0x0000, 0x0011, 0x0000, - 0x0000, 0x0000, 0x0000, 0x3200, 0x0000, 0x2222, 0x0000, 0x2244, - 0x3000, 0x3422, 0x3000, 0x4242, 0x1000, 0x3341, 0x3000, 0x4423, - 0x0000, 0x0000, 0x0002, 0x0000, 0x0322, 0x0000, 0x0442, 0x0000, - 0x3222, 0x0000, 0x1114, 0x0000, 0x0214, 0x0000, 0x0124, 0x0000, - 0x1000, 0x2132, 0x0000, 0x1431, 0x1000, 0x4112, 0x1000, 0x4414, - 0x0000, 0x1441, 0x0000, 0x2143, 0x0000, 0x1211, 0x0000, 0x1100, - 0x0113, 0x0000, 0x0121, 0x0000, 0x1444, 0x0000, 0x0442, 0x0000, - 0x0112, 0x0000, 0x0321, 0x0000, 0x0011, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1200, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, - 0x0000, 0x2221, 0x2000, 0x2222, 0x0000, 0x2422, 0x0000, 0x3331, - 0x0000, 0x3330, 0x0000, 0x3313, 0x0000, 0x3331, 0x0000, 0x0330, - 0x0012, 0x0000, 0x0122, 0x0000, 0x0124, 0x0000, 0x3113, 0x0000, - 0x1333, 0x0000, 0x0133, 0x0000, 0x0133, 0x0000, 0x0031, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x1100, - 0x0000, 0x1100, 0x1000, 0x4411, 0x4100, 0x2444, 0x2210, 0x2222, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1111, 0x0001, - 0x1111, 0x0001, 0x2444, 0x0114, 0x2222, 0x1222, 0x2222, 0x2244, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0012, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, - 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, - 0x2220, 0x2222, 0x2220, 0x2222, 0x2221, 0x2222, 0x2221, 0x2222, - 0x2221, 0x2222, 0x2221, 0x2222, 0x2221, 0x2222, 0x2221, 0x2222, - 0x2222, 0x4422, 0x2222, 0x4422, 0x2222, 0x4422, 0x2222, 0x1222, - 0x2222, 0x1442, 0x1222, 0x0142, 0x1222, 0x0142, 0x2442, 0x0122, - 0x0014, 0x0000, 0x0014, 0x0000, 0x0004, 0x0000, 0x0001, 0x0000, +static const u16 vp_rpg_obj_tiles[3584] = { + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, + 0x1100, 0x7393, 0x7700, 0x2173, 0x7310, 0x1117, 0x2921, 0x8722, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, + 0x8281, 0x0017, 0x2287, 0x0027, 0x1117, 0x0172, 0x2777, 0x1781, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x0000, 0x1000, 0x0000, 0x2000, 0x0000, 0x8000, 0x0000, 0x7000, + 0x0000, 0x7000, 0x0000, 0x7000, 0x0000, 0x7000, 0x0000, 0x1000, + 0x2822, 0x2822, 0x9122, 0x2278, 0x7218, 0x7171, 0x7712, 0x7318, + 0x1177, 0x1991, 0x8111, 0x1977, 0x9119, 0x8111, 0x9392, 0x9455, + 0x2772, 0x2772, 0x2722, 0x7219, 0x1717, 0x7127, 0x8137, 0x7177, + 0x1991, 0x7711, 0x7791, 0x1117, 0x1118, 0x9119, 0x5549, 0x2939, + 0x0001, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, + 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x6100, + 0x9391, 0x9941, 0x8110, 0x9999, 0x1400, 0x9389, 0x1410, 0x1717, + 0x6644, 0x7777, 0x6644, 0x7771, 0x6161, 0x7111, 0x4199, 0x3166, + 0x4499, 0x1939, 0x9999, 0x0118, 0x3239, 0x0041, 0x7177, 0x0165, + 0x7777, 0x6666, 0x1777, 0x6666, 0x1117, 0x1616, 0x6413, 0x9914, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0016, 0x0000, + 0x0000, 0x3100, 0x0000, 0x3000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1299, 0x3164, 0x1181, 0x3144, 0x7711, 0x3156, 0x7510, 0x3114, + 0x4410, 0x6666, 0x6400, 0x1166, 0x1100, 0x0111, 0x1000, 0x0011, + 0x4413, 0x9921, 0x4513, 0x1814, 0x6413, 0x1173, 0x1113, 0x1773, + 0x6666, 0x1465, 0x6441, 0x0146, 0x4110, 0x0014, 0x1000, 0x0011, + 0x0013, 0x0000, 0x0003, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x1100, 0x8111, + 0x8800, 0x2299, 0x9810, 0x2299, 0x2291, 0x7222, 0x2292, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1112, 0x0001, + 0x8992, 0x0012, 0x9222, 0x0013, 0x2227, 0x0192, 0x2222, 0x1292, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7000, 0x0000, 0x2100, 0x0000, 0x2100, 0x0000, 0x2100, + 0x0000, 0x8100, 0x0000, 0x7100, 0x0000, 0x7100, 0x0000, 0x7000, + 0x2298, 0x2222, 0x2282, 0x2222, 0x2922, 0x2222, 0x9222, 0x2222, + 0x2228, 0x8822, 0x2272, 0x8222, 0x2777, 0x2277, 0x7777, 0x7777, + 0x2222, 0x2222, 0x2222, 0x2292, 0x2222, 0x7729, 0x9222, 0x7722, + 0x2228, 0x7777, 0x2222, 0x7777, 0x7727, 0x7777, 0x7777, 0x7777, + 0x0001, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, + 0x0007, 0x0000, 0x0007, 0x0000, 0x0007, 0x0000, 0x0001, 0x0000, + 0x0000, 0x7000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1100, 0x0000, 0x1100, + 0x7777, 0x7777, 0x7771, 0x7777, 0x7710, 0x7777, 0x1100, 0x7771, + 0x4461, 0x4441, 0x4466, 0x1114, 0x4466, 0x4444, 0x4445, 0x4444, + 0x7777, 0x7777, 0x7777, 0x0177, 0x7777, 0x0077, 0x1777, 0x0011, + 0x1444, 0x0164, 0x4411, 0x1664, 0x4444, 0x1644, 0x4444, 0x1444, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x3100, 0x0000, 0x1000, - 0x2221, 0x2222, 0x2210, 0x2222, 0x4100, 0x2244, 0x4100, 0x2244, - 0x3110, 0x1133, 0x3111, 0x3333, 0x3333, 0x3333, 0x1331, 0x3111, - 0x2442, 0x0122, 0x4222, 0x0122, 0x4442, 0x0012, 0x4442, 0x0012, - 0x2111, 0x0012, 0x1333, 0x0012, 0x2441, 0x0111, 0x3331, 0x0122, + 0x0000, 0x0000, 0x0001, 0x0000, 0x0016, 0x0000, 0x0016, 0x0000, + 0x0000, 0x4100, 0x0000, 0x4000, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4411, 0x4444, 0x4444, 0x4444, 0x4111, 0x4444, 0x4110, 0x4444, + 0x6610, 0x4446, 0x6610, 0x4446, 0x6400, 0x1116, 0x1100, 0x0001, + 0x4444, 0x1144, 0x4444, 0x4441, 0x4444, 0x1114, 0x4444, 0x0111, + 0x6444, 0x0166, 0x6444, 0x0166, 0x6111, 0x0016, 0x1100, 0x0011, + 0x0016, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x1331, 0x3111, 0x4100, 0x3333, 0x3300, 0x4444, 0x3310, 0x3311, - 0x1110, 0x1100, 0x1110, 0x1100, 0x0000, 0x1000, 0x0000, 0x1000, - 0x3331, 0x0122, 0x1113, 0x0011, 0x4334, 0x0004, 0x1333, 0x0000, - 0x1333, 0x0000, 0x1333, 0x0000, 0x1333, 0x0000, 0x1111, 0x0000, + 0x0000, 0x1000, 0x0000, 0x8100, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1101, 0x7700, 0x2137, + 0x1111, 0x1212, 0x2299, 0x1172, 0x2228, 0x2222, 0x7228, 0x2229, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x9822, 0x0012, + 0x2212, 0x0199, 0x2271, 0x1722, 0x1171, 0x2821, 0x2872, 0x2222, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0002, 0x0000, + 0x0000, 0x2100, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2172, 0x2222, 0x1371, 0x9212, 0x3371, 0x2211, 0x9911, 0x7211, + 0x1910, 0x1184, 0x1910, 0x1996, 0x1991, 0x3996, 0x9991, 0x3999, + 0x2222, 0x2222, 0x2829, 0x2222, 0x8222, 0x7222, 0x8222, 0x7722, + 0x7777, 0x7777, 0x7111, 0x7777, 0x7989, 0x7777, 0x7989, 0x7777, + 0x0002, 0x0000, 0x0017, 0x0000, 0x0017, 0x0000, 0x0017, 0x0000, + 0x0017, 0x0000, 0x0017, 0x0000, 0x0007, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x7399, 0x1181, 0x7771, 0x2281, 0x1777, 0x7711, 0x6177, + 0x1100, 0x1661, 0x0000, 0x6111, 0x0000, 0x4661, 0x1000, 0x1461, + 0x7199, 0x1777, 0x7111, 0x0017, 0x4556, 0x0011, 0x1116, 0x0014, + 0x4666, 0x0041, 0x4666, 0x0041, 0x4666, 0x0041, 0x4646, 0x0041, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x1141, 0x0000, 0x9311, 0x0000, 0x8141, 0x0000, 0x1110, + 0x0000, 0x6660, 0x0000, 0x6610, 0x0000, 0x1100, 0x0000, 0x1000, + 0x4116, 0x0041, 0x1449, 0x0044, 0x1133, 0x0014, 0x1133, 0x0001, + 0x4777, 0x0001, 0x4444, 0x0001, 0x1444, 0x0000, 0x0111, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x2100, 0x7289, + 0x9910, 0x1282, 0x2221, 0x1222, 0x1288, 0x1711, 0x2222, 0x2782, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1011, 0x0011, 0x7312, 0x0077, + 0x1211, 0x1117, 0x2111, 0x9922, 0x2222, 0x8822, 0x9222, 0x8821, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x1111, 0x0000, 0x2221, 0x0000, 0x2210, - 0x0000, 0x0011, 0x1000, 0x1122, 0x2110, 0x2222, 0x2110, 0x2222, - 0x2221, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, - 0x0000, 0x0000, 0x0111, 0x0011, 0x1222, 0x0012, 0x1222, 0x0012, - 0x2222, 0x0001, 0x2222, 0x0012, 0x2222, 0x0122, 0x2222, 0x0122, + 0x0001, 0x0000, 0x0018, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0000, 0x2000, 0x0000, 0x2100, 0x0000, 0x7100, 0x0000, 0x7100, + 0x0000, 0x7100, 0x0000, 0x7100, 0x0000, 0x7000, 0x0000, 0x1000, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2277, 0x2228, + 0x7777, 0x7777, 0x7777, 0x1117, 0x7777, 0x9897, 0x7777, 0x9897, + 0x2222, 0x8712, 0x7199, 0x1731, 0x1122, 0x1733, 0x1122, 0x0199, + 0x1817, 0x0191, 0x6991, 0x0191, 0x6993, 0x1991, 0x9993, 0x1999, + 0x0012, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x2210, 0x0000, 0x1100, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x4222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, - 0x3331, 0x3333, 0x3310, 0x3333, 0x3310, 0x3333, 0x3310, 0x3333, - 0x4222, 0x0122, 0x2222, 0x0122, 0x2222, 0x1012, 0x2222, 0x2112, - 0x3333, 0x3101, 0x3333, 0x3313, 0x3333, 0x3333, 0x3333, 0x3333, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, 0x0122, 0x0000, - 0x0133, 0x0000, 0x0013, 0x0000, 0x0013, 0x0000, 0x0013, 0x0000, - 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x0000, + 0x7771, 0x9917, 0x7100, 0x1117, 0x1100, 0x6654, 0x4100, 0x6111, + 0x1400, 0x6664, 0x1400, 0x6664, 0x1400, 0x1664, 0x1400, 0x6664, + 0x9987, 0x0199, 0x1777, 0x1811, 0x2771, 0x1882, 0x7716, 0x1187, + 0x1661, 0x0011, 0x9116, 0x0013, 0x1664, 0x0033, 0x1641, 0x0013, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x3331, 0x3333, 0x3133, 0x3333, 0x3133, 0x1133, 0x3331, 0x3333, - 0x3331, 0x1333, 0x3331, 0x1333, 0x3310, 0x0133, 0x1100, 0x0011, - 0x3333, 0x1333, 0x3333, 0x0133, 0x3113, 0x0013, 0x3333, 0x0133, - 0x3331, 0x0133, 0x3331, 0x0133, 0x3110, 0x0013, 0x0000, 0x0000, - 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1400, 0x6614, 0x4400, 0x9341, 0x4100, 0x3331, 0x1000, 0x3311, + 0x1000, 0x7774, 0x1000, 0x4444, 0x0000, 0x4441, 0x0000, 0x1110, + 0x1511, 0x0001, 0x1419, 0x0000, 0x1441, 0x0000, 0x1112, 0x0000, + 0x0669, 0x0000, 0x0166, 0x0000, 0x0011, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7761, 0x7100, 0x8788, 0x7710, 0x8868, 0x5781, 0x4888, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0116, 0x0000, 0x6778, 0x0001, 0x5678, 0x0017, 0x8888, 0x0167, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x5486, 0x8674, 0x2346, 0x4445, 0x3325, 0x3333, 0x3281, 0x3333, + 0x3815, 0x8763, 0x3141, 0x6443, 0x4230, 0x7123, 0x2820, 0x3324, + 0x8768, 0x0578, 0x8888, 0x6664, 0x7542, 0x1568, 0x6642, 0x8875, + 0x5552, 0x7566, 0x3524, 0x4663, 0x2423, 0x5553, 0x3263, 0x5551, + 0x0000, 0x0000, 0x0006, 0x0000, 0x0000, 0x0000, 0x0061, 0x0000, + 0x0017, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x4100, + 0x0000, 0x5100, 0x0000, 0x1300, 0x0000, 0x4300, 0x0000, 0x2100, + 0x6670, 0x2288, 0x6811, 0x4846, 0x7116, 0x5568, 0x1154, 0x4415, + 0x4654, 0x6155, 0x5414, 0x6655, 0x4444, 0x6554, 0x5511, 0x1155, + 0x1172, 0x0155, 0x4467, 0x0001, 0x6641, 0x0011, 0x1615, 0x0165, + 0x5116, 0x0566, 0x4146, 0x1566, 0x1145, 0x4551, 0x1141, 0x4555, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1151, 0x5551, 0x5461, 0x5551, 0x5410, 0x6665, 0x1100, 0x6614, + 0x1440, 0x1111, 0x1110, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2144, 0x1222, 0x4155, 0x1333, 0x4556, 0x0122, 0x4566, 0x0001, + 0x4411, 0x0000, 0x1141, 0x0000, 0x1141, 0x0000, 0x0441, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, + 0x1000, 0x3334, 0x3100, 0x4433, 0x3310, 0x4443, 0x3330, 0x4443, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, + 0x3444, 0x0001, 0x5444, 0x0034, 0x5454, 0x0344, 0x4444, 0x1444, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x3000, 0x0000, 0x1210, 0x0000, 0x3330, + 0x3331, 0x4443, 0x3331, 0x1143, 0x6331, 0x1143, 0x3331, 0x1433, + 0x3331, 0x4433, 0x3311, 0x3333, 0x2133, 0x2211, 0x1123, 0x2133, + 0x4444, 0x5114, 0x4444, 0x4114, 0x4441, 0x4446, 0x1144, 0x3441, + 0x1344, 0x1344, 0x3333, 0x1333, 0x2222, 0x3111, 0x1223, 0x3133, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0014, 0x0000, 0x0031, 0x0000, 0x0011, 0x0000, 0x1113, 0x0000, + 0x0000, 0x2320, 0x0000, 0x1110, 0x0000, 0x1130, 0x2100, 0x1122, + 0x1000, 0x1211, 0x0000, 0x2100, 0x0000, 0x3000, 0x0000, 0x0000, + 0x2112, 0x1133, 0x3211, 0x1123, 0x3321, 0x1122, 0x3321, 0x2112, + 0x1111, 0x2111, 0x1112, 0x1232, 0x1333, 0x1111, 0x0000, 0x3200, + 0x1133, 0x2133, 0x1132, 0x1333, 0x1133, 0x2332, 0x1233, 0x2321, + 0x1233, 0x1111, 0x3111, 0x1113, 0x2211, 0x3321, 0x0123, 0x1100, + 0x1333, 0x0000, 0x1332, 0x0000, 0x1111, 0x0001, 0x3111, 0x0032, + 0x1122, 0x0011, 0x0002, 0x0000, 0x0013, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x1100, 0x1000, 0x2211, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1110, 0x1111, 0x7771, 0x8888, 0x7771, 0x8888, 0x8999, 0x2722, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x1000, 0x2228, 0x1111, 0x2228, 0x1111, 0x1122, 0x7223, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0111, 0x0000, 0x1888, 0x0011, 0x1888, 0x0011, 0x1777, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x2110, 0x0000, 0x2110, + 0x2100, 0x9992, 0x2100, 0x9992, 0x8211, 0x2228, 0x2877, 0x2222, + 0x2877, 0x2222, 0x2888, 0x1122, 0x2222, 0x2222, 0x2222, 0x2222, + 0x8222, 0x2122, 0x8222, 0x2122, 0x2112, 0x2122, 0x1222, 0x1122, + 0x1222, 0x1122, 0x1111, 0x2177, 0x8882, 0x2277, 0x8882, 0x2277, + 0x1112, 0x1112, 0x1112, 0x1112, 0x1112, 0x2221, 0x8811, 0x2888, + 0x8811, 0x2888, 0x2222, 0x2222, 0x2222, 0x9222, 0x2222, 0x9222, + 0x1177, 0x1111, 0x1177, 0x1111, 0x9222, 0x9999, 0x2222, 0x2282, + 0x2222, 0x2282, 0x2222, 0x8882, 0x2211, 0x8882, 0x2211, 0x8882, + 0x0001, 0x0000, 0x0001, 0x0000, 0x1118, 0x0000, 0x0117, 0x0000, + 0x0117, 0x0000, 0x0001, 0x0000, 0x0111, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x2110, 0x0000, 0x2771, 0x0000, 0x2771, 0x0000, 0x2771, + 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7771, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, 0x2222, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2777, 0x2222, 0x7777, 0x2227, + 0x2222, 0x2222, 0x8222, 0x2999, 0x8222, 0x2999, 0x2882, 0x9222, + 0x2222, 0x2222, 0x2222, 0x2222, 0x2228, 0x2222, 0x8227, 0x7888, + 0x2222, 0x2992, 0x9922, 0x2119, 0x9922, 0x2119, 0x9999, 0x7111, + 0x2222, 0x1331, 0x2222, 0x1331, 0x2222, 0x1991, 0x7717, 0x7771, + 0x7122, 0x8877, 0x1122, 0x1171, 0x1122, 0x1171, 0x1311, 0x1171, + 0x1333, 0x1171, 0x1333, 0x1171, 0x1999, 0x0011, 0x9777, 0x0019, + 0x1112, 0x0000, 0x1112, 0x0000, 0x1112, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7771, 0x0000, 0x7110, + 0x0000, 0x7110, 0x0000, 0x7110, 0x0000, 0x1000, 0x0000, 0x1000, + 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, 0x7777, + 0x7777, 0x7777, 0x7777, 0x7777, 0x1777, 0x7111, 0x9117, 0x7933, + 0x9117, 0x7933, 0x9117, 0x1988, 0x9117, 0x1988, 0x9117, 0x1988, + 0x1177, 0x1118, 0x1177, 0x1118, 0x9917, 0x6449, 0x9917, 0x6449, + 0x9917, 0x6449, 0x9931, 0x6449, 0x9931, 0x9999, 0x9931, 0x9999, + 0x9911, 0x0019, 0x9911, 0x0019, 0x9911, 0x0019, 0x9911, 0x1199, + 0x9911, 0x1199, 0x9911, 0x1199, 0x9999, 0x1199, 0x9999, 0x1199, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7711, 0x7777, 0x7100, 0x7777, 0x7100, 0x7777, 0x1000, 0x7711, + 0x0000, 0x1110, 0x0000, 0x1110, 0x0000, 0x4410, 0x0000, 0x4410, + 0x1777, 0x1999, 0x7777, 0x7111, 0x7777, 0x7111, 0x1117, 0x7111, + 0x5444, 0x1666, 0x5444, 0x1666, 0x1111, 0x6611, 0x4441, 0x1144, + 0x8871, 0x9999, 0x7777, 0x7333, 0x7777, 0x7333, 0x7777, 0x1117, + 0x7711, 0x2777, 0x7711, 0x2777, 0x1166, 0x7777, 0x1161, 0x7771, + 0x9999, 0x0019, 0x2199, 0x0012, 0x2199, 0x0012, 0x8111, 0x1188, + 0x8822, 0x1188, 0x8822, 0x1188, 0x8877, 0x1118, 0x1277, 0x0011, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x4410, 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, + 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, + 0x4441, 0x1144, 0x6444, 0x6666, 0x6444, 0x6666, 0x6444, 0x6666, + 0x6664, 0x4166, 0x6664, 0x1166, 0x6444, 0x1666, 0x6444, 0x1666, + 0x1161, 0x7771, 0x6616, 0x1116, 0x1166, 0x9661, 0x1166, 0x9661, + 0x6664, 0x1116, 0x6641, 0x1666, 0x4411, 0x1666, 0x4411, 0x1666, + 0x1277, 0x0011, 0x0111, 0x0000, 0x0133, 0x0000, 0x0133, 0x0000, + 0x1933, 0x0001, 0x1333, 0x0001, 0x0133, 0x0000, 0x0133, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x1141, 0x1000, 0x4441, + 0x0000, 0x4410, 0x0000, 0x4410, 0x0000, 0x1100, 0x0000, 0x1100, + 0x1444, 0x3666, 0x5111, 0x9955, 0x5111, 0x9955, 0x4441, 0x9933, + 0x3111, 0x3333, 0x3111, 0x3333, 0x1771, 0x1333, 0x7774, 0x2111, + 0x1113, 0x1665, 0x1199, 0x1554, 0x1199, 0x1554, 0x1199, 0x1554, + 0x4413, 0x1114, 0x4413, 0x1114, 0x1121, 0x1111, 0x6632, 0x0116, + 0x0011, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x1100, 0x0000, 0x1100, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7774, 0x2111, 0x7774, 0x2777, 0x4444, 0x6444, 0x4444, 0x6444, + 0x4111, 0x4444, 0x7771, 0x2777, 0x7771, 0x2777, 0x1110, 0x1111, + 0x6632, 0x0116, 0x6692, 0x0116, 0x6666, 0x0001, 0x6666, 0x0001, + 0x1114, 0x0000, 0x1122, 0x0000, 0x1122, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x1111, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1111, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x3310, 0x0000, 0x3331, + 0x1000, 0x3333, 0x3000, 0x3333, 0x3000, 0x3333, 0x3100, 0x3333, + 0x4111, 0x3333, 0x3333, 0x4333, 0x3333, 0x4443, 0x3333, 0x4444, + 0x4333, 0x4444, 0x4433, 0x4444, 0x4433, 0x4444, 0x4433, 0x4444, + 0x3333, 0x1113, 0x4444, 0x3344, 0x4444, 0x4454, 0x4444, 0x4544, + 0x4444, 0x4544, 0x4554, 0x4544, 0x4544, 0x4444, 0x4444, 0x4444, + 0x0000, 0x0000, 0x0011, 0x0000, 0x0134, 0x0000, 0x1344, 0x0000, + 0x1444, 0x0001, 0x4444, 0x0013, 0x4444, 0x0014, 0x4444, 0x0044, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3100, 0x3333, 0x3310, 0x3333, 0x3310, 0x3335, 0x3310, 0x3333, + 0x3310, 0x3333, 0x3310, 0x6333, 0x3310, 0x3333, 0x3310, 0x3333, + 0x4443, 0x4444, 0x4443, 0x4444, 0x4443, 0x4114, 0x4443, 0x1111, + 0x1443, 0x1141, 0x1443, 0x1141, 0x4433, 0x1111, 0x4333, 0x4114, + 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, 0x4444, + 0x4441, 0x4444, 0x4441, 0x4444, 0x1444, 0x1111, 0x1444, 0x1111, + 0x1444, 0x0041, 0x1144, 0x0111, 0x4144, 0x0111, 0x1144, 0x0111, + 0x1444, 0x0141, 0x4416, 0x0144, 0x4441, 0x0144, 0x4441, 0x0144, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x5100, 0x0000, 0x2100, + 0x1000, 0x1222, 0x2100, 0x2333, 0x3100, 0x3333, 0x3100, 0x3333, + 0x3311, 0x3333, 0x3313, 0x3333, 0x3113, 0x3333, 0x1311, 0x2222, + 0x2331, 0x1211, 0x1333, 0x1111, 0x1333, 0x1111, 0x1233, 0x2111, + 0x4333, 0x4444, 0x3333, 0x4444, 0x3333, 0x4333, 0x2222, 0x2222, + 0x2111, 0x2222, 0x1333, 0x2222, 0x1333, 0x1221, 0x1333, 0x1121, + 0x4444, 0x1111, 0x4444, 0x1113, 0x4444, 0x4444, 0x2222, 0x2222, + 0x2222, 0x2222, 0x2111, 0x1222, 0x1233, 0x3122, 0x1333, 0x2122, + 0x4441, 0x1134, 0x3444, 0x4333, 0x3333, 0x3133, 0x2222, 0x1112, + 0x2111, 0x1321, 0x1133, 0x1321, 0x1333, 0x3321, 0x1333, 0x3321, + 0x0001, 0x0000, 0x0013, 0x0000, 0x0134, 0x0000, 0x0123, 0x0000, + 0x0011, 0x0000, 0x1111, 0x0001, 0x1113, 0x0012, 0x3233, 0x0012, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x2310, 0x0000, 0x3110, + 0x2100, 0x2333, 0x2100, 0x2222, 0x1000, 0x1111, 0x1100, 0x1111, + 0x3100, 0x1111, 0x2211, 0x1122, 0x2222, 0x1112, 0x2222, 0x2332, + 0x1222, 0x3211, 0x1122, 0x3322, 0x2111, 0x3332, 0x2111, 0x3333, + 0x3111, 0x3333, 0x3111, 0x2333, 0x3111, 0x2333, 0x2112, 0x2222, + 0x1333, 0x2111, 0x1233, 0x2111, 0x1233, 0x2111, 0x1223, 0x2111, + 0x1222, 0x2111, 0x1122, 0x2211, 0x1112, 0x3211, 0x1111, 0x3211, + 0x1333, 0x2121, 0x2333, 0x2111, 0x2332, 0x2111, 0x3332, 0x2111, + 0x3333, 0x2111, 0x3333, 0x1112, 0x3333, 0x1112, 0x3333, 0x1112, + 0x1333, 0x3311, 0x3333, 0x3212, 0x3333, 0x2223, 0x3332, 0x2123, + 0x3322, 0x1133, 0x3322, 0x1133, 0x3221, 0x1123, 0x2211, 0x2122, + 0x3333, 0x0013, 0x3333, 0x0012, 0x3332, 0x0012, 0x2222, 0x0111, + 0x1111, 0x1111, 0x1111, 0x1211, 0x1111, 0x2231, 0x2233, 0x2333, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0011, 0x0000, 0x0132, 0x0000, 0x0122, 0x0000, + 0x0000, 0x1100, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x1223, 0x0000, 0x1111, 0x0000, 0x2310, 0x0000, 0x3310, + 0x0000, 0x3100, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x1111, 0x1121, 0x1111, 0x1222, 0x2111, 0x2233, 0x2333, + 0x3333, 0x1113, 0x1111, 0x0011, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1111, 0x2211, 0x1211, 0x2211, 0x3332, 0x1112, 0x1222, 0x1121, + 0x2111, 0x1131, 0x1000, 0x2322, 0x1000, 0x3332, 0x0000, 0x1111, + 0x2323, 0x1112, 0x2222, 0x2211, 0x1111, 0x3311, 0x1111, 0x2221, + 0x1111, 0x1222, 0x2221, 0x0112, 0x2333, 0x0001, 0x1111, 0x0000, + 0x1111, 0x2111, 0x1111, 0x2111, 0x1123, 0x2211, 0x2222, 0x3312, + 0x3211, 0x3333, 0x1100, 0x1332, 0x0000, 0x0111, 0x0000, 0x0000, + 0x1122, 0x1111, 0x0011, 0x0000, 0x0001, 0x0000, 0x0012, 0x0000, + 0x0012, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0011, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, }; diff --git a/vapor/scripts/rpg-assets.ts b/vapor/scripts/rpg-assets.ts index c3e0ecf1..0b5586ff 100644 --- a/vapor/scripts/rpg-assets.ts +++ b/vapor/scripts/rpg-assets.ts @@ -22,6 +22,11 @@ const HEADER = join(import.meta.dir, "..", "runtime", "gba", "vapor_rpg_assets.g const MANIFEST = join(ROOT, "generation.json"); const API = "https://api.pixellab.ai/v2"; +const WORLD_TILE_SIZE = 16; +const WORLD_TILE_COLUMNS = 10; +const WORLD_ACTOR_SIZE = 32; +const BATTLE_ACTOR_SIZE = 64; + type Rgb = readonly [number, number, number]; type Palette = readonly Rgb[]; type ImageDataLike = { data: Uint8ClampedArray; width: number; height: number }; @@ -34,28 +39,40 @@ const BG: Palette = [ ] as const; const HERO: Palette = [ - [0x00, 0x00, 0x00], [0x18, 0x18, 0x21], [0xff, 0xad, 0x63], [0x42, 0x84, 0xff], - [0xff, 0x7b, 0x4a], + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0xc6, 0x6b, 0x42], [0xff, 0xad, 0x63], + [0x21, 0x52, 0xad], [0x42, 0x84, 0xff], [0x84, 0xb5, 0xff], [0xb5, 0x42, 0x31], + [0xff, 0x7b, 0x4a], [0xef, 0xde, 0x94], ] as const; const ELDER: Palette = [ - [0x00, 0x00, 0x00], [0x18, 0x18, 0x21], [0xff, 0xad, 0x63], [0x94, 0x94, 0x94], - [0xff, 0xff, 0xff], + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0xc6, 0x6b, 0x42], [0xff, 0xad, 0x63], + [0x4a, 0x52, 0x63], [0x73, 0x7b, 0x8c], [0x9c, 0xa5, 0xad], [0xc6, 0xd6, 0xde], + [0xff, 0xff, 0xff], [0x42, 0x84, 0xff], ] as const; const SLIME: Palette = [ - [0x00, 0x00, 0x00], [0x18, 0x18, 0x21], [0x00, 0xad, 0xad], [0x00, 0x5a, 0x5a], - [0xff, 0xff, 0xff], + [0x00, 0x00, 0x00], [0x18, 0x29, 0x4a], [0x00, 0x5a, 0x5a], [0x00, 0xad, 0xad], + [0x39, 0xd6, 0xc6], [0x94, 0xf7, 0xde], [0xff, 0xff, 0xff], +] as const; + +const STYLE_PALETTE: Palette = [ + [0x18, 0x29, 0x4a], [0x21, 0x31, 0x63], [0x21, 0x7b, 0x42], [0x42, 0xc6, 0x5a], + [0x84, 0x5a, 0x29], [0xc6, 0x9c, 0x52], [0x18, 0x4a, 0x94], [0x39, 0x94, 0xe7], + [0xc6, 0x6b, 0x42], [0xff, 0xad, 0x63], [0x42, 0x84, 0xff], [0xff, 0x7b, 0x4a], + [0x73, 0x7b, 0x8c], [0xff, 0xff, 0xff], [0x00, 0xad, 0xad], [0x94, 0xf7, 0xde], ] as const; -const TILE_NAMES = [ +const WORLD_TILE_NAMES = [ "blank", "grass-a", "grass-b", "path-a", "path-b", "wall", "water-a", "water-b", - "tree", "flower", "box-fill", "box-top", "box-bottom", "box-left", "box-right", "box-tl", - "box-tr", "box-bl", "box-br", "battle-sky", "battle-ground", "hp-empty", "hp-full", "hud", + "tree", "flower", +] as const; +const UI_TILE_NAMES = [ + "box-fill", "box-top", "box-bottom", "box-left", "box-right", "box-tl", "box-tr", "box-bl", + "box-br", "battle-sky", "battle-ground", "hp-empty", "hp-full", "hud", ] as const; const ACTOR_NAMES = ["hero-south", "hero-north", "hero-west", "hero-east", "elder", "slime"] as const; const STYLE = [ "Original cheerful early-2000s handheld cartridge RPG pixel art.", - "High top-down world view, compact chibi characters, crisp hard-edged native pixels.", + "High top-down world view, expressive 32-pixel chibi characters, crisp hard-edged native pixels.", "One-pixel selective deep-navy outlines, flat clusters, fixed top-left lighting.", "Readable silhouettes and restrained surface texture; no imitation of any existing game.", ].join(" "); @@ -67,40 +84,47 @@ const TERRAIN_STYLE = [ "No characters, items, icons, perspective, canvas border or imitation of an existing game.", ].join(" "); +const MONSTER_STYLE = [ + "Original cheerful early-2000s handheld cartridge RPG monster pixel art.", + "High top-down view, crisp hard-edged native pixels, flat color clusters and fixed top-left lighting.", + "One-pixel selective deep-navy outline, readable non-humanoid silhouette, no imitation of any existing game.", +].join(" "); + const EXCLUSIONS = [ "antialiasing", "blur", "gradients", "dithering", "soft shadows", "bloom", "photorealism", "painterly texture", "isometric view", "perspective", "text", "letters", "numbers", "labels", - "watermark", "logo", "mockup", "enlarged preview", + "watermark", "logo", "mockup", "enlarged preview", "multiple characters", "duplicate subject", + "sprite sheet", "contact sheet", "lineup", "alternate poses", ].join(", "); const GENERATION = { - version: 1, + version: 2, provider: "PixelLab", apiBase: API, - artDirection: "Vapor Quest: bright field, deep-navy information layer, one-pixel silhouettes", + artDirection: "Vapor Quest close-up: 16px world cells, 32px actors, deep-navy information layer", assets: { styleAnchor: { - endpoint: "/create-image-bitforge", - seed: 22051, - size: { width: 96, height: 64 }, - prompt: `${STYLE} A tiny forest village clearing: grass, a tan footpath, blue stream, gray stone wall, leafy tree, one blue-tunic adventurer, one white-haired elder, and one teal slime. Compose it as a clean asset style board, with no text or UI.`, + endpoint: "/create-image-pixen", + seed: 23050, + size: { width: 64, height: 64 }, + prompt: `${STYLE} Exactly one single south-facing full-body blue-tunic adventurer with short brown hair and a warm orange scarf, centered in a neutral standing pose on transparent background. Blue clothing must be the largest color area. This is a canonical style reference, not a sprite sheet: one subject, one pose, no duplicate, no alternate view, no scenery, text or UI.`, }, terrainSets: [ { name: "field", endpoint: "/tilesets", - seed: 22061, - lower: `${TERRAIN_STYLE} Seamless bright green meadow grass, quiet walkable ground, only two or three tiny dark grass clusters, no objects or border.`, - upper: `${TERRAIN_STYLE} Seamless warm tan compacted village footpath, quiet walkable ground, only two or three tiny brown stone marks, no grass edge or border.`, + seed: 23061, + lower: `${TERRAIN_STYLE} Native 16x16 seamless bright green meadow grass, quiet walkable ground, four or five deliberate dark grass blades, no objects or border.`, + upper: `${TERRAIN_STYLE} Native 16x16 seamless warm tan compacted village footpath, quiet walkable ground, a few readable brown pebbles, no grass edge or border.`, lowerFile: "grass.png", upperFile: "path.png", }, { name: "barriers", endpoint: "/tilesets", - seed: 22062, - lower: `${TERRAIN_STYLE} Seamless deep blue stream water, clearly impassable, two restrained horizontal ripple marks, quiet even field, no shore or border.`, - upper: `${TERRAIN_STYLE} Seamless gray stone barrier, clearly solid and impassable, chunky rectangular stones, dark mortar and bright top-left edges, no grass or border.`, + seed: 23062, + lower: `${TERRAIN_STYLE} Native 16x16 seamless deep blue stream water, clearly impassable, three crisp horizontal ripple clusters, quiet even field, no shore or border.`, + upper: `${TERRAIN_STYLE} Native 16x16 seamless gray stone barrier, clearly solid and impassable, chunky rectangular stones, dark mortar and bright top-left edges, no grass or border.`, lowerFile: "water.png", upperFile: "wall.png", }, @@ -109,38 +133,38 @@ const GENERATION = { { name: "tree", endpoint: "/map-objects", - seed: 22069, - prompt: `${STYLE} One centered top-down deciduous tree map object on transparent background. A single compact, solid, filled round dome canopy occupies most of the image and must never form a hollow ring, split crown, doorway or arch. Dense leaves read as impassable, with dark lower-right foliage, bright top-left leaf clusters and exactly one short centered trunk. No grass tile and no cast shadow.`, + seed: 23069, + prompt: `${STYLE} One centered top-down deciduous tree map object designed to fill one 16x16 world cell after reduction. A single compact, solid, filled round dome canopy occupies most of the image and must never form a hollow ring, split crown, doorway or arch. Dense leaves read as impassable, with dark lower-right foliage, bright top-left leaf clusters and exactly one short centered trunk. No grass tile and no cast shadow.`, }, { name: "flower", endpoint: "/map-objects", - seed: 22066, - prompt: `${STYLE} One tiny gold-and-cream meadow flower map object on transparent background. Low-profile walkable decoration, delicate stem, no enclosing outline, no ground tile and no cast shadow.`, + seed: 23066, + prompt: `${STYLE} One readable gold-and-cream meadow flower cluster designed for one 16x16 world cell after reduction. Low-profile walkable decoration, three small blossoms and delicate green stems, no enclosing outline, no ground tile and no cast shadow.`, }, ], heroSouth: { endpoint: "/create-image-bitforge", - seed: 22053, - size: { width: 32, height: 32 }, - prompt: `${STYLE} One south-facing 32x32 source sprite designed to reduce cleanly to a 16x16 GBA character. Full-body compact chibi adventurer with a readable head, torso, two arms and two separated feet. Blue tunic is the largest color block, warm orange-red scarf is the identity accent, warm skin, deep-navy outline. Centered, feet at the bottom, strong silhouette, no weapon, no detached pixels.`, + seed: 23063, + size: { width: 64, height: 64 }, + prompt: `${STYLE} Exactly one south-facing native 64x64 source sprite designed to retain facial, clothing and limb detail in a 32x32 GBA character. Full-body chibi adventurer with a readable head, short brown hair, torso, two arms, gloves, boots and two separated feet. Blue tunic and darker blue trousers are the largest color blocks; warm orange-red scarf is the identity accent; warm skin and selective deep-navy outline. Centered, feet at the bottom, strong silhouette, no weapon, no detached pixels, no duplicate, no alternate pose, no sprite sheet.`, }, heroRotations: { endpoint: "/create-character-v3", - seed: 22054, - prompt: "The same compact blue-tunic adventurer with orange-red scarf, rotated consistently for a high top-down RPG; preserve head height, shoulder width, palette, outline and foot anchor in every direction.", + seed: 23054, + prompt: "The same expressive blue-tunic adventurer with short brown hair, orange-red scarf, gloves and boots, rotated consistently for a high top-down RPG; preserve face, head height, shoulder width, clothing blocks, palette, outline and foot anchor in every direction.", }, elder: { - endpoint: "/create-image-bitforge", - seed: 22055, - size: { width: 32, height: 32 }, - prompt: `${STYLE} One south-facing 32x32 source sprite designed to reduce cleanly to a 16x16 GBA character. Full-body chibi village elder with white hair, short white beard, broad gray robe, warm skin, deep-navy outline, two arms and a grounded hem. Same height, head ratio, lighting and foot anchor as the blue-tunic adventurer. No staff, centered, no detached pixels.`, + endpoint: "/create-image-pixen", + seed: 23060, + size: { width: 64, height: 64 }, + prompt: `${STYLE} Exactly one south-facing native 64x64 source sprite designed to retain facial, hair and robe detail in a 32x32 GBA character. Full-body chibi village elder with swept white hair, eyebrows, short white beard, broad layered gray robe, blue sash, warm skin, deep-navy outline, two visible arms and a grounded hem. Same height, head ratio, lighting and foot anchor as the blue-tunic adventurer. No staff, centered, no skeleton or ghost appearance, no detached pixels, no duplicate, no alternate pose, no sprite sheet.`, }, slime: { - endpoint: "/create-image-bitforge", - seed: 22056, - size: { width: 32, height: 32 }, - prompt: `${STYLE} One 32x32 source sprite designed to reduce cleanly to a 16x16 GBA monster. A low, wide, rounded slime whose body is at least ninety percent bright teal and dark teal, with a deep-navy outline and broad grounded base. White is allowed only for exactly two tiny separated eye highlights; never make the body, face or outline white. Cute but clearly an enemy, no limbs, no floating parts.`, + endpoint: "/map-objects", + seed: 23065, + size: { width: 64, height: 64 }, + prompt: `${MONSTER_STYLE} Exactly one native 64x64 source sprite designed to retain face, highlight and volume detail in a 32x32 GBA monster. A compact teal tentacled puddle ooze with a rounded central body and several short grounded pseudopods. Its amorphous body is at least ninety percent a four-step teal ramp, with a deep-navy outline, broad grounded base, two tiny white eyes and a small dark mouth. Cute but clearly an enemy, wider than tall, with no humanoid torso, clothing, hands or feet, no floating parts, centered at the bottom, no duplicate, no alternate pose, no sprite sheet.`, }, }, } as const; @@ -174,7 +198,9 @@ function paletteGuidePng(): Buffer { const ctx = canvas.getContext("2d"); for (const [row, palette] of [BG, HERO, ELDER, SLIME].entries()) { for (let i = 0; i < 16; i++) { - const color = palette[Math.min(i, palette.length - 1)]; + // Match the generated GBA palette banks: unused OBJ entries are zero, + // not repetitions of the last authored color. + const color = palette[i] ?? ([0, 0, 0] as const); ctx.fillStyle = hex(color); ctx.fillRect(i * 4, row * 16, 4, 16); } @@ -217,32 +243,51 @@ function imageBytes(image: { base64: string }): Buffer { return Buffer.from(encoded, "base64"); } +async function generatePixen( + file: string, + spec: { prompt: string; seed: number; size: { width: number; height: number } }, + palette: Palette, +): Promise<{ hash: string }> { + console.log(`PixelLab: generating ${file}`); + const response = await pixelLab<{ image: { base64: string } }>("/create-image-pixen", { + description: `${spec.prompt} Use this fixed color language: ${paletteText(palette)}. Avoid ${EXCLUSIONS}.`, + image_size: spec.size, + outline: "selective outline", + detail: "medium detail", + view: "high top-down", + direction: "south", + no_background: true, + background_removal_task: "remove_simple_background", + seed: spec.seed, + }); + const bytes = imageBytes(response.image); + writeFileSync(join(SOURCE, file), bytes); + return { hash: sha256(bytes) }; +} + async function generateBitforge( file: string, spec: { prompt: string; seed: number; size: { width: number; height: number } }, palette: Palette, - styleImage?: Buffer, - transparent = false, + styleImage: Buffer, ): Promise<{ hash: string }> { console.log(`PixelLab: generating ${file}`); - const styleReference = styleImage - ? await resizePng(styleImage, spec.size.width, spec.size.height) - : undefined; + const styleReference = await resizePng(styleImage, spec.size.width, spec.size.height); const response = await pixelLab<{ image: { base64: string } }>("/create-image-bitforge", { description: `${spec.prompt} Use only these palette colors: ${paletteText(palette)}.`, negative_description: EXCLUSIONS, image_size: spec.size, - text_guidance_scale: 10, - style_strength: styleImage ? 45 : 0, - outline: "lineless", + text_guidance_scale: 12, + style_strength: 35, + outline: "selective outline", shading: "basic shading", detail: "medium detail", view: "high top-down", - direction: transparent ? "south" : undefined, - no_background: transparent, - coverage_percentage: transparent ? 76 : 100, + direction: "south", + no_background: true, + coverage_percentage: 84, color_image: base64Image(palettePng(palette)), - style_image: styleReference ? base64Image(styleReference) : undefined, + style_image: base64Image(styleReference), seed: spec.seed, }); const bytes = imageBytes(response.image); @@ -268,6 +313,8 @@ async function generateHeroRotations(reference: Buffer): Promise<{ name: "Vapor Quest Hero", seed: spec.seed, no_background: true, + outline: "selective outline", + detail: "medium detail", }); for (;;) { @@ -390,6 +437,41 @@ async function generateMapObject(spec: typeof GENERATION.assets.mapObjects[numbe return { backgroundJobId: created.background_job_id, objectId: created.object_id, hash: sha256(bytes) }; } +async function generateMapSprite( + file: string, + spec: { prompt: string; seed: number; size: { width: number; height: number } }, + palette: Palette, +): Promise> { + console.log(`PixelLab: generating ${file}`); + const created = await pixelLab<{ background_job_id: string; object_id: string }>("/map-objects", { + description: `${spec.prompt} Use only these palette colors: ${paletteText(palette)}.`, + image_size: spec.size, + view: "high top-down", + outline: "selective outline", + shading: "basic shading", + detail: "medium detail", + text_guidance_scale: 12, + color_image: base64Image(await resizePng(palettePng(palette), spec.size.width, spec.size.height)), + seed: spec.seed, + }); + await waitForJob(created.background_job_id, file); + const detail = await fetch(`${API}/map-objects/${created.object_id}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detail.ok) throw new Error(`PixelLab ${file} fetch failed (${detail.status})`); + const object = await detail.json() as { download_url?: string | null }; + if (!object.download_url) throw new Error(`PixelLab ${file} has no download URL`); + const download = await fetch(object.download_url); + if (!download.ok) throw new Error(`PixelLab ${file} download failed (${download.status})`); + const bytes = Buffer.from(await download.arrayBuffer()); + writeFileSync(join(SOURCE, file), bytes); + return { + backgroundJobId: created.background_job_id, + objectId: created.object_id, + hash: sha256(bytes), + }; +} + function sha256(bytes: Uint8Array | string): string { return createHash("sha256").update(bytes).digest("hex"); } @@ -409,11 +491,7 @@ async function generate(force: boolean): Promise { only === "all" || only === group || files.some((file) => !existsSync(join(SOURCE, file))); if (selected("style", ["style-anchor.png"]) && (force || !existsSync(join(SOURCE, "style-anchor.png")))) { - records.styleAnchor = await generateBitforge( - "style-anchor.png", - GENERATION.assets.styleAnchor, - BG, - ); + records.styleAnchor = await generatePixen("style-anchor.png", GENERATION.assets.styleAnchor, STYLE_PALETTE); } const anchor = readFileSync(join(SOURCE, "style-anchor.png")); const worldFiles = [ @@ -453,9 +531,7 @@ async function generate(force: boolean): Promise { if (charactersSelected) { const forceHero = force && (only === "all" || only === "characters" || only === "hero"); if (forceHero || !existsSync(join(SOURCE, "hero-south-reference.png"))) { - records.heroSouth = await generateBitforge( - "hero-south-reference.png", GENERATION.assets.heroSouth, HERO, anchor, true, - ); + records.heroSouth = await generateBitforge("hero-south-reference.png", GENERATION.assets.heroSouth, HERO, anchor); } const rotationsMissing = ["hero-south.png", "hero-north.png", "hero-west.png", "hero-east.png"] .some((file) => !existsSync(join(SOURCE, file))); @@ -464,11 +540,11 @@ async function generate(force: boolean): Promise { } const forceElder = force && (only === "all" || only === "characters" || only === "elder"); if (forceElder || !existsSync(join(SOURCE, "elder.png"))) { - records.elder = await generateBitforge("elder.png", GENERATION.assets.elder, ELDER, anchor, true); + records.elder = await generatePixen("elder.png", GENERATION.assets.elder, ELDER); } const forceSlime = force && (only === "all" || only === "characters" || only === "slime"); if (forceSlime || !existsSync(join(SOURCE, "slime.png"))) { - records.slime = await generateBitforge("slime.png", GENERATION.assets.slime, SLIME, undefined, true); + records.slime = await generateMapSprite("slime.png", GENERATION.assets.slime, SLIME); } } delete records.backgroundAtlas; @@ -493,6 +569,38 @@ function nearestColor(r: number, g: number, b: number, palette: Palette, start = return best; } +/** + * Preserve a generated terrain tile's pixel clusters while translating its + * arbitrary colors into a small semantic GBA ramp. The input luminance range + * determines the authored texture; the ramp only fixes material identity. + */ +export function projectTerrain( + rgba: Uint8ClampedArray, + width: number, + height: number, + shadow: number, + base: number, + highlight: number, + shadowCount: number, + highlightCount: number, +): Uint8Array { + if (rgba.length !== width * height * 4) throw new Error("terrain RGBA dimensions do not match"); + if (shadowCount < 0 || highlightCount < 0 || shadowCount + highlightCount > width * height) { + throw new Error("terrain projection counts exceed the source tile"); + } + const ranked = Array.from({ length: width * height }, (_, index) => ({ + index, + // Integer Rec. 709-style weights plus an index tie-break make the same + // reviewed PNG produce byte-identical art on every build host. + luminance: rgba[index * 4] * 54 + rgba[index * 4 + 1] * 183 + rgba[index * 4 + 2] * 19, + })).sort((a, b) => a.luminance - b.luminance || a.index - b.index); + const result = new Uint8Array(width * height); + result.fill(base); + for (let i = 0; i < shadowCount; i++) result[ranked[i].index] = shadow; + for (let i = 0; i < highlightCount; i++) result[ranked[ranked.length - 1 - i].index] = highlight; + return result; +} + function indicesFromContext(ctx: SKRSContext2D, width: number, height: number, palette: Palette, transparent: boolean): Uint8Array { const raw = ctx.getImageData(0, 0, width, height) as ImageDataLike; const result = new Uint8Array(width * height); @@ -527,28 +635,31 @@ async function loadExact(path: string, width: number, height: number) { return image; } -async function buildBackground(): Promise<{ png: Buffer; pixels: Uint8Array }> { - const canvas = createCanvas(64, 24); +export async function buildBackground(sourceDir = SOURCE): Promise<{ png: Buffer; pixels: Uint8Array }> { + const sheetWidth = WORLD_TILE_COLUMNS * WORLD_TILE_SIZE; + const sheetHeight = WORLD_TILE_SIZE + 8; + const canvas = createCanvas(sheetWidth, sheetHeight); const ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = false; + ctx.fillStyle = hex(BG[1]); + ctx.fillRect(0, 0, sheetWidth, sheetHeight); const images = Object.fromEntries(await Promise.all([ - ...["grass", "path", "wall", "water"].map(async (name) => [name, await loadExact(join(SOURCE, `${name}.png`), 16, 16)]), - ...["tree", "flower"].map(async (name) => [name, await loadExact(join(SOURCE, `${name}.png`), 32, 32)]), + ...["grass", "path", "wall", "water"].map(async (name) => [name, await loadExact(join(sourceDir, `${name}.png`), 16, 16)]), + ...["tree", "flower"].map(async (name) => [name, await loadExact(join(sourceDir, `${name}.png`), 32, 32)]), ])); - const drawTile = (name: string, tile: number, flip = false) => { - const dx = (tile % 8) * 8; - const dy = Math.floor(tile / 8) * 8; + const drawWorldTile = (name: string, frame: number, flip = false) => { + const dx = frame * WORLD_TILE_SIZE; ctx.save(); if (flip) { - ctx.translate(dx + 8, dy); + ctx.translate(dx + WORLD_TILE_SIZE, 0); ctx.scale(-1, 1); - ctx.drawImage(images[name], 0, 0, 16, 16, 0, 0, 8, 8); + ctx.drawImage(images[name], 0, 0, WORLD_TILE_SIZE, WORLD_TILE_SIZE); } else { - ctx.drawImage(images[name], 0, 0, 16, 16, dx, dy, 8, 8); + ctx.drawImage(images[name], dx, 0, WORLD_TILE_SIZE, WORLD_TILE_SIZE); } ctx.restore(); }; - const drawObject = (name: string, tile: number, maxWidth: number, maxHeight: number) => { + const drawObject = (name: string, frame: number, maxWidth: number, maxHeight: number): Set => { const image = images[name]; const scratch = createCanvas(image.width, image.height); const scratchCtx = scratch.getContext("2d"); @@ -574,126 +685,173 @@ async function buildBackground(): Promise<{ png: Buffer; pixels: Uint8Array }> { const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight); const width = Math.max(1, Math.round(sourceWidth * scale)); const height = Math.max(1, Math.round(sourceHeight * scale)); - const ox = (tile % 8) * 8 + Math.floor((8 - width) / 2); - const oy = Math.floor(tile / 8) * 8 + 8 - height; - ctx.imageSmoothingEnabled = true; - ctx.drawImage(image, minX, minY, sourceWidth, sourceHeight, ox, oy, width, height); + const localX = Math.floor((WORLD_TILE_SIZE - width) / 2); + const localY = WORLD_TILE_SIZE - height; + const ox = frame * WORLD_TILE_SIZE + localX; + const oy = localY; ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, minX, minY, sourceWidth, sourceHeight, ox, oy, width, height); + const mask = new Set(); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const sx = minX + Math.min(sourceWidth - 1, Math.floor(x * sourceWidth / width)); + const sy = minY + Math.min(sourceHeight - 1, Math.floor(y * sourceHeight / height)); + if (raw.data[(sy * image.width + sx) * 4 + 3] >= 128) { + mask.add((localY + y) * WORLD_TILE_SIZE + localX + x); + } + } + } + return mask; }; - drawTile("grass", 1); - drawTile("grass", 2, true); - drawTile("path", 3); - drawTile("path", 4, true); - drawTile("wall", 5); - drawTile("water", 6); - drawTile("water", 7, true); - drawTile("grass", 8); - drawTile("grass", 9, true); - drawObject("tree", 8, 7, 8); - drawObject("flower", 9, 5, 6); - const indices = indicesFromContext(ctx, 64, 24, BG, false); - - // Hardware semantics override generative ambiguity for the non-art blank and - // guarantee that all nine dialog pieces tile without seams. - const setTile = (tile: number, fill: number, border: "none" | "top" | "bottom" | "left" | "right" | "tl" | "tr" | "bl" | "br") => { - const ox = (tile % 8) * 8; - const oy = Math.floor(tile / 8) * 8; - for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) indices[(oy + y) * 64 + ox + x] = fill; - const edge = 14; - if (border === "top" || border === "tl" || border === "tr") for (let x = 0; x < 8; x++) indices[oy * 64 + ox + x] = edge; - if (border === "bottom" || border === "bl" || border === "br") for (let x = 0; x < 8; x++) indices[(oy + 7) * 64 + ox + x] = edge; - if (border === "left" || border === "tl" || border === "bl") for (let y = 0; y < 8; y++) indices[(oy + y) * 64 + ox] = edge; - if (border === "right" || border === "tr" || border === "br") for (let y = 0; y < 8; y++) indices[(oy + y) * 64 + ox + 7] = edge; - }; - setTile(0, 1, "none"); - setTile(10, 13, "none"); - setTile(11, 13, "top"); - setTile(12, 13, "bottom"); - setTile(13, 13, "left"); - setTile(14, 13, "right"); - setTile(15, 13, "tl"); - setTile(16, 13, "tr"); - setTile(17, 13, "bl"); - setTile(18, 13, "br"); - setTile(19, 8, "none"); - setTile(20, 2, "none"); - setTile(21, 1, "none"); - setTile(22, 1, "none"); - setTile(23, 1, "none"); - const paint = (tile: number, x: number, y: number, color: number) => { - const ox = (tile % 8) * 8; - const oy = Math.floor(tile / 8) * 8; - indices[(oy + y) * 64 + ox + x] = color; + drawWorldTile("grass", 1); + drawWorldTile("grass", 2, true); + drawWorldTile("path", 3); + drawWorldTile("path", 4, true); + drawWorldTile("wall", 5); + drawWorldTile("water", 6); + drawWorldTile("water", 7, true); + drawWorldTile("grass", 8); + drawWorldTile("grass", 9, true); + const treeMask = drawObject("tree", 8, 15, 16); + const flowerMask = drawObject("flower", 9, 10, 11); + const indices = indicesFromContext(ctx, sheetWidth, sheetHeight, BG, false); + + const worldAt = (frame: number, x: number, y: number): number => + y * sheetWidth + frame * WORLD_TILE_SIZE + x; + const paintWorld = (frame: number, x: number, y: number, color: number): void => { + indices[worldAt(frame, x, y)] = color; }; - setTile(1, 3, "none"); - setTile(2, 3, "none"); - setTile(3, 5, "none"); - setTile(4, 5, "none"); - setTile(5, 7, "none"); - setTile(6, 9, "none"); - setTile(7, 9, "none"); - for (const [tile, marks] of [ - [1, [[1, 2], [6, 6], [2, 7]]], - [2, [[5, 1], [2, 5], [6, 7]]], + + // The generated PNGs own the base texture. Translate their luminance + // clusters into material-specific ramps, then add only sparse readability + // accents below. This keeps PixelLab input materially present in the ROM. + const sourceRgba = (ctx.getImageData(0, 0, sheetWidth, WORLD_TILE_SIZE) as ImageDataLike).data; + for (const [frame, sourceFrame, shadow, base, highlight, shadowCount, highlightCount] of [ + [1, 1, 10, 3, 11, 8, 4], [2, 2, 10, 3, 11, 8, 4], + [3, 3, 4, 5, 14, 20, 10], [4, 4, 4, 5, 14, 20, 10], + [5, 5, 6, 7, 14, 32, 12], + [6, 6, 8, 9, 13, 28, 8], [7, 7, 8, 9, 13, 28, 8], + // Object frames reuse pristine grass projection; their PixelLab masks are + // applied afterwards and cannot perturb the surrounding ground texture. + [8, 1, 10, 3, 11, 8, 4], [9, 2, 10, 3, 11, 8, 4], ] as const) { - for (const [x, y] of marks) paint(tile, x, y, 2); + const tile = new Uint8ClampedArray(WORLD_TILE_SIZE * WORLD_TILE_SIZE * 4); + for (let y = 0; y < WORLD_TILE_SIZE; y++) { + for (let x = 0; x < WORLD_TILE_SIZE; x++) { + const sourceAt = (y * sheetWidth + sourceFrame * WORLD_TILE_SIZE + x) * 4; + const targetAt = (y * WORLD_TILE_SIZE + x) * 4; + tile[targetAt] = sourceRgba[sourceAt]; + tile[targetAt + 1] = sourceRgba[sourceAt + 1]; + tile[targetAt + 2] = sourceRgba[sourceAt + 2]; + tile[targetAt + 3] = sourceRgba[sourceAt + 3]; + } + } + const mapped = projectTerrain( + tile, + WORLD_TILE_SIZE, + WORLD_TILE_SIZE, + shadow, + base, + highlight, + shadowCount, + highlightCount, + ); + for (let y = 0; y < WORLD_TILE_SIZE; y++) { + for (let x = 0; x < WORLD_TILE_SIZE; x++) paintWorld(frame, x, y, mapped[y * WORLD_TILE_SIZE + x]); + } } - for (const [tile, marks] of [ - [3, [[1, 1], [6, 5], [3, 7]]], - [4, [[6, 1], [2, 4], [5, 7]]], + for (const [frame, color, marks] of [ + [1, 2, [[2, 3], [11, 5], [5, 12], [14, 14]]], + [2, 2, [[12, 2], [4, 6], [9, 11], [1, 14]]], + [3, 4, [[3, 2], [12, 6], [6, 11], [14, 14]]], + [4, 4, [[12, 2], [5, 5], [2, 11], [10, 14]]], ] as const) { - for (const [x, y] of marks) paint(tile, x, y, 4); + for (const [x, y] of marks) paintWorld(frame, x, y, color); } - for (let x = 0; x < 8; x++) { - paint(5, x, 3, 6); - paint(5, x, 7, 6); - } - for (let y = 0; y < 3; y++) paint(5, 3, y, 6); - for (let y = 4; y < 7; y++) paint(5, 6, y, 6); - paint(5, 0, 0, 14); - paint(5, 4, 4, 14); - for (const [tile, y0] of [[6, 2], [7, 5]] as const) { - for (let x = 0; x < 3; x++) paint(tile, x, y0, 8); - for (let x = 5; x < 8; x++) paint(tile, x, 7 - y0, 8); - } - // Preserve the PixelLab tree silhouette while collapsing its source grays - // into the shared foliage ramp. The tiny target needs a closed canopy and a - // deliberate two-pixel trunk to remain a tree rather than a dark arch. - for (let y = 0; y <= 5; y++) { - for (let x = 0; x < 8; x++) { - const ox = (8 % 8) * 8; - const oy = Math.floor(8 / 8) * 8; - const at = (oy + y) * 64 + ox + x; - if (indices[at] === 1 || indices[at] === 6 || indices[at] === 13) indices[at] = 10; - else if (indices[at] === 7 || indices[at] === 14) indices[at] = 11; - } + for (const [frame, marks] of [ + [1, [[3, 4], [12, 6], [6, 13]]], + [2, [[11, 3], [5, 7], [2, 13]]], + ] as const) for (const [x, y] of marks) paintWorld(frame, x, y, 10); + for (const [frame, marks] of [ + [3, [[8, 3], [2, 9], [13, 12]]], + [4, [[4, 2], [11, 8], [6, 13]]], + ] as const) for (const [x, y] of marks) paintWorld(frame, x, y, 14); + for (let x = 0; x < WORLD_TILE_SIZE; x++) { + paintWorld(5, x, 5, 6); + paintWorld(5, x, 11, 6); } - for (let x = 0; x < 8; x++) { - if (x !== 3 && x !== 4) { - paint(8, x, 6, 3); - paint(8, x, 7, 3); + for (let y = 0; y < 5; y++) paintWorld(5, 7, y, 6); + for (let y = 6; y < 11; y++) paintWorld(5, 3, y, 6); + for (let y = 12; y < 16; y++) paintWorld(5, 12, y, 6); + paintWorld(5, 1, 1, 14); + paintWorld(5, 9, 7, 14); + paintWorld(5, 14, 13, 14); + for (const [frame, rows] of [[6, [3, 10]], [7, [6, 13]]] as const) { + for (const y of rows) { + for (let x = 1; x < 7; x++) paintWorld(frame, x, y, 8); + for (let x = 10; x < 15; x++) paintWorld(frame, x, (y + 5) % 16, 8); } } - paint(8, 3, 5, 4); - paint(8, 4, 5, 5); - paint(8, 3, 6, 4); - paint(8, 4, 6, 4); - paint(8, 3, 7, 4); - paint(8, 4, 7, 4); - paint(8, 2, 1, 11); - paint(8, 5, 2, 11); - paint(8, 1, 3, 11); + paintWorld(6, 12, 2, 13); + paintWorld(6, 4, 12, 13); + paintWorld(7, 3, 4, 13); + paintWorld(7, 12, 11, 13); + + for (const local of treeMask) { + const x = local % WORLD_TILE_SIZE; + const y = Math.floor(local / WORLD_TILE_SIZE); + const at = worldAt(8, x, y); + if (y >= 12 && x >= 6 && x <= 9) indices[at] = y === 12 ? 5 : 4; + else indices[at] = ((x * 3 + y * 5) % 11 < 3) ? 11 : 10; + } + for (const local of flowerMask) { + const x = local % WORLD_TILE_SIZE; + const y = Math.floor(local / WORLD_TILE_SIZE); + indices[worldAt(9, x, y)] = y < 9 ? ((x + y) % 3 ? 12 : 14) : 11; + } + + // UI remains a screen-space 8x8 system. These exact pieces guarantee that + // dialog borders, HUD and HP bars tile without seams beside 16px world cells. + const setUiTile = (tile: number, fill: number, border: "none" | "top" | "bottom" | "left" | "right" | "tl" | "tr" | "bl" | "br") => { + const ox = tile * 8; + const oy = WORLD_TILE_SIZE; + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) indices[(oy + y) * sheetWidth + ox + x] = fill; + const edge = 14; + if (border === "top" || border === "tl" || border === "tr") for (let x = 0; x < 8; x++) indices[oy * sheetWidth + ox + x] = edge; + if (border === "bottom" || border === "bl" || border === "br") for (let x = 0; x < 8; x++) indices[(oy + 7) * sheetWidth + ox + x] = edge; + if (border === "left" || border === "tl" || border === "bl") for (let y = 0; y < 8; y++) indices[(oy + y) * sheetWidth + ox] = edge; + if (border === "right" || border === "tr" || border === "br") for (let y = 0; y < 8; y++) indices[(oy + y) * sheetWidth + ox + 7] = edge; + }; + setUiTile(0, 13, "none"); + setUiTile(1, 13, "top"); + setUiTile(2, 13, "bottom"); + setUiTile(3, 13, "left"); + setUiTile(4, 13, "right"); + setUiTile(5, 13, "tl"); + setUiTile(6, 13, "tr"); + setUiTile(7, 13, "bl"); + setUiTile(8, 13, "br"); + setUiTile(9, 8, "none"); + setUiTile(10, 2, "none"); + setUiTile(11, 1, "none"); + setUiTile(12, 1, "none"); + setUiTile(13, 1, "none"); + const paintUi = (tile: number, x: number, y: number, color: number) => { + const ox = tile * 8; + const oy = WORLD_TILE_SIZE; + indices[(oy + y) * sheetWidth + ox + x] = color; + }; for (let x = 0; x < 8; x++) { for (let y = 2; y <= 5; y++) { - paint(21, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 6 : 7); - paint(22, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 10 : 12); + paintUi(11, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 6 : 7); + paintUi(12, x, y, y === 2 || y === 5 || x === 0 || x === 7 ? 10 : 12); } - paint(23, x, 7, 12); + paintUi(13, x, 7, 12); } - const finalCanvas = createCanvas(64, 24); + const finalCanvas = createCanvas(sheetWidth, sheetHeight); const finalCtx = finalCanvas.getContext("2d"); - drawIndices(finalCtx, indices, 64, 24, BG, 0, 0, false); + drawIndices(finalCtx, indices, sheetWidth, sheetHeight, BG, 0, 0, false); return { png: finalCanvas.toBuffer("image/png"), pixels: indices }; } @@ -725,6 +883,9 @@ async function normalizeSprite( } } if (maxX < minX || maxY < minY) throw new Error(`${path} has no opaque actor pixels`); + if (minX === 0 || minY === 0 || maxX === image.width - 1 || maxY === image.height - 1) { + throw new Error(`${path} actor touches the source canvas edge; reject scene/background leakage`); + } const sourceWidth = maxX - minX + 1; const sourceHeight = maxY - minY + 1; const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight); @@ -741,8 +902,13 @@ async function normalizeSprite( } function polishSlime(slime: Uint8Array, size: number): void { + const dark = 2; + const mid = 3; + const bright = 4; + const pale = 5; + const eye = 6; for (let i = 0; i < slime.length; i++) { - if (slime[i] === 4) slime[i] = 2; + if (slime[i] === eye) slime[i] = bright; } let slimeMinX = size; let slimeMinY = size; @@ -761,11 +927,11 @@ function polishSlime(slime: Uint8Array, size: number): void { const slimeWidth = slimeMaxX - slimeMinX + 1; const slimeHeight = slimeMaxY - slimeMinY + 1; const eyeY = slimeMinY + Math.max(1, Math.floor(slimeHeight * 0.36)); - slime[eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.34)] = 4; - slime[eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.66)] = 4; + const leftEye = eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.34); + const rightEye = eyeY * size + slimeMinX + Math.floor(slimeWidth * 0.66); const tealRows: number[] = []; for (let y = 0; y < size; y++) { - if (slime.slice(y * size, y * size + size).some((pixel) => pixel === 2 || pixel === 3)) tealRows.push(y); + if (slime.slice(y * size, y * size + size).some((pixel) => pixel >= dark && pixel <= pale)) tealRows.push(y); } const shadeFrom = tealRows.length > 0 ? Math.floor((tealRows[0] + tealRows[tealRows.length - 1] + 1) / 2) @@ -773,10 +939,18 @@ function polishSlime(slime: Uint8Array, size: number): void { for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const at = y * size + x; - if (y < shadeFrom && slime[at] === 3) slime[at] = 2; - if (y >= shadeFrom && slime[at] === 2) slime[at] = 3; + if (y < shadeFrom && slime[at] === dark) slime[at] = mid; + if (y >= shadeFrom && slime[at] >= bright && slime[at] <= pale) slime[at] = mid; } } + const body = [...slime.keys()].filter((at) => slime[at] >= dark && slime[at] <= pale); + if (body.length > 3) { + slime[body[0]] = bright; + slime[body[Math.floor(body.length / 4)]] = pale; + slime[body[body.length - 1]] = dark; + } + slime[leftEye] = eye; + slime[rightEye] = eye; } async function buildActors(): Promise<{ @@ -796,31 +970,33 @@ async function buildActors(): Promise<{ const pixels: Uint8Array[] = []; for (const [file, palette, kind] of specs) { pixels.push(await normalizeSprite( - join(SOURCE, file), palette, 16, kind === "slime" ? 11 : 10, kind === "slime" ? 9 : 15, + join(SOURCE, file), palette, WORLD_ACTOR_SIZE, + kind === "slime" ? 28 : 20, + kind === "slime" ? 21 : 30, )); } - // The PixelLab slime source intentionally uses a very small cyan ramp. - // Reserve the lower body for the fixed dark teal so it stays grounded after - // RGB555 quantization and on the dimmer original GBA LCD. + // Reassert the reviewed eyes and top-left/lower-right teal shading so the + // generated puddle-ooze silhouette survives RGB555 quantization and the + // dimmer original GBA LCD without changing its PixelLab-authored outline. const slime = pixels[5]; - polishSlime(slime, 16); - const canvas = createCanvas(96, 16); + polishSlime(slime, WORLD_ACTOR_SIZE); + const canvas = createCanvas(ACTOR_NAMES.length * WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE); const ctx = canvas.getContext("2d"); - ctx.clearRect(0, 0, 96, 16); + ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < pixels.length; i++) { const palette = i < 4 ? HERO : i === 4 ? ELDER : SLIME; - drawIndices(ctx, pixels[i], 16, 16, palette, i * 16, 0, true); + drawIndices(ctx, pixels[i], WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE, palette, i * WORLD_ACTOR_SIZE, 0, true); } const battlePixels = [ - await normalizeSprite(join(SOURCE, "hero-east.png"), HERO, 32, 24, 30), - await normalizeSprite(join(SOURCE, "slime.png"), SLIME, 32, 26, 24), + await normalizeSprite(join(SOURCE, "hero-east.png"), HERO, BATTLE_ACTOR_SIZE, 48, 60), + await normalizeSprite(join(SOURCE, "slime.png"), SLIME, BATTLE_ACTOR_SIZE, 54, 44), ]; - polishSlime(battlePixels[1], 32); - const battleCanvas = createCanvas(64, 32); + polishSlime(battlePixels[1], BATTLE_ACTOR_SIZE); + const battleCanvas = createCanvas(2 * BATTLE_ACTOR_SIZE, BATTLE_ACTOR_SIZE); const battleCtx = battleCanvas.getContext("2d"); - battleCtx.clearRect(0, 0, 64, 32); - drawIndices(battleCtx, battlePixels[0], 32, 32, HERO, 0, 0, true); - drawIndices(battleCtx, battlePixels[1], 32, 32, SLIME, 32, 0, true); + battleCtx.clearRect(0, 0, battleCanvas.width, battleCanvas.height); + drawIndices(battleCtx, battlePixels[0], BATTLE_ACTOR_SIZE, BATTLE_ACTOR_SIZE, HERO, 0, 0, true); + drawIndices(battleCtx, battlePixels[1], BATTLE_ACTOR_SIZE, BATTLE_ACTOR_SIZE, SLIME, BATTLE_ACTOR_SIZE, 0, true); return { png: canvas.toBuffer("image/png"), pixels, @@ -850,8 +1026,16 @@ function packTile(pixels: Uint8Array, stride: number, ox: number, oy: number): n function wordsForBackground(pixels: Uint8Array): number[] { const words: number[] = []; - for (let tile = 0; tile < TILE_NAMES.length; tile++) { - words.push(...packTile(pixels, 64, (tile % 8) * 8, Math.floor(tile / 8) * 8)); + const stride = WORLD_TILE_COLUMNS * WORLD_TILE_SIZE; + for (let frame = 0; frame < WORLD_TILE_NAMES.length; frame++) { + for (let ty = 0; ty < 2; ty++) { + for (let tx = 0; tx < 2; tx++) { + words.push(...packTile(pixels, stride, frame * WORLD_TILE_SIZE + tx * 8, ty * 8)); + } + } + } + for (let tile = 0; tile < UI_TILE_NAMES.length; tile++) { + words.push(...packTile(pixels, stride, tile * 8, WORLD_TILE_SIZE)); } return words; } @@ -859,10 +1043,10 @@ function wordsForBackground(pixels: Uint8Array): number[] { function wordsForActors(actors: Uint8Array[], battleActors: Uint8Array[]): number[] { const words: number[] = []; for (const pixels of actors) { - for (let ty = 0; ty < 2; ty++) for (let tx = 0; tx < 2; tx++) words.push(...packTile(pixels, 16, tx * 8, ty * 8)); + for (let ty = 0; ty < 4; ty++) for (let tx = 0; tx < 4; tx++) words.push(...packTile(pixels, WORLD_ACTOR_SIZE, tx * 8, ty * 8)); } for (const pixels of battleActors) { - for (let ty = 0; ty < 4; ty++) for (let tx = 0; tx < 4; tx++) words.push(...packTile(pixels, 32, tx * 8, ty * 8)); + for (let ty = 0; ty < 8; ty++) for (let tx = 0; tx < 8; tx++) words.push(...packTile(pixels, BATTLE_ACTOR_SIZE, tx * 8, ty * 8)); } return words; } @@ -886,11 +1070,18 @@ function generatedHeader(bgPixels: Uint8Array, actorPixels: Uint8Array[], battle "#ifndef VP_RPG_ASSETS_GENERATED_H", "#define VP_RPG_ASSETS_GENERATED_H", "", - `#define VP_RPG_BG_TILE_COUNT ${TILE_NAMES.length}`, - `#define VP_RPG_OBJ_SMALL_FRAME_COUNT ${ACTOR_NAMES.length}`, - "#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_OBJ_SMALL_FRAME_COUNT * 4)", - "#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 16)", - "#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 16)", + `#define VP_RPG_WORLD_TILE_FRAME_COUNT ${WORLD_TILE_NAMES.length}`, + "#define VP_RPG_WORLD_TILE_FRAME_TILES 4", + `#define VP_RPG_UI_TILE_COUNT ${UI_TILE_NAMES.length}`, + "#define VP_RPG_UI_TILE_BASE (VP_RPG_WORLD_TILE_FRAME_COUNT * VP_RPG_WORLD_TILE_FRAME_TILES)", + "#define VP_RPG_BG_TILE_COUNT (VP_RPG_UI_TILE_BASE + VP_RPG_UI_TILE_COUNT)", + `#define VP_RPG_WORLD_ACTOR_FRAME_COUNT ${ACTOR_NAMES.length}`, + "#define VP_RPG_WORLD_ACTOR_FRAME_TILES 16", + "#define VP_RPG_WORLD_ELDER_TILE (4 * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_WORLD_SLIME_TILE (5 * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_WORLD_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_BATTLE_SLIME_TILE (VP_RPG_BATTLE_HERO_TILE + 64)", + "#define VP_RPG_OBJ_TILE_COUNT (VP_RPG_BATTLE_SLIME_TILE + 64)", "", formatWords("vp_rpg_bg_palette", paddedPalette(BG)), "", @@ -906,26 +1097,94 @@ function generatedHeader(bgPixels: Uint8Array, actorPixels: Uint8Array[], battle } function assertAssetSemantics(bg: Uint8Array, actors: Uint8Array[], battleActors: Uint8Array[]): void { - if (bg.length !== 64 * 24) throw new Error("background sheet must contain 24 8x8 tiles"); - if (actors.length !== 6 || actors.some((frame) => frame.length !== 256)) throw new Error("actor sheet must contain six 16x16 frames"); + const bounds = (frame: Uint8Array, size: number): { width: number; height: number } => { + let minX = size; + let minY = size; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { + if (frame[y * size + x] === 0) continue; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + return { width: maxX - minX + 1, height: maxY - minY + 1 }; + }; + const connectedComponents = (frame: Uint8Array, size: number): number => { + const visited = new Uint8Array(frame.length); + let components = 0; + for (let start = 0; start < frame.length; start++) { + if (frame[start] === 0 || visited[start]) continue; + components++; + const stack = [start]; + visited[start] = 1; + while (stack.length > 0) { + const at = stack.pop()!; + const x = at % size; + const y = Math.floor(at / size); + for (const next of [x > 0 ? at - 1 : -1, x + 1 < size ? at + 1 : -1, y > 0 ? at - size : -1, y + 1 < size ? at + size : -1]) { + if (next >= 0 && frame[next] !== 0 && !visited[next]) { + visited[next] = 1; + stack.push(next); + } + } + } + } + return components; + }; + const bottomContactSpans = (frame: Uint8Array, size: number): number => { + let spans = 0; + let opaque = false; + for (let x = 0; x < size; x++) { + const next = frame[(size - 1) * size + x] !== 0; + if (next && !opaque) spans++; + opaque = next; + } + return spans; + }; + if (bg.length !== WORLD_TILE_COLUMNS * WORLD_TILE_SIZE * (WORLD_TILE_SIZE + 8)) { + throw new Error("background preview must contain ten 16x16 world frames and fourteen 8x8 UI tiles"); + } + if (actors.length !== 6 || actors.some((frame) => frame.length !== WORLD_ACTOR_SIZE ** 2)) { + throw new Error("actor sheet must contain six 32x32 frames"); + } for (const [index, frame] of actors.entries()) { const visible = frame.filter((pixel) => pixel !== 0).length; - if (visible < (index === 5 ? 20 : 30)) throw new Error(`${ACTOR_NAMES[index]} silhouette is too sparse (${visible} pixels)`); - if (!frame.slice(15 * 16).some((pixel) => pixel !== 0)) throw new Error(`${ACTOR_NAMES[index]} does not touch the shared y=15 foot line`); + if (visible < (index === 5 ? 220 : 280)) throw new Error(`${ACTOR_NAMES[index]} silhouette is too sparse (${visible} pixels)`); + if (!frame.slice(31 * WORLD_ACTOR_SIZE).some((pixel) => pixel !== 0)) throw new Error(`${ACTOR_NAMES[index]} does not touch the shared y=31 foot line`); + const box = bounds(frame, WORLD_ACTOR_SIZE); + if (box.width < (index === 5 ? 22 : 18) || box.height < (index === 5 ? 14 : 26)) { + throw new Error(`${ACTOR_NAMES[index]} bbox is too small (${box.width}x${box.height})`); + } const max = index < 4 ? HERO.length - 1 : index === 4 ? ELDER.length - 1 : SLIME.length - 1; if (frame.some((pixel) => pixel > max)) throw new Error(`${ACTOR_NAMES[index]} exceeds its OBJ palette`); + if (connectedComponents(frame, WORLD_ACTOR_SIZE) !== 1) throw new Error(`${ACTOR_NAMES[index]} must be one connected silhouette`); + if (index === 5 && (box.width < box.height || bottomContactSpans(frame, WORLD_ACTOR_SIZE) < 2)) { + throw new Error("slime must remain a wide puddle ooze with at least two grounded pseudopod contacts"); + } } const heroHashes = actors.slice(0, 4).map((pixels) => sha256(pixels)); if (new Set(heroHashes).size !== 4) throw new Error("hero directions must be visually distinct"); - if (!actors[5].some((pixel) => pixel === 2) || !actors[5].some((pixel) => pixel === 3)) throw new Error("slime must retain both teal shades"); - if (battleActors.length !== 2 || battleActors.some((frame) => frame.length !== 1024)) { - throw new Error("battle actor sheet must contain two 32x32 frames"); + const heroHeights = actors.slice(0, 4).map((frame) => bounds(frame, WORLD_ACTOR_SIZE).height); + if (Math.max(...heroHeights) - Math.min(...heroHeights) > 2) throw new Error("hero direction heights drift by more than two pixels"); + const slimeTeals = new Set(actors[5].filter((pixel) => pixel >= 2 && pixel <= 5)); + if (slimeTeals.size < 3) throw new Error("slime must retain at least three teal shades"); + if (actors[5].filter((pixel) => pixel === 6).length !== 2) throw new Error("slime must have exactly two white eye pixels"); + if (battleActors.length !== 2 || battleActors.some((frame) => frame.length !== BATTLE_ACTOR_SIZE ** 2)) { + throw new Error("battle actor sheet must contain two 64x64 frames"); } for (const [index, frame] of battleActors.entries()) { - if (frame.filter((pixel) => pixel !== 0).length < 100) throw new Error(`battle actor ${index} silhouette is too sparse`); - if (!frame.slice(31 * 32).some((pixel) => pixel !== 0)) throw new Error(`battle actor ${index} does not touch y=31`); + if (frame.filter((pixel) => pixel !== 0).length < (index === 0 ? 1000 : 900)) throw new Error(`battle actor ${index} silhouette is too sparse`); + if (!frame.slice(63 * BATTLE_ACTOR_SIZE).some((pixel) => pixel !== 0)) throw new Error(`battle actor ${index} does not touch y=63`); + const box = bounds(frame, BATTLE_ACTOR_SIZE); + if (box.width < (index === 0 ? 38 : 44) || box.height < (index === 0 ? 52 : 30)) { + throw new Error(`battle actor ${index} bbox is too small (${box.width}x${box.height})`); + } const max = index === 0 ? HERO.length - 1 : SLIME.length - 1; if (frame.some((pixel) => pixel > max)) throw new Error(`battle actor ${index} exceeds its OBJ palette`); + if (connectedComponents(frame, BATTLE_ACTOR_SIZE) !== 1) throw new Error(`battle actor ${index} must be one connected silhouette`); + if (index === 1 && box.width < box.height) throw new Error("battle slime must remain wider than it is tall"); } } @@ -949,7 +1208,7 @@ async function build(check: boolean): Promise { const bytes = typeof expected === "string" ? Buffer.from(expected) : expected; if (!actual.equals(bytes)) throw new Error(`generated asset is stale: ${path}`); } - console.log(`RPG assets OK: ${TILE_NAMES.length} BG tiles, ${ACTOR_NAMES.length} world actors + 2 battle actors, four fixed 4bpp palettes`); + console.log(`RPG assets OK: 54 BG tiles, ${ACTOR_NAMES.length} 32x32 world actors + 2 64x64 battle actors, four fixed 4bpp palettes`); return; } for (const [path, contents] of targets) writeFileSync(path, contents); @@ -959,8 +1218,10 @@ async function build(check: boolean): Promise { console.log(`Built ${HEADER}`); } -const command = process.argv[2] ?? "check"; -if (command === "generate") await generate(process.argv.includes("--force")); -else if (command === "build") await build(false); -else if (command === "check") await build(true); -else throw new Error(`usage: bun vapor/scripts/rpg-assets.ts [--force]`); +if (import.meta.main) { + const command = process.argv[2] ?? "check"; + if (command === "generate") await generate(process.argv.includes("--force")); + else if (command === "build") await build(false); + else if (command === "check") await build(true); + else throw new Error(`usage: bun vapor/scripts/rpg-assets.ts [--force]`); +} diff --git a/vapor/tests/rpg-assets.test.ts b/vapor/tests/rpg-assets.test.ts index 2cfaae85..2252d2c4 100644 --- a/vapor/tests/rpg-assets.test.ts +++ b/vapor/tests/rpg-assets.test.ts @@ -1,11 +1,13 @@ // Deterministic art-pipeline checks for the PixelLab-backed GBA RPG assets. import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "bun"; import { describe, expect, test } from "bun:test"; import { createCanvas, loadImage } from "@napi-rs/canvas"; +import { buildBackground } from "../scripts/rpg-assets.ts"; const ROOT = join(import.meta.dir, "..", "examples", "rpg", "assets"); const SOURCE = join(ROOT, "source"); @@ -18,9 +20,15 @@ const BG = [ "74,82,99", "156,165,173", "24,74,148", "57,148,231", "33,82,41", "41,165,66", "255,214,66", "33,49,99", "239,222,148", "255,82,82", ]; -const HERO = ["24,24,33", "255,173,99", "66,132,255", "255,123,74"]; -const ELDER = ["24,24,33", "255,173,99", "148,148,148", "255,255,255"]; -const SLIME = ["24,24,33", "0,173,173", "0,90,90", "255,255,255"]; +const HERO = [ + "24,41,74", "198,107,66", "255,173,99", "33,82,173", "66,132,255", + "132,181,255", "181,66,49", "255,123,74", "239,222,148", +]; +const ELDER = [ + "24,41,74", "198,107,66", "255,173,99", "74,82,99", "115,123,140", + "156,165,173", "198,214,222", "255,255,255", "66,132,255", +]; +const SLIME = ["24,41,74", "0,90,90", "0,173,173", "57,214,198", "148,247,222", "255,255,255"]; async function rgba(path: string): Promise<{ width: number; height: number; data: Uint8ClampedArray }> { const image = await loadImage(path); @@ -43,8 +51,8 @@ describe("Vapor Quest GBA art pipeline", () => { await $`bun ${SCRIPT} check`.quiet(); expect(generatedWordCount("vp_rpg_bg_palette")).toBe(16); expect(generatedWordCount("vp_rpg_obj_palettes")).toBe(48); - expect(generatedWordCount("vp_rpg_bg_tiles")).toBe(24 * 16); - expect(generatedWordCount("vp_rpg_obj_tiles")).toBe(56 * 16); + expect(generatedWordCount("vp_rpg_bg_tiles")).toBe(54 * 16); + expect(generatedWordCount("vp_rpg_obj_tiles")).toBe(224 * 16); }); test("generation provenance is complete and contains no credential", () => { @@ -66,9 +74,9 @@ describe("Vapor Quest GBA art pipeline", () => { } }); - test("the background sheet is exactly 24 opaque 8x8 tiles in one fixed bank", async () => { + test("the background sheet combines ten 16x16 world cells with fourteen 8x8 UI tiles", async () => { const image = await rgba(join(FINAL, "background.png")); - expect([image.width, image.height]).toEqual([64, 24]); + expect([image.width, image.height]).toEqual([160, 24]); const allowed = new Set(BG); for (let at = 0; at < image.data.length; at += 4) { expect(image.data[at + 3]).toBe(255); @@ -76,9 +84,60 @@ describe("Vapor Quest GBA art pipeline", () => { } }); - test("six actor frames share a foot line and stay inside their OBJ banks", async () => { + test("each reviewed PixelLab terrain source materially changes the final atlas", async () => { + const directory = mkdtempSync(join(tmpdir(), "vapor-rpg-terrain-")); + const files = ["grass.png", "path.png", "wall.png", "water.png", "tree.png", "flower.png"]; + try { + for (const file of files) copyFileSync(join(SOURCE, file), join(directory, file)); + const baseline = await buildBackground(directory); + expect(createHash("sha256").update((await buildBackground(directory)).png).digest("hex")) + .toBe(createHash("sha256").update(baseline.png).digest("hex")); + for (const file of ["grass.png", "path.png", "wall.png", "water.png"]) { + const path = join(directory, file); + const original = readFileSync(path); + const image = await loadImage(original); + const canvas = createCanvas(16, 16); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + for (let y = 4; y < 8; y++) { + for (let x = 4; x < 8; x++) { + context.fillStyle = (x + y) % 2 === 0 ? "#000000" : "#ffffff"; + context.fillRect(x, y, 1, 1); + } + } + writeFileSync(path, canvas.toBuffer("image/png")); + const changed = await buildBackground(directory); + expect(createHash("sha256").update(changed.png).digest("hex"), file) + .not.toBe(createHash("sha256").update(baseline.png).digest("hex")); + writeFileSync(path, original); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("the review palette guide pads unused OBJ slots exactly like GBA RAM", async () => { + const image = await rgba(join(FINAL, "palette-guide.png")); + expect([image.width, image.height]).toEqual([64, 64]); + const lengths = [16, 10, 10, 7]; + for (let row = 0; row < lengths.length; row++) { + for (let index = lengths[row]; index < 16; index++) { + const at = ((row * 16 + 8) * image.width + index * 4 + 1) * 4; + expect([...image.data.slice(at, at + 4)]).toEqual([0, 0, 0, 255]); + } + } + const header = readFileSync(HEADER, "utf8"); + const match = header.match(/static const u16 vp_rpg_obj_palettes\[48\] = \{([\s\S]*?)\n\};/); + if (!match) throw new Error("missing generated OBJ palette"); + const words = [...match[1].matchAll(/0x([0-9a-f]{4})/g)].map((entry) => Number.parseInt(entry[1], 16)); + for (const [base, used] of [[0, 10], [16, 10], [32, 7]] as const) { + expect(words.slice(base + used, base + 16)).toEqual(Array(16 - used).fill(0)); + } + }); + + test("six detailed 32x32 actor frames share a foot line and stay inside their OBJ banks", async () => { const image = await rgba(join(FINAL, "actors.png")); - expect([image.width, image.height]).toEqual([96, 16]); + expect([image.width, image.height]).toEqual([192, 32]); const frameHashes: string[] = []; const frameColors: Set[] = []; for (let frame = 0; frame < 6; frame++) { @@ -87,48 +146,49 @@ describe("Vapor Quest GBA art pipeline", () => { let visible = 0; let grounded = false; const bytes: number[] = []; - for (let y = 0; y < 16; y++) { - for (let x = 0; x < 16; x++) { - const at = (y * 96 + frame * 16 + x) * 4; + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const at = (y * 192 + frame * 32 + x) * 4; const alpha = image.data[at + 3]; expect(alpha === 0 || alpha === 255).toBe(true); bytes.push(image.data[at], image.data[at + 1], image.data[at + 2], alpha); if (alpha === 0) continue; visible++; - if (y === 15) grounded = true; + if (y === 31) grounded = true; const color = `${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`; colors.add(color); expect(allowed.has(color)).toBe(true); } } - expect(visible).toBeGreaterThanOrEqual(frame === 5 ? 20 : 30); + expect(visible).toBeGreaterThanOrEqual(frame === 5 ? 220 : 280); expect(grounded).toBe(true); + expect(colors.size).toBeGreaterThanOrEqual(frame === 5 ? 5 : 6); frameHashes.push(createHash("sha256").update(Uint8Array.from(bytes)).digest("hex")); frameColors.push(colors); } expect(new Set(frameHashes.slice(0, 4)).size).toBe(4); - expect(frameColors[5]).toEqual(new Set(SLIME)); + expect(frameColors[5].has("255,255,255")).toBe(true); }); - test("battle reuses the same actors and palettes at a readable 32x32 scale", async () => { + test("battle reuses the same actors and palettes at a readable 64x64 scale", async () => { const image = await rgba(join(FINAL, "battle-actors.png")); - expect([image.width, image.height]).toEqual([64, 32]); + expect([image.width, image.height]).toEqual([128, 64]); for (let frame = 0; frame < 2; frame++) { const allowed = new Set(frame === 0 ? HERO : SLIME); let visible = 0; let grounded = false; - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - const at = (y * 64 + frame * 32 + x) * 4; + for (let y = 0; y < 64; y++) { + for (let x = 0; x < 64; x++) { + const at = (y * 128 + frame * 64 + x) * 4; const alpha = image.data[at + 3]; expect(alpha === 0 || alpha === 255).toBe(true); if (alpha === 0) continue; visible++; - if (y === 31) grounded = true; + if (y === 63) grounded = true; expect(allowed.has(`${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`)).toBe(true); } } - expect(visible).toBeGreaterThan(100); + expect(visible).toBeGreaterThan(frame === 0 ? 1000 : 900); expect(grounded).toBe(true); } }); diff --git a/vapor/tests/rpg.test.ts b/vapor/tests/rpg.test.ts index b708f904..eef3ede8 100644 --- a/vapor/tests/rpg.test.ts +++ b/vapor/tests/rpg.test.ts @@ -21,6 +21,7 @@ let source = ""; let app: CompiledApp; let runReads: Record; let holdReads: Record; +let cameraReads: Record; let repeatBoundaryX: number[]; function press(button: number): string { @@ -54,6 +55,11 @@ function littleEndianHex(words: number[]): string { return words.map((word) => `${(word & 0xff).toString(16).padStart(2, "0")}${(word >> 8).toString(16).padStart(2, "0")}`).join(""); } +function littleEndianU16(hex: string, byteOffset: number): number { + const at = byteOffset * 2; + return Number.parseInt(`${hex.slice(at + 2, at + 4)}${hex.slice(at, at + 2)}`, 16); +} + async function countPpmColors(path: string): Promise { const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); const head = new TextDecoder().decode(bytes.slice(0, 64)); @@ -94,8 +100,8 @@ beforeAll(async () => { lines.push(`S ${worldShot}`); lines.push("D world_bg1 0x06004800 2048"); lines.push("D world_oam 0x07000000 24"); - lines.push("D asset_bg_tiles 0x06008000 768"); - lines.push("D asset_obj_tiles 0x06010000 1792"); + lines.push("D asset_bg_tiles 0x06008000 1728"); + lines.push("D asset_obj_tiles 0x06010000 7168"); lines.push("D asset_bg_palette 0x050001e0 32"); lines.push("D asset_obj_palettes 0x05000200 96"); @@ -109,6 +115,7 @@ beforeAll(async () => { lines.push(press(Button.Right), press(Button.Down), press(Button.Right), press(Button.A)); readState(lines, "offer", ["mode", "playerX", "playerY", "facing", "quest", "dialog", "choice"]); lines.push(`S ${dialogShot}`); + lines.push("R dialog_dispcnt 0x04000000 2"); // Exercise NO with a long hold: repeats are world-only, so the two-choice // dialog must not toggle back and forth. Close, talk again, then accept YES. @@ -127,6 +134,7 @@ beforeAll(async () => { lines.push(`S ${battleShot}`); lines.push("D battle_bg1 0x06004800 2048"); lines.push("D battle_oam 0x07000000 24"); + lines.push("R battle_dispcnt 0x04000000 2"); // HEAL once after another long hold; battle selection also ignores repeats. // Then ATTACK three times. Victory is a reactive dialog state. @@ -176,6 +184,28 @@ beforeAll(async () => { expect(holdParsed.ok).toBe(true); holdReads = holdParsed.reads; + // Drop below the solid Slime, then walk far enough east to put the hero on + // the camera focus column. The logical map state remains global while BG1 + // and OAM become a 15x10 window. + const cameraScenario = join(OUT, "rpg-camera-tape.txt"); + const cameraLines = ["A 8", press(Button.Down), press(Button.Down)]; + for (let i = 0; i < 12; i++) cameraLines.push(press(Button.Right)); + cameraLines.push( + `R player_x 0x${stateAddress("playerX").toString(16)} 4`, + "D bg1 0x06004800 2048", + "D oam 0x07000000 16", + "R trips 0x0200000c 1", + "", + ); + await Bun.write(cameraScenario, cameraLines.join("\n")); + const cameraOutput = await $`${RUNNER} ${ROM} ${cameraScenario}`.text(); + const cameraParsed = JSON.parse(cameraOutput) as { + ok: boolean; + reads: Record; + }; + expect(cameraParsed.ok).toBe(true); + cameraReads = cameraParsed.reads; + // Host video scheduling means setKeys() and the runtime input loop do not // share a frame boundary. Adjacent holds pin the first two observed repeats. repeatBoundaryX = []; @@ -231,7 +261,7 @@ describe("Pocket Vapor RPG host", () => { expect(app.graph).toContain("mode (num)"); expect(app.graph).toContain("questActive:"); expect(app.plan).toContain("RPG host RAM: 3076 B"); - expect(app.plan).toContain("RPG art: 24 BG tiles + 6 world and 2 battle OBJ frames, 4 palette banks; 2688 B ROM"); + expect(app.plan).toContain("RPG art: 54 BG tiles + 6 32x32 world and 2 64x64 battle OBJ frames, 4 palette banks; 9024 B ROM"); }); test("pixel RPG host is explicitly GBA-only", () => { @@ -269,6 +299,15 @@ describe("native GBA RPG play tape", () => { expect(repeatBoundaryX).toEqual([3, 4, 4, 5]); }); + test("the 15x10 camera follows the player and culls off-window NPCs", () => { + expect(cameraReads.player_x).toBe(14); + const oam = cameraReads.oam as string; + expect(littleEndianU16(oam, 2) & 0x01ff).toBe(104); + expect(littleEndianU16(oam, 8) & 0x0200).toBe(0x0200); + expect(cameraReads.bg1).not.toBe(runReads.world_bg1); + expect(cameraReads.trips).toBe(0); + }); + test("collision turns without moving and A opens the elder offer", () => { expect([value("boot", "mode"), value("boot", "playerX"), value("boot", "playerY")]).toEqual([0, 2, 2]); expect([value("wall", "playerX"), value("wall", "playerY"), value("wall", "facing")]).toEqual([2, 1, 1]); @@ -323,6 +362,23 @@ describe("native GBA RPG play tape", () => { expect(runReads.world_oam).not.toBe(runReads.battle_oam); }); + test("world metatiles and enlarged OBJ sizes reach native GBA OAM", () => { + const world = runReads.world_oam as string; + const battle = runReads.battle_oam as string; + const worldAttr1 = [littleEndianU16(world, 2), littleEndianU16(world, 10)]; + const battleAttr1 = [littleEndianU16(battle, 2), littleEndianU16(battle, 10)]; + expect(worldAttr1.map((attr) => attr & 0xc000)).toEqual([0x8000, 0x8000]); + expect(battleAttr1.map((attr) => attr & 0xc000)).toEqual([0xc000, 0xc000]); + // At boot the lower elder is OAM slot zero and the hero is slot one. + // Their two-cell X distance is now 32 screen pixels, proving the 16px grid. + expect([(worldAttr1[0] & 0x01ff), (worldAttr1[1] & 0x01ff)]).toEqual([56, 24]); + }); + + test("the dialog hardware window clips enlarged actors behind screen-space UI", () => { + expect(runReads.dialog_dispcnt).toBe(0x3340); + expect(runReads.battle_dispcnt).toBe(0x1340); + }); + test("generated 4bpp art and palettes arrive in the exact GBA VRAM banks", () => { expect(runReads.asset_bg_tiles).toBe(littleEndianHex(generatedWords("vp_rpg_bg_tiles"))); expect(runReads.asset_obj_tiles).toBe(littleEndianHex(generatedWords("vp_rpg_obj_tiles"))); From 4d1f12aeff6b100b8916fd0464a8af86a91dd075 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:36:48 +0800 Subject: [PATCH 5/5] feat(vapor): animate smooth RPG walking --- vapor/DESIGN.md | 45 +- vapor/README.md | 36 +- vapor/compiler/compile.ts | 126 ++++- vapor/examples/rpg/assets/README.md | 14 +- vapor/examples/rpg/assets/final/hero-walk.png | Bin 0 -> 6040 bytes vapor/examples/rpg/assets/generation.json | 62 ++- .../rpg/assets/source/hero-walk-east-0.png | Bin 0 -> 1049 bytes .../rpg/assets/source/hero-walk-east-1.png | Bin 0 -> 995 bytes .../rpg/assets/source/hero-walk-east-2.png | Bin 0 -> 1040 bytes .../rpg/assets/source/hero-walk-east-3.png | Bin 0 -> 1085 bytes .../rpg/assets/source/hero-walk-north-0.png | Bin 0 -> 884 bytes .../rpg/assets/source/hero-walk-north-1.png | Bin 0 -> 821 bytes .../rpg/assets/source/hero-walk-north-2.png | Bin 0 -> 888 bytes .../rpg/assets/source/hero-walk-north-3.png | Bin 0 -> 871 bytes .../rpg/assets/source/hero-walk-south-0.png | Bin 0 -> 1206 bytes .../rpg/assets/source/hero-walk-south-1.png | Bin 0 -> 1262 bytes .../rpg/assets/source/hero-walk-south-2.png | Bin 0 -> 1292 bytes .../rpg/assets/source/hero-walk-south-3.png | Bin 0 -> 1315 bytes .../rpg/assets/source/hero-walk-west-0.png | Bin 0 -> 1081 bytes .../rpg/assets/source/hero-walk-west-1.png | Bin 0 -> 1045 bytes .../rpg/assets/source/hero-walk-west-2.png | Bin 0 -> 996 bytes .../rpg/assets/source/hero-walk-west-3.png | Bin 0 -> 1061 bytes vapor/examples/rpg/rpg.tsx | 77 ++- vapor/host/rpg.ts | 6 + vapor/runtime/gba/vapor_gba.c | 29 + vapor/runtime/gba/vapor_rpg.c | 303 +++++++--- .../runtime/gba/vapor_rpg_assets.generated.h | 520 +++++++++++++++++- vapor/runtime/vapor.h | 9 +- vapor/scripts/rpg-assets.ts | 309 ++++++++++- vapor/tests/compiler.test.ts | 32 ++ vapor/tests/harness/mgba_runner.c | 23 + vapor/tests/rpg-assets.test.ts | 118 +++- vapor/tests/rpg.test.ts | 236 ++++++-- 33 files changed, 1747 insertions(+), 198 deletions(-) create mode 100644 vapor/examples/rpg/assets/final/hero-walk.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-east-0.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-east-1.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-east-2.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-east-3.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-north-0.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-north-1.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-north-2.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-north-3.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-south-0.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-south-1.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-south-2.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-south-3.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-west-0.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-west-1.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-west-2.png create mode 100644 vapor/examples/rpg/assets/source/hero-walk-west-3.png diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index 5d97c002..f03dd788 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -224,40 +224,51 @@ it. Its responsibilities are split four ways: module provides real, deterministic JS lookup semantics; AOT recognizes the same calls and emits calls to fixed C helpers over the generated map. Out-of-bounds movement fails closed. -3. **Reactive gameplay — ordinary Vue Vapor.** Mode, player position and - facing, quest state, current dialogue/choice, HP and battle selection are - ordinary `ref` slots; derived gates are `computed`; `onButton` keymaps and - setup functions implement movement, interaction, dialogue and battle. +3. **Reactive gameplay — ordinary Vue Vapor.** Mode, integer player position, + facing, in-flight pixel progress, quest state, current dialogue/choice, HP + and battle selection are ordinary `ref` slots; gates are `computed` and + screen offsets are derived expressions. `onButton` keymaps, the standard + Vue Vapor `onFrame(buttons)` lifecycle, and setup functions implement movement, + interaction, dialogue and battle. A step advances by a deterministic 2px + per 60 Hz tick; the destination cell and its event commit only on arrival. They use the same dirty-bit graph and direct C lowering as Todo. There is no residual script bytecode, stack machine, hidden controller state, or second gameplay VM. 4. **Fixed presentation — ``.** The component is stateless: - every dynamic input arrives as a prop and therefore participates in the - normal dependency mask. The GBA runtime renders the tile world on BG1 and - characters through OBJ, with its fixed dialogue/battle presentation; it - does not expose GBA registers or SDK concepts to application code. - -The RPG movement contract consumes hardware-neutral `onButton` press edges -for immediate steps and a separate `onButtonRepeat` registration for held -D-pad movement. The GBA host waits 12 frames after the edge, then emits a -repeat every 6 frames. The app routes repeats only to its world keymap, so -dialogue and battle choices still advance once per physical press. + every dynamic input, including the signed player pixel offsets and walk + frame, arrives as a prop and therefore participates in the normal + dependency mask. The GBA runtime derives a fractional camera from those + props, renders a 16×11 overscan tile window on BG1 and characters through + OBJ, then commits tile/OAM/scroll/window shadows during VBlank. It does not + own a hidden motion timeline or expose GBA registers or SDK concepts to + application code. + +The RPG movement contract consumes the standard hardware-neutral button mask +through `@pocketjs/framework/vue-vapor/lifecycle`'s `onFrame`. The GBA adapter +maps its physical held keys to the shared `BTN` ABI; app code never reads GBA +key bits. Direction priority is deterministic, a current step finishes after +release, and turning, collision and arrival events remain gameplay state that +the compiler/debug tape can observe. Dialogue and battle still use +`onButton` press edges, so choices advance once per physical press. The JS `RpgScreen` currently returns no pixels, so collision/event behavior can run in the JS/oracle path but pixel-frame acceptance is mGBA-only for this POC. A browser pixel renderer, saves, audio and CJK text are follow-ups, not implied capabilities. -Input is not DOM events. The host module exposes three explicit capabilities: +Input is not DOM events. Pocket Vapor exposes four explicit capabilities: - `onButton((b: Button) => void)` for frame-latched press edges; - `onButtonRepeat((b: Button) => void)` for normalized held-D-pad repeats (currently supplied by the GBA target); +- `onFrame((buttons) => void)` from the public Vue Vapor lifecycle for the + shared held `BTN` mask (currently lowered by the GBA target); - `onAxisDelta(RelativeAxis.Primary, (delta) => void)` for signed, hardware-neutral incremental movement in canonical units. Under the oracle the module executes and the test tape feeds it; under the -compiler registrations become `app_on_button()`, `app_on_button_repeat()` -and `app_on_axis_delta(axis, delta)`. Physical hosts own normalization: +compiler registrations become `app_on_button()`, `app_on_button_repeat()`, +`app_on_frame(buttons)`, and `app_on_axis_delta(axis, delta)`. Physical hosts +own normalization: rotary adapters preserve signed motion as millidegrees, while applications own detents, acceleration, and sensitivity. Playdate forwards crank motion to Primary; a future ESP32 board can adapt an encoder without exposing pins diff --git a/vapor/README.md b/vapor/README.md index 9efe36a4..f5bb5328 100644 --- a/vapor/README.md +++ b/vapor/README.md @@ -183,21 +183,25 @@ a second gameplay language or VM: and emits fixed ROM tables. - `rpgBlocked(...)` and `rpgEventAt(...)` are pure map queries with real JS implementations for behavior/oracle use and equivalent native C helpers. -- The player, mode, facing, quest, dialogue choice, HP, and battle cursor are - ordinary `ref` state; the quest gate is an ordinary `computed`. Button - handlers and setup functions contain the actual gameplay and compile - directly to C. +- The player, mode, facing, quest, dialogue choice, HP, battle cursor, and + in-flight walking progress are ordinary `ref` state; the quest gate is an + ordinary `computed` and presentation offsets are derived expressions. + Button/frame handlers and setup functions contain the actual gameplay and + compile directly to C. - One stateless `` receives that complete reactive state. On GBA the compiler lowers it to the fixed tile/sprite RPG renderer rather than shipping Vue or JavaScript in the ROM. -- The world uses 16×16 logical metatiles with a clamped 15×10 following - camera, detailed 32×32 world actors, and 64×64 battle portraits. HUD, - dialogue, font, and command UI remain on the native 8×8 screen grid. - -The demo's ten reactive refs occupy 40 bytes. The fixed GBA host adds 3,076 -bytes of BG1 tilemap and OAM shadow buffers so all VRAM commits stay inside -vblank; map, collision, event, dialogue, tile and sprite assets remain in ROM. -The fixed four-bank art payload is 9,024 bytes. +- The world uses 16×16 logical metatiles with a pixel-scrolled, clamped 15×10 + camera, detailed 32×32 world actors, four-frame directional walk cycles, + and 64×64 battle portraits. A 16×11 overscan buffer keeps fractional camera + movement covered while HUD, dialogue, font, and command UI remain fixed on + the native 8×8 screen grid. + +The demo's eleven reactive refs occupy 44 bytes. The fixed GBA host adds 3,136 +bytes of BG1 tilemap, OAM, UI/camera-cache, and register-shadow state so VRAM, +OAM, scroll, and window commits stay inside VBlank; map, collision, event, +dialogue, tile and sprite assets remain in ROM. The fixed four-bank art payload +is 17,216 bytes, including sixteen generated directional walk frames. The demo is one complete loop: start at `(2,2)`, walk next to the solid Elder, face them and press A, choose whether to accept the quest, walk onto @@ -206,9 +210,11 @@ three attacks, then return to the Elder to complete the quest. Controls: -- World: D-pad moves one cell immediately; holding it waits 12 frames, then - continues every 6 frames. A talks to the event cell directly in front of - the player. +- World: D-pad starts a cardinal step and advances it by 2 pixels per fixed + 60 Hz frame. Holding continues seamlessly; releasing finishes the accepted + step without stopping between cells. The integer map coordinate and any + destination event commit only after all 16 pixels arrive. A talks to the + event cell directly in front of an idle player. - Dialogue: Up/Down selects YES or NO; A confirms or advances. - Battle: Up/Down selects ATTACK or HEAL; A performs the action. diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 8a7507d3..625381fb 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -17,6 +17,7 @@ // arms), which can only cause redundant repaints, never a missed one. import ts from "typescript"; +import { BTN as FRAMEWORK_BTN } from "../../contracts/spec/spec.ts"; import { FONT8 } from "./font.gen.ts"; import { rgb555, rgb565, StyleTable, type StyleIssue } from "./styles.ts"; import { BACKDROP } from "./styles.ts"; @@ -55,6 +56,18 @@ export const VAPOR_TARGETS: Record = { }; const BUTTON_NAMES = ["A", "B", "Select", "Start", "Right", "Left", "Up", "Down", "R", "L"] as const; +const FRAMEWORK_BUTTON_TO_VAPOR: Readonly> = { + CROSS: 0, + CIRCLE: 1, + SELECT: 2, + START: 3, + RIGHT: 4, + LEFT: 5, + UP: 6, + DOWN: 7, + RTRIGGER: 8, + LTRIGGER: 9, +}; const PLAYDATE_BUTTONS = new Set([0, 1, 4, 5, 6, 7]); const RELATIVE_AXIS_NAMES = ["Primary", "Secondary"] as const; const TARGET_RELATIVE_AXES: Record = { @@ -267,6 +280,7 @@ class AppCompiler { private fns: FnBinding[] = []; private handler: ts.ArrowFunction | null = null; private repeatHandler: ts.ArrowFunction | null = null; + private frameHandler: ts.ArrowFunction | null = null; private axisHandlers = new Map(); private template: ts.JsxFragment | null = null; @@ -276,6 +290,8 @@ class AppCompiler { private hostOnButtonRepeat = ""; private hostButton = ""; private hostOnAxisDelta = ""; + private frameworkOnFrame = ""; + private frameworkButton = ""; private hostDefineRpgMap = ""; private hostRpgBlocked = ""; private hostRpgEventAt = ""; @@ -382,6 +398,14 @@ class AppCompiler { else if (imported === "rpgEventAt") this.hostRpgEventAt = local; else if (imported === "RpgScreen") this.hostRpgScreen = local; else this.err(spec, `unsupported RPG host import: ${imported}`); + } else if (from === "@pocketjs/framework/vue-vapor/lifecycle") { + if (imported === "onFrame") this.frameworkOnFrame = local; + else this.err(spec, `unsupported framework lifecycle import: ${imported}`); + } else if (from === "@pocketjs/framework/vue-vapor/input") { + if (imported === "BTN") { + this.frameworkButton = local; + this.scope.set(local, { kind: "const", name: local, value: FRAMEWORK_BTN }); + } else this.err(spec, `unsupported framework input import: ${imported}`); } else this.err(stmt, `unsupported import source: ${from}`); } } @@ -632,6 +656,19 @@ class AppCompiler { const arg = call.arguments[0]; if (!arg || !ts.isArrowFunction(arg)) this.err(call, "onButtonRepeat takes an arrow"); this.repeatHandler = arg; + } else if ( + ts.isCallExpression(call) && + ts.isIdentifier(call.expression) && + call.expression.text === this.frameworkOnFrame + ) { + if (this.target.name !== "gba") + this.err(call, "onFrame is currently supported only on the gba target"); + if (this.frameHandler) this.err(call, "only one onFrame handler is supported"); + const arg = call.arguments[0]; + if (!arg || !ts.isArrowFunction(arg)) this.err(call, "onFrame takes an arrow"); + if (arg.parameters.length > 1 || (arg.parameters[0] && !ts.isIdentifier(arg.parameters[0].name))) + this.err(arg, "onFrame arrow takes zero args or one simple buttons parameter"); + this.frameHandler = arg; } else if ( ts.isCallExpression(call) && ts.isIdentifier(call.expression) && @@ -1086,6 +1123,10 @@ class AppCompiler { const sub = this.substProp(e); if (sub) return this.constNum(sub); if (ts.isNumericLiteral(e)) return Number(e.text); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) { + const operand = this.constNum(e.operand); + return operand === null ? null : -operand; + } if (ts.isIdentifier(e)) { const b = this.scope.get(e.text); if (b?.kind === "const" && typeof b.value === "number") return b.value; @@ -1098,7 +1139,15 @@ class AppCompiler { if (b?.kind === "const" && Array.isArray(b.value) && e.name.text === "length") return b.value.length; if (b?.kind === "const" && typeof b.value === "object" && !Array.isArray(b.value)) { const record = b.value as Record; - if (e.name.text in record) return record[e.name.text]; + if (e.name.text in record) { + if (e.expression.text === this.frameworkButton) { + const button = FRAMEWORK_BUTTON_TO_VAPOR[e.name.text]; + if (button === undefined) + this.err(e, `BTN.${e.name.text} has no Pocket Vapor button mapping`); + this.buttonsUsed.add(button); + } + return record[e.name.text]; + } } if (e.expression.text === this.hostButton) { const buttons: Record = { @@ -1470,6 +1519,8 @@ class AppCompiler { [K.AsteriskToken]: "*", [K.SlashToken]: "/", [K.PercentToken]: "%", + [K.AmpersandToken]: "&", + [K.BarToken]: "|", [K.LessThanToken]: "<", [K.GreaterThanToken]: ">", [K.LessThanEqualsToken]: "<=", @@ -1815,6 +1866,8 @@ class AppCompiler { decls: string[]; body: string[]; isStatic: boolean; + /** The native host owns this unit's backing rows and redraw policy. */ + skipRowClear?: boolean; } const units: Unit[] = []; @@ -1851,6 +1904,10 @@ class AppCompiler { } } + if (this.rpgScreenCount > 0 && units.length !== 1) { + this.err(this.template!, "RpgScreen owns the full native display and must be the only root render unit"); + } + // static rows paint once; overlap with a dynamic span demotes to dynamic const dynamic = units.filter((u) => !u.isStatic); const overlap = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1]; @@ -1864,6 +1921,7 @@ class AppCompiler { const hit = merged.find((m) => overlap(m.span, u.span)); if (hit) { hit.span = [Math.min(hit.span[0], u.span[0]), Math.max(hit.span[1], u.span[1])]; + hit.skipRowClear = !!hit.skipRowClear && !!u.skipRowClear; for (const d of u.deps) hit.deps.add(d); hit.decls.push(...u.decls); hit.body.push(...u.body); @@ -1888,6 +1946,7 @@ class AppCompiler { Math.min(merged[i].span[0], merged[j].span[0]), Math.max(merged[i].span[1], merged[j].span[1]), ]; + merged[i].skipRowClear = !!merged[i].skipRowClear && !!merged[j].skipRowClear; for (const d of merged[j].deps) merged[i].deps.add(d); merged[i].decls.push(...merged[j].decls); merged[i].body.push(...merged[j].body); @@ -1904,7 +1963,11 @@ class AppCompiler { const effects: { name: string; mask: number; span: [number, number] }[] = []; merged.forEach((u, i) => { const name = `eff_${i}`; - const body = [...u.decls, ` vp_row_clear(${u.span[0]}, ${u.span[1]});`, ...u.body]; + const body = [ + ...u.decls, + ...(u.skipRowClear ? [] : [` vp_row_clear(${u.span[0]}, ${u.span[1]});`]), + ...u.body, + ]; this.bodies.push(`static void ${name}(void) {\n${body.join("\n")}\n}\n`); effects.push({ name, mask: this.maskOf(u.deps), span: u.span }); }); @@ -2103,6 +2166,7 @@ class AppCompiler { decls: string[]; body: string[]; isStatic: boolean; + skipRowClear: boolean; } { if (this.target.name !== "gba") this.err(el, "RpgScreen is currently supported only on the gba target"); if (!ts.isJsxSelfClosingElement(el)) this.err(el, "RpgScreen must be a self-closing element"); @@ -2114,7 +2178,10 @@ class AppCompiler { "mode", "playerX", "playerY", + "playerOffsetX", + "playerOffsetY", "facing", + "playerFrame", "quest", "dialog", "choice", @@ -2149,7 +2216,10 @@ class AppCompiler { const mode = readNumber("mode"); const playerX = readNumber("playerX"); const playerY = readNumber("playerY"); + const playerOffsetX = readNumber("playerOffsetX"); + const playerOffsetY = readNumber("playerOffsetY"); const facing = readNumber("facing"); + const playerFrame = readNumber("playerFrame"); const quest = readNumber("quest"); const dialog = readNumber("dialog"); const choice = readNumber("choice"); @@ -2158,12 +2228,21 @@ class AppCompiler { const battleCursor = readNumber("battleCursor"); out.push( ` vp_rpg_render(&RPG_${map.name}, (u8)(${mode.c}), ${playerX.c}, ${playerY.c}, ` + - `(u8)(${facing.c}), ${quest.c}, ${dialog.c}, ${choice.c}, ${heroHp.c}, ${enemyHp.c}, ` + - `${battleCursor.c});`, + `${playerOffsetX.c}, ${playerOffsetY.c}, (u8)(${facing.c}), (u8)(${playerFrame.c}), ` + + `${quest.c}, ${dialog.c}, ${choice.c}, ${heroHp.c}, ${enemyHp.c}, ${battleCursor.c});`, ); }); this.curDeps = prev; - return { span: [0, this.target.height], deps, decls, body, isStatic: false }; + /* The RPG host maintains BG0 UI incrementally. Clearing the full character + * grid before every movement effect would erase the cached fixed HUD. */ + return { + span: [0, this.target.height], + deps, + decls, + body, + isStatic: false, + skipRowClear: true, + }; } private compileMapUnit(call: ts.CallExpression): { @@ -2325,6 +2404,31 @@ class AppCompiler { this.scope = saved; } + let frameHandlerOut: string[] = []; + if (this.frameHandler) { + const saved = new Map(this.scope); + const parameter = this.frameHandler.parameters[0]; + if (parameter) { + if (!ts.isIdentifier(parameter.name)) + this.err(this.frameHandler, "onFrame arrow needs a simple buttons parameter"); + this.scope.set(parameter.name.text, { + kind: "local", + cName: "buttons_arg", + ty: NUM, + }); + } + const frameHandlerArrow = this.frameHandler; + const { decls, body } = this.withHoist((out) => { + if (ts.isBlock(frameHandlerArrow.body)) { + for (const stmt of frameHandlerArrow.body.statements) this.compileStmt(stmt, out, " "); + } else { + this.compileExprStmt(frameHandlerArrow.body, out, " "); + } + }); + frameHandlerOut = [...decls, ...body]; + this.scope = saved; + } + const axisHandlerFns: string[] = []; const axisHandlerCases: string[] = []; for (const [axis, handler] of [...this.axisHandlers].sort(([a], [b]) => a - b)) { @@ -2488,6 +2592,13 @@ class AppCompiler { // handler c.push(`void app_on_button(u8 b) {\n s32 b_arg = (s32)b;\n${handlerOut.join("\n")}\n}\n`); + if (this.frameHandler) { + c.push( + `void app_on_frame(u32 buttons) {\n s32 buttons_arg = (s32)buttons;\n${frameHandlerOut.join("\n")}\n}\n`, + ); + } else { + c.push("void app_on_frame(u32 buttons) {\n (void)buttons;\n}\n"); + } if (this.target.name === "gba") { if (this.repeatHandler) { c.push( @@ -2588,6 +2699,7 @@ class AppCompiler { .join(", ") || "none" }`, ); + graphLines.push(` frame hook: ${this.frameHandler ? "fixed (gba)" : "none"}`); graphLines.push(` button repeats: ${this.repeatHandler ? "directional (gba)" : "none"}`); graphLines.push( ` relative axes: ${ @@ -2632,8 +2744,8 @@ class AppCompiler { planLines.push( `RPG assets: ${this.rpgMaps.length} map(s), ${tileCount} tiles + collision bytes, ${eventCount} events, ${dialogCount} dialogs; ${this.rpgScreenCount} full-screen effect(s)`, ); - planLines.push("RPG art: 54 BG tiles + 6 32x32 world and 2 64x64 battle OBJ frames, 4 palette banks; 9024 B ROM copied to VRAM"); - planLines.push("RPG host RAM: 3076 B fixed BG1 tilemap + OAM shadow buffers"); + planLines.push("RPG art: 54 BG tiles + 6 static and 16 walking 32x32 world frames + 2 64x64 battle OBJ frames, 4 palette banks; 17216 B ROM copied to VRAM"); + planLines.push("RPG host RAM: 3136 B fixed BG1 tilemap + OAM/UI/cache/register shadow state"); } return { diff --git a/vapor/examples/rpg/assets/README.md b/vapor/examples/rpg/assets/README.md index e57bd5e8..baccac3a 100644 --- a/vapor/examples/rpg/assets/README.md +++ b/vapor/examples/rpg/assets/README.md @@ -14,6 +14,9 @@ anchors and GBA palettes before emitting the reviewed sheets in `final/` and camera-followed 15x10 slice of the map. UI pieces remain screen-space 8x8. - World OBJ frames are 32x32 with a shared y=31 foot line. Battle derives readable 64x64 close-up frames from the same reviewed PixelLab sources. +- The hero has four reviewed walking frames in each cardinal direction. Each + direction shares one scale and horizontal anchor, while every contacting + foot is normalized to y=31 so movement stays grounded without sprite jitter. - The SLIME gameplay role uses a compact teal tentacled puddle-ooze silhouette; its short pseudopods, two white eyes and broad ground contact are intentional. - Generated terrain luminance clusters remain the base texture; deterministic @@ -45,10 +48,11 @@ bun run vapor:rpg:assets:check ``` Do not hand-edit the generated header. Review `background.png`, `actors.png`, -`battle-actors.png`, the palette guide, and the world/dialog/battle mGBA -captures after changing any source image. +the 4x4 `hero-walk.png` cycle sheet, `battle-actors.png`, the palette guide, +and the world/dialog/battle mGBA captures after changing any source image. Use `--only=style`, `--only=world`, `--only=tree`, `--only=flower`, -`--only=characters`, `--only=hero`, `--only=elder` or `--only=slime` with -`--force` for a targeted iteration. Without `--force`, generation only fills -missing sources. +`--only=characters`, `--only=hero`, `--only=walk`, `--only=elder` or +`--only=slime` with `--force` for a targeted iteration. The walk generator +reuses the persistent PixelLab hero and its `walking-4-frames` skeleton +template. Without `--force`, generation only fills missing sources. diff --git a/vapor/examples/rpg/assets/final/hero-walk.png b/vapor/examples/rpg/assets/final/hero-walk.png new file mode 100644 index 0000000000000000000000000000000000000000..fef336dcb437b90bcc3ec4b886abf51bb06c7f06 GIT binary patch literal 6040 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 Ty|>dr00000NkvXXu0mjfWZnE- literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-east-1.png b/vapor/examples/rpg/assets/source/hero-walk-east-1.png new file mode 100644 index 0000000000000000000000000000000000000000..2fa757effdbff12a9c1e55aba3d473019329b5b9 GIT binary patch literal 995 zcmV<9104K`P)L?ROxB(9M;N8Huz39`#V z37xjn<8kx8nTTw|%;V4Yu`B@?V~jDz7-Nhv#u#IaF~%5Uj4{R-dxt^S1-wF>86Px! zhUB@BEDBn%n}t*PL!RaElC*sP^nmgAD}d;^P%BCRKE`+Nm)wNfB2M6(BH^Ne66? zhS+Ms0;j-(H3qYw@fdCq4dm6oZhL%wF_zs&YFym7#qm!MpI6)mkqEfSxzKg%?V!g}$fIMqg>jJ2|;tro7=p`XUEtHxEg% z!81P?WzStDnED}r!`b7z)^&>*=v$9$RHLd#~247v6QiRyXKEexlSi)C18AI((A)b<^k zkX{GZ!v%n4EZ}*hGp(Rnsb~rp0RChFuI`GB$=f1ms^D~maTgF1R2l9Fm@IIO$2%;C`EjNH<^djIs>qWp*Kg2$oRmPUF zKypjJP4psQDF`Cf7M%5u7q`?DQJn&f>)?GfQ#T*GyK)PY@?Heh89b=B1WAtfWrvOO zjmBUf%^KIZzl;^I8v+*n7SRV{>W5G+Cnj?KaP)8$c2;y9xK^?*GIdl&CXW6ISw~&hQJL#*9mvX+)CCy{j=06Ok~)xeX9daO z04cpcW^Uf>yq}p2Op@L?d2;T_drknQlu}A5rIb=iDW#NBN-3q3Qc9`+C$H%Sd;{~r ze(&hG$ZHYVsFIlv!w(PMO!D1H(}Zmh(Ealc1YXbYO*f#1fqifQtHIUj~>RvWs0 z_v9bitTq6|c>w~hPr?{%dzu?=HO{)aewVo)+V6GBU_n5*AfpjytA%s?`vI%RXWPrG z(94@6;o3wr;)et7cP{Y0%ntTt;jZ$KGDI}}kTX>vAMx)8`1ga8ef`b_v&Gs5dK9b} zP2XYT{@4CYKkTs$0y`|gu3q+TpQ=+lgw5|{i$ zriTb%Us}0li}i=+SHV~3l(lcgLrCuhApyfKVkGB z8&F>_OCoj*uDBsb{VTk@z)TYWJmKL&^8tHtUYsq}+_EcUzk;?+A};-ix7F;!zm-7T z5aF`x4liTOC?d`ax_*~!5&`gspAe?Is;unQA{KaD5(4(lO%67z4V|K}p;jf@F{$NR z(?i6k;=CXOqT%VW<<~VZ`PEJOE)se$eA0{KykJ(owU{l|bc)k+rM@zf)*u7>b%jb5 zzPmpfUb84?b>)JOv}+OV%@hG&3g6j0sb7{L)h-@l^yI_TapLfB%VjTg^x_LmbQQxRP6E++YEcWWVtKPzIZ3 zrur`OTBMX|-!09=jtU3F2mJbcd$KPtc0JhXy<&+ExQ<&UOYh08c*wmz-`WS=)06B4 z_G7K+!M(xMKH!pMP&;f$B{^Wv4YKOF3Dp5dO*@TGWHb+mzt(`gpgf-=WoMT@N0; z{{_o64^21Z8;QRAM~*9nHnm+TrIb=iDW#NBN-3q3Qc5YM+UY-WKD)73KbcSf0000< KMNUMnLSTY_V*YIa literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-east-3.png b/vapor/examples/rpg/assets/source/hero-walk-east-3.png new file mode 100644 index 0000000000000000000000000000000000000000..387066f056c061e63570b598d2e1d14bf76aee2f GIT binary patch literal 1085 zcmV-D1j74?P)WX9>GB}V=1_y3!>KNC$R(51vahpE@T#%64BPG*4JH%cLiKF?3}?Mt5RN(mUJ=9uyifPFm3*um7Dy@&uE?$ul6Q5nYKyQcEE3PJ=XPGpqQqayC2sQ`SPZSi;FS|0l!(rov6BUR(8=3 ze#@YK6$t?Yd}x}r_-jrYYryZ}L4C-v@$tT|iiP(kQg0cF8xVOPEw=&}e0f{V0=vY0 zkvXVb<%D(74|qSANTT3ZZfw|biifi&QhKNKU|Ob(hqq` zw#0n=j>zvO^*FQT>OBtkSgBoz+aD?O;8)d!dpq_>wz9*8PZDqwK zUFFaI?tnRi>n^=P7V>8a7lilVW6AC5L?N$ekuwO8TfsoK(0&NB_1?#RRHW!ma-XRe zOJrl?+t~Ql-UN0%GqyKblpnAzzZXmFp_QwI=dSJ`n`M0Ihi;uh?0!h^!5$5dK}o+= z~ literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-north-0.png b/vapor/examples/rpg/assets/source/hero-walk-north-0.png new file mode 100644 index 0000000000000000000000000000000000000000..4ae4989b61a7c70b4d08775e08db8ab0cd1f5648 GIT binary patch literal 884 zcmV-)1B?8LP)XfUOI&d`kv|Pd zp$)L*>-PQL%X^RmVHYD+PLX2XVIx0J#6`p+EbPU7~6e z;}DUAdwPu6br8hMN`WYh5X=^N=w~-B%&m(f%c+!Y8r8QAz)bBf&Td=)sPtMO2^Rp` zR(s{Vbuq|4lMWvgZDZ-d27tLA+8e_Kl3>f>mdi6e2w4JNu`b*?SVE==*jzd2*`1y4 z5sv(w@3C(T0h_7aMG!9$%oaP{$hsKpe2;x&2>6d-7$)cf4o7}IBdEi=7$6+^`7DD4 zUBE!SkgbbB9xkKzO;=!@EU+xR-r)fNAc&Xw>+%YZt@i>%J-ByxkOx5~pLE8~;C5}{g?|nJu&)j5?$c*jaMh*| zE&zC*kH^r&bjUYo(FU+x9O^Ejcpe3&!G<3^Xl#K)-C@x zP!ADxE&OwgL#}^5p$m9?*^35QKrVK10^Fec#;*{XN(-N)>72;fVtd!!2wia9Vq7BB$V-DDj8_-Trt-}*0q z08gvA6iv7QS!GaK$-QRTTO6O+dM`j$nN*(e_0;5Z+fsPuxd$I(z;r`ICYe!8;DKva zP#0ssEdZu{l04jwENAUzk8H2Fw_7|`u1E8+iRR;VWn_bOo9g-XXg+@FOQ1mYK&Zh0 z$qeE#^zxG|za$q?Ri>tOz4a}(Nkl|ML_|bHL_|bHL_|dXE58A=a&;lYNA|fIpA|fIpA|fIpA|k@TY>tcZ;oKcKz+@bF?z*|p zF{+%q13Aod*M-w-#lHv#!A^tZb5(zg%J(rqI3C6sY({Pax78~EQFvLZ{x}snBer+k z0Du{Guxd1N16+nn)SO!Uv(sy#I-g_wIDBxpz{(1nAE(t&std$HH*p`gi z%^g5AE~XM;%&-F>J6s?N)gJroYZ4bh3M>Ja7Raqz$H;Azdd^d@1Pt@73Cw<rzk3GK(1kj!jo1rK627pzkNjY;Z+JJ%l(9i=d5br-@Ha=dHU~7~99m*l;c1kM1=rf_AELUg#hi z7v&7jk`-oNyG778APJdm6>*ZRS$=NsDqu#`0Y7~8&fSHRQPin~)Do*`?FCXLf?{k; z7rFSadXj24*-Nw|D8dEEOT;VDgbQ%ZU_6H1`0;l4dw2A8iPAt7F2L2Hc#|=i13#ZU zeEu@Rq<^@JwtTQ{Ff6tX?d;_GUevwK=3%GdeqCk_n5skosPw(c&Prl0NE;y44V^>S z0tTWG;{X|VXD`7RaGuo1fA%B#7TWQ$|IL5_YrutSsSWl|$Q$s*u7m%EetaMJ6&sN^ zLwD6JDDNV7spAn55fKp)5fKp)5fKp)k^j;^FjN}(^Z}k{00000NkvXXu0mjfZB}@I literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-north-2.png b/vapor/examples/rpg/assets/source/hero-walk-north-2.png new file mode 100644 index 0000000000000000000000000000000000000000..3a16f3acf3d055f70dc7ff789263026a12849769 GIT binary patch literal 888 zcmV-;1Bd*HP)yIu7zOah&570(M`h|FspDj1>cXE8SsdpMi7bwj*mJ}Mi7c`*4rJ>RSKOW;q)8em zwA|&j^L{fKGGxPS9$z+_03aeFA|fIpA|fIpA|fIpBJw7aE}zP`x;t=;`&$S2WFfN# zY|2X-F3c~ta(gYkJizz5J5a%-AtLcNuhA$dG>DhuY&{wU`0;xVY(j4Qr#>Ext%5g| zdVPt+cV7hy0JFs-qEV1-v-@5la5hZexM|rgqOdyHtvHI&w3~(Z?7oMKhX*ve6M*ZO zXe!u*P8(6^?rac8G2$q$+!l=jB<*&7e5Sz?aAsHaHtlA%?6OGK2%R=S6f#4==Kj`! z+qpd12Qr1s5b&2)cfcmCHY}3$hd(SoMzd6L-2oRwqX2f_J9uIoSnph%(T59=1l)0~%J+Aj_KReVMY5(H7V;#4$L+0uQNCJ+d7>i`RH>_>2`8Mp~0we(gFJa|h_uU?UhHwFn2P4^B_>^wu z4*~lD2!N-00g_InPBK(5g$odMBoawjwx+%%|u zU1kc{R0U~yQ#?l#JVmot?^kh#2J-<=$V>reCX?broO9cUW%~#1>$HLSzv$FEG0cNM zto(&Ap*W3Fre(W$ie@`c|9zgLNw%I2Cltp_*pj-NPkBjD2k76AD z5<>(tstUgT`Tl1T-9NY4`exeJ`*?>=fU<(ObKGOh2MsvwO}I=Kz1qj9l2>6 zUxqU@aN5A&rUCFB8x>=DQ`55DgKm{uwp(sXgK~)2I=6jT-ZWpujoeb$cK&iBLRGo6 z0sH>pe?IBmg-5lQ^i+?RV`F(!V|ml^shsq&!$gQIixso`LmBnq{#ueGR;1+jRkQ*)# zC?B2X&^gacrnE_zo5R~THz9zCh=_=Yh=_=Yh=_=Yh=|BeOuC$mZ$%GaU&G)MIH8ce zq6e_WT8K}bAbWlN@R+v`-SK=cdH@?3^Roc}n6~FXR^n2yv$LEY}PmC>M z9`02Y({^JmSO7@k1o3^8tt%Xm*Q4H}{QQjZbOwNDzRVD?Ny9kW^m6__Zm%vHL%;xl z@pOjqbcXmo%1&7O@NdF*XB$PJ3D~u7$Zohy)eDdY3@l~^Ht2e}l+7~i>w39F9WFq0 zp<%hmVF}o4zaD=d!eXgjfMgJkqgw@2xB$^WRvZ*G zTetw(U>w<9u=j#aL$z3shl=SAjeY*%kcA4>JQ(wE0nESQIP=?>L@m_9LGH$rs09EB zy7PVh{%+ze9_c%|EwX_7{lz_oe{mmg@n~)1S~x(vy?ocLciY5UJfb-+nt>n<*9~?T zs{sSRUDE?dj*Vp=3;=tFd+0u&i7H@V(O37u95P+NUk%$VVsEzM@6sZ+GYS?ICjy57 zuyI3u@s2=YAtw*Sl?I(=}aae2_E04FKp44PLudOrjQ5xBzjB zc#qpUI=_1GF8cOYns5QK!8o#py3&iiylot;$3w@|!ht#W8|JAKnB=8Ld9QaDte^O% xFj>dD(Mg}=L_|bHL_|bHL_|bHL_|bR+keN4G_IxWv{L{8002ovPDHLkV1g%VnAHFP literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-south-0.png b/vapor/examples/rpg/assets/source/hero-walk-south-0.png new file mode 100644 index 0000000000000000000000000000000000000000..f0ca3c7170ffafd3d2591c30d678db9d1ad1a359 GIT binary patch literal 1206 zcmV;n1WEgeP)kOli(|OesIgCO8-_fG(Nm4L&IZGH14k+)}U8CpDE?xL- z_Of+2qhreXN}R}l?mK`-Y>~t?b;IUUM!317sT;y5q~OV|zI)w1xG6b`QtB)w9j_^X zNo6cTME+yn0pO*DFbWwM)9Z9809sLf_qw*Pb;)2+_S$&60{JaSaWG*NG9DZdoGq|< z2FdW*9PB$fj6%lAkYyAS+?3cnBZXdy**E)kaOd8pZaR@Ram;=7(7D&ScAN}%F0hGX z#)AWvvxMMm0i(A1kmBNf3%o-)U$H!2(9{j5^QzZ(Up;iklgiN44fTgR0HS}U0IkS^ z5F_w&HLkr(nN$XwXPuyKtm|v)hVp*Rz?iP02&1rc5Ta9(sM0fn=c=Gu3gR&yPPE=94rA_BylH_ z+m52t4ySofUGPc)={;T+Y6iE|;eJ+p2 zA4V^pkgbpT>s_%eE?1KPgxs4r2X8Z}43o4ufHX}xGfk?a{zJTg0Ns{7tDdD*YDrE z#vrNo^%0dKXxE0tJca!VqzMePC6}AzD$`D8uGaN&sX;R0ep0-?ju7}``~@Fre_M5k z3+NI19pXLrMTol}aRGU33n)2OMNt$*Q4~c{6h%=KMNt$*Q4~c{6h%=KMftV-3m51v UvEi6s?*IS*07*qoM6N<$f=IAGNB{r; literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-south-1.png b/vapor/examples/rpg/assets/source/hero-walk-south-1.png new file mode 100644 index 0000000000000000000000000000000000000000..9454e4eac5a24a131261a909314e176c9a4f688d GIT binary patch literal 1262 zcmVfls z+7tK=<^^L!uR-eM3_u!%y^BdbVmqpP*JgQXn}i1o2qe^JB`Lny&IaU zp{W`G;>E=IWjo$phjugc%AUWzy2ug)cEr;Wv%Dl;OgM(26B1g>wbtneK5sX<>pO-a zW|xR@3thwr><519OLqy!FvQztu5Xr?G*#1Hn~Zwb0jf_AK;KnJL5#p}H#un(l5Gn1 z@3+o+XL-qf5uL5GsC%296`!JZu0j%0q6pHc-TAx1UGjMRr+3~Q*7&z~?dJd<>#xSj z*Fua1!&&56USgIp0A?98jm7|O!?S+czrDlk+HB9>5NUo`jKEe{XhqE)Ys@m{n6yH7 z#cvL49`nzwwHCFz8Qt$v&X_a`ds&-V#=T6fIjoJ@CB4k8+i&~qcXY>IWkmup&0*b3 zdxt@6#`9fIM6-Zi#F=86*wtK5qRL=-cipX+Z1?ZN%652EH9z>Sa(xkj$ufW%*jjG{k8$< zB_)Fo83?JUiI_wOR|~e;+)LQM-@17Dl;Me4mzarkcv5ix7Tz|q7Hbi;CQ*|);Wj55 zn%P*_TC`20t+3V)=l27Uxh7G#=%E|FU7mN_(aM-rYD9)+QZ_k@8P9i&C-dILGzxnu zaJM}SV(jTE6{Rg8s1^aIFb&%9D>ck!?bDt{VXx>8F!gR2J;2?^^oP&$HAu2x^Cn10 zlC$+%-)d9esdwX!?#8zMW5)e#fW!=0lE6>+`>&aM?5ExhOhzuz7zVMk-Y-?gFo^kF zJX~|S&mzi2bh)2B6ATnJUQs_gu1aIPGGic2W3d9SQp5dh-~_y_`Z4jB>K)td1JTOZ z@J!s>3=k}^71j!EQ)i2RveYPq9Pn@NY?r7wSggRqAm&ST-pJfLy2;2Db}q(>64oM0 z;MPjp;>~gngAdR6;*Pl8h=Vr45GS(eC<0m+5v@$`ecWx8oXeELk#@(8tvh6!!o|O< z)bwIpo4MuXxqY&9Y3q?UA-+>f7|(Z?t^rViUyJG18!mp5YT#UVu$HZrwAbZg~ Y0uupToS>{`!vFvP07*qoM6N<$f_D#IL;wH) literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-south-2.png b/vapor/examples/rpg/assets/source/hero-walk-south-2.png new file mode 100644 index 0000000000000000000000000000000000000000..f5303cfc005025cb5ece57b773d7698c4596ad22 GIT binary patch literal 1292 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 Z{{qf)BTzR9s*V5v002ovPDHLkV1kCdbtC`) literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-west-0.png b/vapor/examples/rpg/assets/source/hero-walk-west-0.png new file mode 100644 index 0000000000000000000000000000000000000000..4b0af6934c619ab8cf084abea70989ad818277a6 GIT binary patch literal 1081 zcmV-91jhS`P)- zlHo_PIp>|XT;98%b2x!v2s7VYzH?^+AP9mW2!bF8f*=TjAP9mW2!bF8f*=Tj@ZZGh zx&g1CjeV=7tPZ>Wuxro(4FHjHG5Hd}4r5q<#h|i*GibeN@|4gnn|`rrNDDerZllmo zQf%g`QZ}})un)ZQc<||lb~*R26Li}aDYvm7DOYdiN(0cFxhl7OyVUu1Xe*NZI#rwCu;R!4Nn!Gg6@^av!y!R!EVa*B)F-6WE*BGl&YsNw3S^jUg z|0G-l23n@V-uNT*{4=as2)znm%|ZZZ_%Vq~gPKW~;sU$`E}=-K+1b$Xuy>1;j09^- z$?9}1F2K-(afsx6yM*J%*j$HLt3TLW7tw3l2tne;&%3gWgSY^%G|fck+a-WvU18{W zc*)|5O|-33yhaxj4vrDr;N~GJZGwac3c+aO3QG@e97XPEx+=?l zt!MS?SoDoWBhJt&u3BhN@x5?x1SB%6KC#at2#VUF<3YK=^3KOg7FVM9fCe*> zxuXH-q2uXAr1c`wdgyqS|;Zyq85g(a94!JR$`44@De0MtU` z=T{%fjT&`Y2D;ASI?-sbh%^>a5xFl}j4(Vdy8q6?HZNvDZv?JmMl0C|M|vsn(DAT{ zj$@e_&D`jX2NUGQbvXJj&_jV2k;c7mVR`2t)`)r2JAvw+&OD_ekFUGgMZp8(5kJK47rDmt>0u1}Zu0gM$>-n#d zp0VspFD(K%PA-foGOJZIqIYT9@~%k9`j7(yOdqQ=XuS%6AG^wXXaJx8;Oekz&;^{b zf|5ClOG1VIo4K@bE%5ClOG1VIo4fj55xz7?UK>=-P(00000NkvXXu0mjf62bev literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/assets/source/hero-walk-west-1.png b/vapor/examples/rpg/assets/source/hero-walk-west-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6efd40800f032881e597100434eeb832a37c59fa GIT binary patch literal 1045 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 literal 0 HcmV?d00001 diff --git a/vapor/examples/rpg/rpg.tsx b/vapor/examples/rpg/rpg.tsx index 8108a3cd..e27cb93a 100644 --- a/vapor/examples/rpg/rpg.tsx +++ b/vapor/examples/rpg/rpg.tsx @@ -12,7 +12,9 @@ // Right, A to complete the quest. import { computed, ref } from "vue"; -import { Button, onButton, onButtonRepeat } from "../../host/input.ts"; +import { onFrame } from "@pocketjs/framework/vue-vapor/lifecycle"; +import { BTN } from "@pocketjs/framework/vue-vapor/input"; +import { Button, onButton } from "../../host/input.ts"; import { defineRpgMap, RpgScreen, @@ -117,7 +119,7 @@ const RPG_MAP = defineRpgMap({ }); export default () => { - // Ten refs: comfortably inside Pocket Vapor's current 16-ref budget. + // Eleven refs: comfortably inside Pocket Vapor's current 16-ref budget. const mode = ref(0); const playerX = ref(2); const playerY = ref(2); @@ -128,6 +130,9 @@ export default () => { const heroHp = ref(30); const enemyHp = ref(18); const battleCursor = ref(0); + // -1 is idle; a live cardinal step advances 0, 2, ... 14 pixels before + // committing its destination cell on the following fixed semantic frame. + const walkPx = ref(-1); const questActive = computed(() => quest.value === Quest.Accepted); @@ -167,18 +172,43 @@ export default () => { } } - function movePlayer(dx: number, dy: number, nextFacing: number) { + function beginMove(dx: number, dy: number, nextFacing: number) { + if (walkPx.value >= 0) return; facing.value = nextFacing; const nextX = playerX.value + dx; const nextY = playerY.value + dy; if (!rpgBlocked(RPG_MAP, nextX, nextY)) { - playerX.value = nextX; - playerY.value = nextY; - const event = rpgEventAt(RPG_MAP, nextX, nextY); - if (event !== Event.None) handleEvent(event); + walkPx.value = 0; } } + function advanceMove() { + if (walkPx.value < 0) return; + if (walkPx.value < 14) { + walkPx.value += 2; + return; + } + + if (facing.value === Facing.Up) playerY.value -= 1; + else if (facing.value === Facing.Down) playerY.value += 1; + else if (facing.value === Facing.Left) playerX.value -= 1; + else playerX.value += 1; + walkPx.value = -1; + + const event = rpgEventAt(RPG_MAP, playerX.value, playerY.value); + if (event !== Event.None) handleEvent(event); + } + + function tickWorld(buttons: number) { + advanceMove(); + if (mode.value !== Mode.World || walkPx.value >= 0) return; + + if (buttons & BTN.UP) beginMove(0, -1, Facing.Up); + else if (buttons & BTN.DOWN) beginMove(0, 1, Facing.Down); + else if (buttons & BTN.LEFT) beginMove(-1, 0, Facing.Left); + else if (buttons & BTN.RIGHT) beginMove(1, 0, Facing.Right); + } + function moveChoice(delta: number) { if (dialog.value === Dialog.Offer) { choice.value = (choice.value + delta + 2) % 2; @@ -186,6 +216,7 @@ export default () => { } function interact() { + if (walkPx.value >= 0) return; if (facing.value === Facing.Up) { handleEvent(rpgEventAt(RPG_MAP, playerX.value, playerY.value - 1)); } else if (facing.value === Facing.Down) { @@ -236,10 +267,6 @@ export default () => { } const worldKeys: Keymap = { - [Button.Up]: () => movePlayer(0, -1, Facing.Up), - [Button.Down]: () => movePlayer(0, 1, Facing.Down), - [Button.Left]: () => movePlayer(-1, 0, Facing.Left), - [Button.Right]: () => movePlayer(1, 0, Facing.Right), [Button.A]: interact, }; @@ -271,12 +298,9 @@ export default () => { : worldKeys)[button]?.(), ); - // The GBA host normalizes held D-pad input into delayed repeat events. - // Only the world consumes them: dialogue and battle remain one choice per - // physical press while map movement continues at a predictable cadence. - onButtonRepeat((button) => { - if (mode.value === Mode.World) worldKeys[button]?.(); - }); + // Movement advances on fixed semantic frames using the framework's shared + // held-button mask. Menus still consume only physical press edges above. + onFrame((buttons) => tickWorld(buttons)); return ( <> @@ -285,7 +309,26 @@ export default () => { mode={mode.value} playerX={playerX.value} playerY={playerY.value} + playerOffsetX={ + walkPx.value < 0 + ? 0 + : facing.value === Facing.Left + ? -walkPx.value + : facing.value === Facing.Right + ? walkPx.value + : 0 + } + playerOffsetY={ + walkPx.value < 0 + ? 0 + : facing.value === Facing.Up + ? -walkPx.value + : facing.value === Facing.Down + ? walkPx.value + : 0 + } facing={facing.value} + playerFrame={walkPx.value < 0 ? 0 : 1 + Math.trunc(walkPx.value / 4)} quest={quest.value} dialog={dialog.value} choice={choice.value} diff --git a/vapor/host/rpg.ts b/vapor/host/rpg.ts index d501f5b8..caaadb6b 100644 --- a/vapor/host/rpg.ts +++ b/vapor/host/rpg.ts @@ -31,7 +31,13 @@ export interface RpgScreenProps { mode: number; playerX: number; playerY: number; + /** Signed sub-cell presentation offset in world pixels. */ + playerOffsetX: number; + /** Signed sub-cell presentation offset in world pixels. */ + playerOffsetY: number; facing: number; + /** Zero selects the facing idle frame; 1..4 select walking frames. */ + playerFrame: number; quest: number; dialog: number; choice: number; diff --git a/vapor/runtime/gba/vapor_gba.c b/vapor/runtime/gba/vapor_gba.c index a8efd618..c9243640 100644 --- a/vapor/runtime/gba/vapor_gba.c +++ b/vapor/runtime/gba/vapor_gba.c @@ -17,6 +17,16 @@ #define VP_DPAD_MASK 0x00f0 #define VP_REPEAT_DELAY_FRAMES 12 #define VP_REPEAT_INTERVAL_FRAMES 6 +#define VP_BTN_SELECT 0x0001u +#define VP_BTN_START 0x0008u +#define VP_BTN_UP 0x0010u +#define VP_BTN_RIGHT 0x0020u +#define VP_BTN_DOWN 0x0040u +#define VP_BTN_LEFT 0x0080u +#define VP_BTN_LTRIGGER 0x0100u +#define VP_BTN_RTRIGGER 0x0200u +#define VP_BTN_CIRCLE 0x2000u +#define VP_BTN_CROSS 0x4000u #define PAL_BG ((volatile u16 *)0x05000000) #define VRAM ((volatile u16 *)0x06000000) #define SB_MAP 8 @@ -91,6 +101,24 @@ static void vsync(void) { while (REG_VCOUNT < 160) {} } +/* Translate GBA KEYINPUT positions to the public PocketJS BTN contract used + * by PocketJS framework lifecycle callbacks. Unsupported PSP face buttons + * remain clear; GBA A/B are the primary CROSS/CIRCLE actions. */ +static u32 framework_buttons(u16 held) { + u32 buttons = 0; + if (held & (1 << 0)) buttons |= VP_BTN_CROSS; + if (held & (1 << 1)) buttons |= VP_BTN_CIRCLE; + if (held & (1 << 2)) buttons |= VP_BTN_SELECT; + if (held & (1 << 3)) buttons |= VP_BTN_START; + if (held & (1 << 4)) buttons |= VP_BTN_RIGHT; + if (held & (1 << 5)) buttons |= VP_BTN_LEFT; + if (held & (1 << 6)) buttons |= VP_BTN_UP; + if (held & (1 << 7)) buttons |= VP_BTN_DOWN; + if (held & (1 << 8)) buttons |= VP_BTN_RTRIGGER; + if (held & (1 << 9)) buttons |= VP_BTN_LTRIGGER; + return buttons; +} + int main(void) { u16 i; u16 prev_keys = 0x03ff; @@ -149,6 +177,7 @@ int main(void) { repeat_frames[b] = 0; } } + app_on_frame(framework_buttons(held)); if (app_flush()) flushes++; } } diff --git a/vapor/runtime/gba/vapor_rpg.c b/vapor/runtime/gba/vapor_rpg.c index 7db3cfaf..df9e9863 100644 --- a/vapor/runtime/gba/vapor_rpg.c +++ b/vapor/runtime/gba/vapor_rpg.c @@ -8,6 +8,7 @@ #include "vapor_rpg_assets.generated.h" #define REG16(addr) (*(volatile u16 *)(addr)) +#define REG32(addr) (*(volatile u32 *)(addr)) #define REG_DISPCNT REG16(0x04000000) #define REG_BG1CNT REG16(0x0400000a) #define REG_BG1HOFS REG16(0x04000014) @@ -16,6 +17,10 @@ #define REG_WIN0V REG16(0x04000044) #define REG_WININ REG16(0x04000048) #define REG_WINOUT REG16(0x0400004a) +#define REG_DMA3SAD REG32(0x040000d4) +#define REG_DMA3DAD REG32(0x040000d8) +#define REG_DMA3CNT_L REG16(0x040000dc) +#define REG_DMA3CNT_H REG16(0x040000de) #define PAL_BG ((volatile u16 *)0x05000000) #define PAL_OBJ ((volatile u16 *)0x05000200) @@ -24,6 +29,9 @@ #define RPG_BG_MAP ((volatile u16 *)0x06004800) /* screenblock 9 */ #define RPG_OBJ_VRAM ((volatile u16 *)0x06010000) #define OAM ((volatile u16 *)0x07000000) +/* Presentation receipt after the shared grid debug mirrors (0x20005b8 end). */ +#define RPG_DEBUG_SCROLL_X (*(volatile u16 *)0x020005c0) +#define RPG_DEBUG_SCROLL_Y (*(volatile u16 *)0x020005c2) #define RPG_BG_BANK 15 #define RPG_ENTRY(tile) ((u16)((tile) | (RPG_BG_BANK << 12))) @@ -31,6 +39,8 @@ #define RPG_WORLD_CELL_PX 16 #define RPG_VIEW_W 15 #define RPG_VIEW_H 10 +#define RPG_BUFFER_W 16 +#define RPG_BUFFER_H 11 #define RPG_FOCUS_X 7 #define RPG_FOCUS_Y 4 @@ -70,13 +80,76 @@ typedef char vp_rpg_bg_tile_count_must_match[ TILE_COUNT == VP_RPG_BG_TILE_COUNT ? 1 : -1]; typedef char vp_rpg_world_tile_count_must_match[ WORLD_TILE_COUNT == VP_RPG_WORLD_TILE_FRAME_COUNT ? 1 : -1]; +typedef char vp_rpg_walk_direction_count_must_match[ + VP_RPG_WORLD_WALK_DIRECTION_COUNT == 4 ? 1 : -1]; +typedef char vp_rpg_walk_frame_count_must_match[ + VP_RPG_WORLD_WALK_FRAMES == 4 ? 1 : -1]; static u16 rpg_bg_shadow[32 * 32]; static u16 rpg_oam_shadow[128 * 4]; +static const vp_rpg_map *rpg_world_cache_map; +static s32 rpg_world_cache_x; +static s32 rpg_world_cache_y; +static u16 rpg_dispcnt_shadow; +static u16 rpg_bg1_hofs_shadow; +static u16 rpg_bg1_vofs_shadow; +static u16 rpg_win0h_shadow; +static u16 rpg_win0v_shadow; +static u16 rpg_winin_shadow; +static u16 rpg_winout_shadow; +static u16 rpg_dma_fill_value; +static s32 rpg_ui_mode; +static s32 rpg_ui_quest; +static s32 rpg_ui_dialog; +static s32 rpg_ui_choice; +static s32 rpg_ui_hero_hp; +static s32 rpg_ui_enemy_hp; +static s32 rpg_ui_battle_cursor; static u8 rpg_bg_dirty; static u8 rpg_oam_dirty; +static u8 rpg_oam_visible_slots; +static u8 rpg_oam_commit_slots; +static u8 rpg_registers_dirty; +static u8 rpg_world_cache_valid; +static u8 rpg_ui_cache_valid; static u8 rpg_ready; +static void stage_video(u16 dispcnt, u16 win0h, u16 win0v, + u16 winin, u16 winout) { + rpg_dispcnt_shadow = dispcnt; + rpg_win0h_shadow = win0h; + rpg_win0v_shadow = win0v; + rpg_winin_shadow = winin; + rpg_winout_shadow = winout; + rpg_registers_dirty = 1; +} + +static void stage_scroll(u16 x, u16 y) { + rpg_bg1_hofs_shadow = x; + rpg_bg1_vofs_shadow = y; + rpg_registers_dirty = 1; +} + +static void dma3_copy16(const void *source, volatile void *target, + u16 halfwords) { + if (!halfwords) return; + REG_DMA3CNT_H = 0; + REG_DMA3SAD = (u32)source; + REG_DMA3DAD = (u32)target; + REG_DMA3CNT_L = halfwords; + REG_DMA3CNT_H = 0x8000; /* immediate, incrementing, 16-bit transfer */ +} + +static void dma3_fill16(u16 value, void *target, u16 halfwords) { + if (!halfwords) return; + rpg_dma_fill_value = value; + REG_DMA3CNT_H = 0; + REG_DMA3SAD = (u32)&rpg_dma_fill_value; + REG_DMA3DAD = (u32)target; + REG_DMA3CNT_L = halfwords; + REG_DMA3CNT_H = 0x8100; /* immediate, fixed source, 16-bit transfer */ +} + static void upload_transparent_font(void) { volatile u16 *dst = FONT_VRAM + 16; /* tile zero remains transparent */ u16 i; @@ -97,7 +170,7 @@ static void upload_transparent_font(void) { } } -static void hide_objects(void) { +static void reset_objects(void) { u16 i; for (i = 0; i < 128; i++) { rpg_oam_shadow[i * 4] = 0x0200; @@ -105,6 +178,26 @@ static void hide_objects(void) { rpg_oam_shadow[i * 4 + 2] = 0; rpg_oam_shadow[i * 4 + 3] = 0; } + rpg_oam_visible_slots = 0; + rpg_oam_commit_slots = 128; + rpg_oam_dirty = 1; +} + +static void finish_objects(u8 visible) { + u8 i; + u8 previous = rpg_oam_visible_slots; + for (i = visible; i < previous; i++) { + u16 at = (u16)i * 4; + rpg_oam_shadow[at] = 0x0200; + rpg_oam_shadow[at + 1] = 0; + rpg_oam_shadow[at + 2] = 0; + rpg_oam_shadow[at + 3] = 0; + } + { + u8 required = visible > previous ? visible : previous; + if (required > rpg_oam_commit_slots) rpg_oam_commit_slots = required; + } + rpg_oam_visible_slots = visible; rpg_oam_dirty = 1; } @@ -129,15 +222,16 @@ static void show_object_64(u8 slot, s16 x, s16 y, u16 tile, u8 palette) { } static void map_fill(u8 tile) { - u16 i; u16 entry = RPG_ENTRY(tile); - for (i = 0; i < 32 * 32; i++) rpg_bg_shadow[i] = entry; + dma3_fill16(entry, rpg_bg_shadow, 32 * 32); rpg_bg_dirty = 1; } static void map_cell(u8 x, u8 y, u8 tile) { - if (x < 32 && y < 32) + if (x < 32 && y < 32) { rpg_bg_shadow[(u16)y * 32 + x] = RPG_ENTRY(tile); + rpg_bg_dirty = 1; + } } static void map_world_cell(u8 x, u8 y, u8 frame) { @@ -199,96 +293,122 @@ static void draw_box(u8 left, u8 top, u8 right, u8 bottom) { } } -static s32 camera_axis(s32 player, s32 map_size, s32 view_size, - s32 focus) { +static s32 camera_axis_px(s32 player_px, s32 map_size, s32 view_size, + s32 focus) { s32 camera; s32 maximum; if (map_size <= view_size) return 0; - maximum = map_size - view_size; - camera = player - focus; + maximum = (map_size - view_size) * RPG_WORLD_CELL_PX; + camera = player_px - focus * RPG_WORLD_CELL_PX; if (camera < 0) return 0; if (camera > maximum) return maximum; return camera; } static void draw_world(const vp_rpg_map *map, s32 player_x, s32 player_y, - u8 facing, s32 quest, u8 hud) { + s32 player_offset_x, s32 player_offset_y, + u8 facing, u8 player_frame, s32 quest, u8 hud) { u8 x, y, ox, oy, slot; - s32 cam_x, cam_y, world_y, end_y; - map_fill(0); - hide_objects(); - if (!map || !map->tiles || !map->width || !map->height) return; - cam_x = camera_axis(player_x, map->width, RPG_VIEW_W, RPG_FOCUS_X); - cam_y = camera_axis(player_y, map->height, RPG_VIEW_H, RPG_FOCUS_Y); + s32 cam_px, cam_py, cam_x, cam_y, world_y, end_y; + s32 player_px, player_py, player_sort_y; + if (!map || !map->tiles || !map->width || !map->height) { + finish_objects(0); + return; + } + if (player_offset_x < -15) player_offset_x = -15; + if (player_offset_x > 15) player_offset_x = 15; + if (player_offset_y < -15) player_offset_y = -15; + if (player_offset_y > 15) player_offset_y = 15; + player_px = player_x * RPG_WORLD_CELL_PX + player_offset_x; + player_py = player_y * RPG_WORLD_CELL_PX + player_offset_y; + cam_px = camera_axis_px(player_px, map->width, RPG_VIEW_W, RPG_FOCUS_X); + cam_py = camera_axis_px(player_py, map->height, RPG_VIEW_H, RPG_FOCUS_Y); + cam_x = cam_px / RPG_WORLD_CELL_PX; + cam_y = cam_py / RPG_WORLD_CELL_PX; + stage_scroll((u16)(cam_px - cam_x * RPG_WORLD_CELL_PX), + (u16)(cam_py - cam_y * RPG_WORLD_CELL_PX)); ox = map->width < RPG_VIEW_W ? (u8)((RPG_VIEW_W - map->width) >> 1) : 0; oy = map->height < RPG_VIEW_H ? (u8)((RPG_VIEW_H - map->height) >> 1) : 0; - for (y = (u8)cam_y; y < map->height; y++) { - u8 screen_y = (u8)(oy + y - cam_y); - if (screen_y >= RPG_VIEW_H) break; - for (x = (u8)cam_x; x < map->width; x++) { - u8 screen_x = (u8)(ox + x - cam_x); - u16 at; - if (screen_x >= RPG_VIEW_W) break; - at = (u16)y * map->width + x; - map_world_cell((u8)(screen_x * 2), (u8)(screen_y * 2), - world_tile(map->tiles[at], x, y)); + if (!rpg_world_cache_valid || rpg_world_cache_map != map || + rpg_world_cache_x != cam_x || rpg_world_cache_y != cam_y) { + map_fill(0); + for (y = (u8)cam_y; y < map->height; y++) { + u8 screen_y = (u8)(oy + y - cam_y); + if (screen_y >= RPG_BUFFER_H) break; + for (x = (u8)cam_x; x < map->width; x++) { + u8 screen_x = (u8)(ox + x - cam_x); + u16 at; + if (screen_x >= RPG_BUFFER_W) break; + at = (u16)y * map->width + x; + map_world_cell((u8)(screen_x * 2), (u8)(screen_y * 2), + world_tile(map->tiles[at], x, y)); + } } + rpg_world_cache_map = map; + rpg_world_cache_x = cam_x; + rpg_world_cache_y = cam_y; + rpg_world_cache_valid = 1; } slot = 0; - end_y = cam_y + RPG_VIEW_H - oy; + end_y = cam_y + RPG_BUFFER_H - oy; if (end_y > map->height) end_y = map->height; + player_sort_y = player_py / RPG_WORLD_CELL_PX; for (world_y = end_y; world_y > cam_y && slot < 127;) { - s16 screen_y; + s16 actor_y; world_y--; - screen_y = (s16)(oy + world_y - cam_y); + actor_y = (s16)(oy * RPG_WORLD_CELL_PX + + world_y * RPG_WORLD_CELL_PX - cam_py - 16); if (player_x >= 0 && player_x < map->width && player_y >= 0 && player_y < map->height && - player_y == world_y && player_x >= cam_x && - player_x < cam_x + RPG_VIEW_W - ox) { - u16 hero_tile = facing < 4 - ? (u16)(facing * VP_RPG_WORLD_ACTOR_FRAME_TILES) - : 0; - s16 screen_x = (s16)(ox + player_x - cam_x); - show_object_32(slot++, (s16)(screen_x * RPG_WORLD_CELL_PX - 8), - (s16)(screen_y * RPG_WORLD_CELL_PX - 16), - hero_tile, 0); + player_sort_y == world_y) { + u16 hero_tile = 0; + s16 actor_x = (s16)(ox * RPG_WORLD_CELL_PX + player_px - cam_px - 8); + s16 hero_y = (s16)(oy * RPG_WORLD_CELL_PX + player_py - cam_py - 16); + if (facing < VP_RPG_WORLD_WALK_DIRECTION_COUNT) { + hero_tile = (u16)(facing * VP_RPG_WORLD_ACTOR_FRAME_TILES); + if (player_frame > 0 && player_frame <= VP_RPG_WORLD_WALK_FRAMES) { + hero_tile = (u16)(VP_RPG_WORLD_WALK_TILE_BASE + + ((u16)facing * VP_RPG_WORLD_WALK_FRAMES + player_frame - 1) * + VP_RPG_WORLD_ACTOR_FRAME_TILES); + } + } + if (actor_x > -32 && actor_x < 240 && hero_y > -32 && hero_y < 160) + show_object_32(slot++, actor_x, hero_y, hero_tile, 0); } - for (x = (u8)cam_x; x < map->width && slot < 127; x++) { - s16 screen_x = (s16)(ox + x - cam_x); + for (x = 0; x < map->width && slot < 127; x++) { + s16 actor_x = (s16)(ox * RPG_WORLD_CELL_PX + + (s32)x * RPG_WORLD_CELL_PX - cam_px - 8); u8 ch; - if (screen_x >= RPG_VIEW_W) break; + if (actor_x <= -32 || actor_x >= 240 || actor_y <= -32 || actor_y >= 160) + continue; ch = map->tiles[(u16)world_y * map->width + x]; if (ch == 'N') { - s16 actor_x = (s16)(screen_x * RPG_WORLD_CELL_PX - 8); - if (player_y == world_y && player_x + 1 == x) actor_x += 4; - else if (player_y == world_y && player_x - 1 == x) actor_x -= 4; - show_object_32(slot++, actor_x, - (s16)(screen_y * RPG_WORLD_CELL_PX - 16), + show_object_32(slot++, actor_x, actor_y, VP_RPG_WORLD_ELDER_TILE, 1); } else if (ch == 'S' && quest == 1) { - s16 actor_x = (s16)(screen_x * RPG_WORLD_CELL_PX - 8); - if (player_y == world_y && player_x + 1 == x) actor_x += 4; - else if (player_y == world_y && player_x - 1 == x) actor_x -= 4; - show_object_32(slot++, actor_x, - (s16)(screen_y * RPG_WORLD_CELL_PX - 16), + show_object_32(slot++, actor_x, actor_y, VP_RPG_WORLD_SLIME_TILE, 2); } } } if (hud) { - for (x = 0; x < 30; x++) map_cell(x, 0, TILE_HUD); if (quest == 0) line_text(0, 1, "QUEST: TALK TO THE ELDER"); else if (quest == 1) line_text(0, 1, "QUEST: DEFEAT THE SLIME"); else if (quest == 2) line_text(0, 1, "QUEST: RETURN TO THE ELDER"); else line_text(0, 1, "QUEST COMPLETE: VILLAGE SAVED"); } + finish_objects(slot); } static void draw_dialog(const vp_rpg_map *map, s32 player_x, s32 player_y, - u8 facing, s32 quest, s32 dialog, s32 choice) { + s32 player_offset_x, s32 player_offset_y, + u8 facing, u8 player_frame, s32 quest, + s32 dialog, s32 choice) { const vp_rpg_dialog *d; - draw_world(map, player_x, player_y, facing, quest, 0); + draw_world(map, player_x, player_y, player_offset_x, player_offset_y, + facing, player_frame, quest, 0); draw_box(1, 11, 28, 19); + rpg_world_cache_valid = 0; if (!map || !map->dialogs || dialog < 1 || dialog > map->dialog_count) { line_text(15, 3, "..."); return; @@ -311,7 +431,8 @@ static u8 hp_tiles(s32 hp, s32 max_hp) { static void draw_battle(s32 hero_hp, s32 enemy_hp, s32 cursor) { u8 x, y, full; - hide_objects(); + stage_scroll(0, 0); + rpg_world_cache_valid = 0; for (y = 0; y < 32; y++) for (x = 0; x < 32; x++) rpg_bg_shadow[(u16)y * 32 + x] = @@ -326,6 +447,7 @@ static void draw_battle(s32 hero_hp, s32 enemy_hp, s32 cursor) { draw_box(2, 14, 27, 19); show_object_64(0, 8, 40, VP_RPG_BATTLE_HERO_TILE, 0); show_object_64(1, 168, 16, VP_RPG_BATTLE_SLIME_TILE, 2); + finish_objects(2); line_text(1, 2, "WILD SLIME"); line_hp(2, 2, enemy_hp, 18); line_text(10, 18, "HERO"); @@ -368,47 +490,80 @@ void vp_rpg_video_init(void) { for (i = 0; i < VP_RPG_OBJ_TILE_COUNT * 16; i++) RPG_OBJ_VRAM[i] = vp_rpg_obj_tiles[i]; map_fill(0); - hide_objects(); + reset_objects(); REG_BG1CNT = (u16)(2 | (2 << 2) | (9 << 8)); - REG_BG1HOFS = 0; - REG_BG1VOFS = 0; - REG_DISPCNT = 0x1340; + stage_scroll(0, 0); + stage_video(0x1340, 0, 0, 0, 0); rpg_ready = 1; } void vp_rpg_render(const vp_rpg_map *map, u8 mode, s32 player_x, - s32 player_y, u8 facing, s32 quest, s32 dialog, - s32 choice, s32 hero_hp, s32 enemy_hp, - s32 battle_cursor) { + s32 player_y, s32 player_offset_x, + s32 player_offset_y, u8 facing, u8 player_frame, + s32 quest, s32 dialog, s32 choice, s32 hero_hp, + s32 enemy_hp, s32 battle_cursor) { + u8 ui_changed; if (!rpg_ready) return; - vp_row_clear(0, VP_GRID_H); + ui_changed = !rpg_ui_cache_valid || rpg_ui_mode != mode || + rpg_ui_quest != quest || rpg_ui_dialog != dialog || + rpg_ui_choice != choice || rpg_ui_hero_hp != hero_hp || + rpg_ui_enemy_hp != enemy_hp || + rpg_ui_battle_cursor != battle_cursor; + if (ui_changed) { + vp_row_clear(0, VP_GRID_H); + rpg_ui_mode = mode; + rpg_ui_quest = quest; + rpg_ui_dialog = dialog; + rpg_ui_choice = choice; + rpg_ui_hero_hp = hero_hp; + rpg_ui_enemy_hp = enemy_hp; + rpg_ui_battle_cursor = battle_cursor; + rpg_ui_cache_valid = 1; + } if (mode == 1) { /* Keep enlarged OBJ heads visible above the dialog, but clip their lower * pixels at its y=88 edge so arbitrary maps cannot draw actors over UI. */ - REG_WIN0H = (u16)((0 << 8) | 240); - REG_WIN0V = (u16)((88 << 8) | 160); - REG_WININ = 0x0003; /* inside: BG0 text + BG1 box, no OBJ */ - REG_WINOUT = 0x0013; /* outside: BG0 + BG1 + OBJ */ - REG_DISPCNT = 0x3340; /* mode 0, 1D OBJ, BG0/BG1/OBJ + WIN0 */ - draw_dialog(map, player_x, player_y, facing, quest, dialog, choice); + stage_video(0x3340, (u16)((0 << 8) | 240), + (u16)((88 << 8) | 160), + 0x0003, 0x0013); + draw_dialog(map, player_x, player_y, player_offset_x, player_offset_y, + facing, player_frame, quest, dialog, choice); } else if (mode == 2) { - REG_DISPCNT = 0x1340; + stage_video(0x1340, 0, 0, 0, 0); draw_battle(hero_hp, enemy_hp, battle_cursor); } else { - REG_DISPCNT = 0x1340; - draw_world(map, player_x, player_y, facing, quest, 1); + /* The map scrolls behind a fixed 8px HUD. WIN0 exposes only BG0 and the + * backdrop in that strip, so neither world tiles nor actors can drift + * through the screen-space status line. */ + stage_video(0x3340, (u16)((0 << 8) | 240), + (u16)((0 << 8) | 8), + 0x0001, 0x0013); + draw_world(map, player_x, player_y, player_offset_x, player_offset_y, + facing, player_frame, quest, ui_changed); } } void vp_rpg_video_commit(void) { - u16 i; if (!rpg_ready) return; + if (rpg_registers_dirty) { + REG_BG1HOFS = rpg_bg1_hofs_shadow; + REG_BG1VOFS = rpg_bg1_vofs_shadow; + REG_WIN0H = rpg_win0h_shadow; + REG_WIN0V = rpg_win0v_shadow; + REG_WININ = rpg_winin_shadow; + REG_WINOUT = rpg_winout_shadow; + REG_DISPCNT = rpg_dispcnt_shadow; + RPG_DEBUG_SCROLL_X = rpg_bg1_hofs_shadow; + RPG_DEBUG_SCROLL_Y = rpg_bg1_vofs_shadow; + rpg_registers_dirty = 0; + } if (rpg_bg_dirty) { - for (i = 0; i < 32 * 32; i++) RPG_BG_MAP[i] = rpg_bg_shadow[i]; + dma3_copy16(rpg_bg_shadow, RPG_BG_MAP, 32 * RPG_BUFFER_H * 2); rpg_bg_dirty = 0; } if (rpg_oam_dirty) { - for (i = 0; i < 128 * 4; i++) OAM[i] = rpg_oam_shadow[i]; + dma3_copy16(rpg_oam_shadow, OAM, (u16)rpg_oam_commit_slots * 4); + rpg_oam_commit_slots = 0; rpg_oam_dirty = 0; } } diff --git a/vapor/runtime/gba/vapor_rpg_assets.generated.h b/vapor/runtime/gba/vapor_rpg_assets.generated.h index 9ecdb1c2..dff53b40 100644 --- a/vapor/runtime/gba/vapor_rpg_assets.generated.h +++ b/vapor/runtime/gba/vapor_rpg_assets.generated.h @@ -7,8 +7,12 @@ #define VP_RPG_UI_TILE_COUNT 14 #define VP_RPG_UI_TILE_BASE (VP_RPG_WORLD_TILE_FRAME_COUNT * VP_RPG_WORLD_TILE_FRAME_TILES) #define VP_RPG_BG_TILE_COUNT (VP_RPG_UI_TILE_BASE + VP_RPG_UI_TILE_COUNT) -#define VP_RPG_WORLD_ACTOR_FRAME_COUNT 6 #define VP_RPG_WORLD_ACTOR_FRAME_TILES 16 +#define VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT 6 +#define VP_RPG_WORLD_WALK_DIRECTION_COUNT 4 +#define VP_RPG_WORLD_WALK_FRAMES 4 +#define VP_RPG_WORLD_WALK_TILE_BASE (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES) +#define VP_RPG_WORLD_ACTOR_FRAME_COUNT (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT + VP_RPG_WORLD_WALK_DIRECTION_COUNT * VP_RPG_WORLD_WALK_FRAMES) #define VP_RPG_WORLD_ELDER_TILE (4 * VP_RPG_WORLD_ACTOR_FRAME_TILES) #define VP_RPG_WORLD_SLIME_TILE (5 * VP_RPG_WORLD_ACTOR_FRAME_TILES) #define VP_RPG_BATTLE_HERO_TILE (VP_RPG_WORLD_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES) @@ -140,7 +144,7 @@ static const u16 vp_rpg_bg_tiles[864] = { 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0x1111, 0xcccc, 0xcccc, }; -static const u16 vp_rpg_obj_tiles[3584] = { +static const u16 vp_rpg_obj_tiles[7680] = { 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, @@ -336,6 +340,518 @@ static const u16 vp_rpg_obj_tiles[3584] = { 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1110, 0x7000, 0x7998, 0x7100, 0x7773, 0x7210, 0x1117, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0111, 0x0000, 0x7777, 0x0001, 0x2771, 0x0012, 0x8117, 0x0178, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x2277, 0x7727, 0x3827, 0x7773, 0x9177, 0x7172, 0x7717, 0x1317, + 0x7177, 0x7111, 0x1177, 0x1333, 0x3117, 0x3311, 0x1197, 0x3111, + 0x7711, 0x7781, 0x3777, 0x7832, 0x9777, 0x7318, 0x7817, 0x2187, + 0x1117, 0x8177, 0x8317, 0x1771, 0x1182, 0x2179, 0x1172, 0x8314, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3193, 0x9944, 0x3110, 0x9999, 0x7100, 0x9993, 0x4000, 0x1711, + 0x5100, 0x1771, 0x6100, 0x1776, 0x6610, 0x2116, 0x6610, 0x3151, + 0x4499, 0x2913, 0x9999, 0x0119, 0x9999, 0x0011, 0x7117, 0x0011, + 0x7717, 0x0161, 0x1717, 0x1164, 0x5127, 0x1666, 0x6133, 0x1641, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x6410, 0x3151, 0x4110, 0x9144, 0x9910, 0x8141, 0x9910, 0x8113, + 0x1000, 0x6661, 0x1000, 0x1654, 0x1000, 0x1111, 0x0000, 0x0111, + 0x1133, 0x1441, 0x2199, 0x1991, 0x1188, 0x0131, 0x6188, 0x0014, + 0x6666, 0x0006, 0x4111, 0x0004, 0x7110, 0x0007, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x7797, + 0x7100, 0x1172, 0x2210, 0x1712, 0x7271, 0x7222, 0x7777, 0x7773, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x7771, 0x0017, + 0x7777, 0x0172, 0x1117, 0x1228, 0x7282, 0x7997, 0x7278, 0x7373, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3877, 0x2779, 0x7717, 0x1217, 0x7177, 0x1211, 0x1777, 0x1388, + 0x8177, 0x3911, 0x9191, 0x3111, 0x3810, 0x9959, 0x7100, 0x9993, + 0x7128, 0x7319, 0x1778, 0x7137, 0x1772, 0x7771, 0x3317, 0x7171, + 0x1181, 0x3173, 0x1188, 0x8719, 0x9699, 0x1919, 0x9999, 0x0127, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x9997, 0x4000, 0x1171, 0x5100, 0x7771, 0x6100, 0x2716, + 0x6410, 0x8166, 0x6410, 0x8161, 0x6410, 0x9161, 0x6410, 0x8161, + 0x8999, 0x0011, 0x1717, 0x0115, 0x1777, 0x0166, 0x6822, 0x0166, + 0x6139, 0x0141, 0x1188, 0x0131, 0x6199, 0x0011, 0x6183, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x8118, 0x3100, 0x5617, 0x1000, 0x1111, 0x1000, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x6188, 0x0001, 0x6665, 0x0006, 0x6664, 0x0006, 0x5611, 0x0005, + 0x9410, 0x0009, 0x1110, 0x0001, 0x9110, 0x0019, 0x1000, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1110, 0x1000, 0x7732, 0x7100, 0x1173, 0x7110, 0x1712, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0111, 0x0000, 0x7397, 0x0007, 0x2777, 0x0012, 0x8117, 0x0132, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3371, 0x7222, 0x7271, 0x7733, 0x3872, 0x7778, 0x2717, 0x7137, + 0x7777, 0x1711, 0x1177, 0x1237, 0x8137, 0x3111, 0x8981, 0x8144, + 0x7777, 0x7997, 0x9772, 0x2923, 0x2172, 0x7813, 0x1218, 0x7177, + 0x1217, 0x7771, 0x7817, 0x7711, 0x1131, 0x3176, 0x1438, 0x7916, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9981, 0x9166, 0x1100, 0x9999, 0x1000, 0x9991, 0x5000, 0x1131, + 0x5100, 0x1271, 0x6100, 0x1775, 0x1610, 0x7141, 0x5410, 0x3161, + 0x4499, 0x2916, 0x9999, 0x0113, 0x9999, 0x0017, 0x8111, 0x0011, + 0x8717, 0x0151, 0x7777, 0x0166, 0x6177, 0x1466, 0x6199, 0x1461, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9611, 0x8161, 0x9921, 0x9159, 0x1810, 0x8179, 0x1100, 0x7191, + 0x4100, 0x6664, 0x4000, 0x1564, 0x1000, 0x1141, 0x1000, 0x0011, + 0x6133, 0x1461, 0x6149, 0x1441, 0x5133, 0x1991, 0x5133, 0x1991, + 0x6666, 0x0017, 0x6111, 0x0006, 0x4110, 0x0004, 0x1000, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x7333, + 0x7100, 0x7119, 0x7710, 0x1177, 0x3771, 0x7727, 0x7227, 0x7329, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x7892, 0x0007, + 0x7777, 0x0017, 0x7117, 0x0172, 0x7777, 0x7997, 0x7777, 0x2982, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3727, 0x2779, 0x7717, 0x7137, 0x7777, 0x7211, 0x1177, 0x1733, + 0x7187, 0x8211, 0x7921, 0x3141, 0x9310, 0x9956, 0x8100, 0x9993, + 0x2777, 0x2919, 0x7778, 0x2128, 0x1817, 0x7177, 0x8917, 0x7711, + 0x1188, 0x2177, 0x4488, 0x8917, 0x6999, 0x1913, 0x9999, 0x0118, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x9992, 0x1100, 0x1171, 0x4410, 0x1771, 0x1410, 0x1771, + 0x1410, 0x7711, 0x1910, 0x9314, 0x1100, 0x4411, 0x1000, 0x9814, + 0x3999, 0x0001, 0x7111, 0x0001, 0x7717, 0x0011, 0x1777, 0x0111, + 0x5117, 0x1666, 0x1519, 0x1661, 0x1114, 0x4661, 0x1519, 0x4551, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x8214, 0x4000, 0x6666, 0x4000, 0x6666, 0x4000, 0x4164, + 0x1000, 0x0149, 0x1000, 0x0111, 0x7000, 0x0176, 0x1000, 0x0011, + 0x4518, 0x9991, 0x7311, 0x1391, 0x4445, 0x0111, 0x1110, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2111, + 0x1000, 0x2988, 0x9700, 0x2229, 0x9911, 0x2222, 0x2911, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x1112, 0x0000, + 0x7932, 0x0011, 0x2227, 0x0199, 0x2222, 0x1792, 0x2222, 0x7792, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2927, 0x2222, 0x2927, 0x2222, 0x2227, 0x2229, 0x2227, 0x8292, + 0x2777, 0x2222, 0x7777, 0x2772, 0x7771, 0x7772, 0x7710, 0x7777, + 0x2222, 0x7792, 0x2222, 0x7792, 0x9222, 0x7722, 0x2982, 0x7772, + 0x7222, 0x7777, 0x7222, 0x7777, 0x7777, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7700, 0x7777, 0x1000, 0x7771, 0x1000, 0x1111, 0x6600, 0x1116, + 0x6600, 0x4446, 0x6600, 0x4446, 0x6600, 0x4446, 0x1100, 0x4441, + 0x7777, 0x0177, 0x1777, 0x0011, 0x1111, 0x0011, 0x4411, 0x0164, + 0x4444, 0x0164, 0x4444, 0x0164, 0x4444, 0x0114, 0x4444, 0x0114, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x4416, 0x5510, 0x4414, 0x6600, 0x1116, 0x1100, 0x0441, + 0x1100, 0x0449, 0x1000, 0x0111, 0x1000, 0x0111, 0x0000, 0x0000, + 0x4444, 0x0114, 0x4444, 0x0014, 0x4111, 0x0014, 0x4440, 0x0014, + 0x4440, 0x0014, 0x1910, 0x0011, 0x1190, 0x0011, 0x1110, 0x0011, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x1000, 0x2992, 0x7100, 0x2988, 0x9900, 0x7222, 0x2910, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, + 0x8992, 0x0001, 0x8282, 0x0001, 0x2227, 0x0099, 0x2222, 0x0192, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2911, 0x2222, 0x2927, 0x2222, 0x9227, 0x2222, 0x2227, 0x8292, + 0x2227, 0x8222, 0x2777, 0x2222, 0x7777, 0x2772, 0x7771, 0x7777, + 0x2222, 0x1792, 0x2222, 0x7792, 0x2222, 0x7729, 0x2882, 0x7722, + 0x2222, 0x7772, 0x7222, 0x7777, 0x7722, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x7777, 0x7100, 0x7777, 0x1000, 0x7771, 0x1100, 0x1114, + 0x6600, 0x1446, 0x6600, 0x4446, 0x6600, 0x4446, 0x6600, 0x4446, + 0x7777, 0x0177, 0x7777, 0x0017, 0x1777, 0x0011, 0x1111, 0x0061, + 0x4411, 0x0044, 0x4444, 0x0144, 0x4444, 0x0114, 0x4444, 0x0114, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9910, 0x4419, 0x9910, 0x4419, 0x5610, 0x1111, 0x6600, 0x1416, + 0x1100, 0x0441, 0x1000, 0x0111, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4444, 0x0014, 0x4444, 0x0014, 0x4111, 0x0001, 0x4441, 0x0001, + 0x4440, 0x0001, 0x1940, 0x0001, 0x1140, 0x0001, 0x1110, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x1000, 0x2111, 0x8100, 0x2298, 0x2910, 0x2222, 0x2971, 0x7222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1118, 0x0000, 0x1998, 0x0001, 0x9227, 0x0013, 0x2227, 0x0089, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x2271, 0x2222, 0x2282, 0x2222, 0x2222, 0x2222, 0x9222, 0x2222, + 0x2222, 0x2822, 0x2282, 0x2222, 0x2227, 0x2222, 0x7777, 0x7777, + 0x2222, 0x0129, 0x2222, 0x1729, 0x2222, 0x7779, 0x2222, 0x7777, + 0x7988, 0x7777, 0x7222, 0x7777, 0x7727, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7777, 0x7777, 0x7710, 0x7777, 0x7100, 0x7777, 0x1100, 0x7771, + 0x6600, 0x4414, 0x6610, 0x1144, 0x4610, 0x4444, 0x4110, 0x4444, + 0x7777, 0x1777, 0x7777, 0x0077, 0x7777, 0x0001, 0x1177, 0x0011, + 0x6114, 0x0066, 0x6441, 0x0166, 0x4444, 0x0166, 0x4444, 0x0111, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4110, 0x4444, 0x4110, 0x4444, 0x1100, 0x4441, 0x1000, 0x1114, + 0x1000, 0x0444, 0x1000, 0x0441, 0x1000, 0x0991, 0x0000, 0x0111, + 0x1444, 0x1999, 0x1444, 0x1655, 0x1444, 0x0151, 0x1111, 0x0011, + 0x1410, 0x0014, 0x4110, 0x0011, 0x1610, 0x0011, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x2111, + 0x1000, 0x2998, 0x3700, 0x2223, 0x9910, 0x7222, 0x2910, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0000, 0x1112, 0x0001, + 0x9992, 0x0001, 0x9222, 0x0039, 0x2227, 0x0199, 0x2222, 0x1192, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2981, 0x2222, 0x2987, 0x2222, 0x2227, 0x2229, 0x2227, 0x8292, + 0x2277, 0x8222, 0x7777, 0x2222, 0x7777, 0x2772, 0x7771, 0x7777, + 0x2222, 0x7232, 0x2222, 0x7792, 0x9222, 0x7722, 0x2882, 0x7772, + 0x2222, 0x7777, 0x7222, 0x7777, 0x7722, 0x7777, 0x7777, 0x1777, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x7777, 0x1000, 0x7777, 0x1000, 0x7771, 0x6600, 0x4411, + 0x6600, 0x1144, 0x4410, 0x4444, 0x4110, 0x4444, 0x4110, 0x4444, + 0x7777, 0x0177, 0x7777, 0x0017, 0x1777, 0x0001, 0x5144, 0x0015, + 0x6411, 0x0066, 0x6444, 0x0166, 0x4444, 0x0166, 0x4444, 0x0166, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x4100, 0x4444, 0x1100, 0x4444, 0x1000, 0x1114, 0x1000, 0x0444, + 0x1000, 0x0444, 0x1000, 0x0441, 0x1000, 0x0924, 0x1000, 0x0111, + 0x9444, 0x1999, 0x9444, 0x1999, 0x1111, 0x1666, 0x1140, 0x0141, + 0x6440, 0x0013, 0x1110, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, 0x7700, 0x2277, + 0x1111, 0x7111, 0x2292, 0x1122, 0x2222, 0x2222, 0x7222, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0111, 0x0000, 0x9982, 0x0001, + 0x9222, 0x0019, 0x2717, 0x1782, 0x1111, 0x1282, 0x2222, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x7100, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7121, 0x2231, 0x1771, 0x9911, 0x9911, 0x2719, 0x7110, 0x7717, + 0x1910, 0x7191, 0x1910, 0x7194, 0x1910, 0x1994, 0x9900, 0x1799, + 0x2329, 0x2222, 0x2239, 0x7222, 0x2822, 0x7772, 0x2882, 0x7772, + 0x7711, 0x7777, 0x7199, 0x7777, 0x7193, 0x7777, 0x7713, 0x0177, + 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, + 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x3100, 0x7799, 0x1000, 0x1177, 0x7100, 0x6577, 0x1000, 0x1161, + 0x0000, 0x6566, 0x0000, 0x6111, 0x0000, 0x1166, 0x0000, 0x6166, + 0x7771, 0x0017, 0x1144, 0x0001, 0x0416, 0x0000, 0x1146, 0x0000, + 0x1146, 0x0000, 0x1146, 0x0000, 0x1466, 0x0000, 0x1446, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1166, 0x0000, 0x9966, 0x0000, 0x9961, 0x0000, 0x9161, + 0x0000, 0x1161, 0x0000, 0x4610, 0x0000, 0x4110, 0x0000, 0x1100, + 0x1144, 0x0000, 0x0119, 0x0000, 0x0189, 0x0000, 0x0119, 0x0000, + 0x0111, 0x0000, 0x0114, 0x0000, 0x0114, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x7000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, + 0x7700, 0x2277, 0x1111, 0x2111, 0x2299, 0x1122, 0x2227, 0x1222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, + 0x1922, 0x0001, 0x9282, 0x0007, 0x2227, 0x0122, 0x2117, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x2000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2227, 0x2222, 0x7771, 0x2293, 0x3171, 0x9887, 0x9911, 0x2271, + 0x2710, 0x2271, 0x1310, 0x7713, 0x1910, 0x1189, 0x9910, 0x3199, + 0x2222, 0x1222, 0x2229, 0x7222, 0x2222, 0x7722, 0x2888, 0x7772, + 0x2778, 0x7777, 0x7771, 0x7777, 0x7719, 0x1777, 0x7718, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9900, 0x9199, 0x1100, 0x1777, 0x2100, 0x5177, 0x7000, 0x1617, + 0x0000, 0x6646, 0x0000, 0x6146, 0x0000, 0x6161, 0x0000, 0x1661, + 0x7779, 0x0017, 0x7711, 0x0001, 0x1144, 0x0000, 0x0441, 0x0000, + 0x0116, 0x0000, 0x0446, 0x0000, 0x1466, 0x0000, 0x1666, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x6661, 0x0000, 0x6661, 0x0000, 0x1161, 0x0000, 0x4466, + 0x0000, 0x4466, 0x0000, 0x4445, 0x1000, 0x1128, 0x1000, 0x0111, + 0x1461, 0x0000, 0x8911, 0x0001, 0x8991, 0x0001, 0x1911, 0x0000, + 0x0111, 0x0000, 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x8000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, + 0x7700, 0x2277, 0x1111, 0x7111, 0x2299, 0x1122, 0x2228, 0x1222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, + 0x1922, 0x0001, 0x9222, 0x0007, 0x2228, 0x0122, 0x2112, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x8000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7228, 0x2222, 0x7171, 0x9292, 0x1317, 0x9927, 0x3910, 0x2271, + 0x7710, 0x2211, 0x1910, 0x7731, 0x1910, 0x8199, 0x9910, 0x3199, + 0x2222, 0x1222, 0x2229, 0x7222, 0x2222, 0x7722, 0x2882, 0x7772, + 0x2778, 0x7777, 0x7771, 0x7777, 0x7718, 0x1777, 0x7713, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9900, 0x9199, 0x0000, 0x1771, 0x1000, 0x4177, 0x2100, 0x1177, + 0x0000, 0x6641, 0x0000, 0x6166, 0x0000, 0x6666, 0x0000, 0x6666, + 0x7779, 0x0017, 0x7711, 0x0001, 0x1114, 0x0000, 0x0441, 0x0000, + 0x0441, 0x0000, 0x1146, 0x0000, 0x1146, 0x0000, 0x1146, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x6615, 0x0000, 0x6116, 0x0000, 0x3991, 0x0000, 0x2991, + 0x0000, 0x7311, 0x0000, 0x1110, 0x0000, 0x4410, 0x0000, 0x1100, + 0x1146, 0x0000, 0x1114, 0x0000, 0x0117, 0x0000, 0x0118, 0x0000, + 0x0141, 0x0000, 0x0115, 0x0000, 0x0144, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x1000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x1100, + 0x7700, 0x2287, 0x1111, 0x2111, 0x2299, 0x1122, 0x2221, 0x1222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0000, + 0x1982, 0x0001, 0x9282, 0x0007, 0x2727, 0x0172, 0x2117, 0x0122, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x7000, 0x0000, 0x1000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x2222, 0x7171, 0x9297, 0x1871, 0x9927, 0x3910, 0x2271, + 0x7110, 0x2271, 0x1910, 0x1731, 0x1910, 0x9199, 0x9910, 0x3199, + 0x2222, 0x1222, 0x2229, 0x7222, 0x2222, 0x7722, 0x2882, 0x7772, + 0x2772, 0x7777, 0x7771, 0x7777, 0x7719, 0x1777, 0x7713, 0x0177, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9900, 0x3199, 0x0000, 0x1171, 0x0000, 0x1177, 0x1000, 0x6677, + 0x0000, 0x6661, 0x0000, 0x6611, 0x0000, 0x6616, 0x0000, 0x6611, + 0x7779, 0x0017, 0x1711, 0x0001, 0x1151, 0x0000, 0x1546, 0x0000, + 0x0416, 0x0000, 0x1416, 0x0000, 0x1116, 0x0000, 0x1414, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x9100, 0x4661, 0x9100, 0x4416, 0x1000, 0x1199, 0x0000, 0x1111, + 0x0000, 0x6610, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1000, + 0x1444, 0x0000, 0x1444, 0x0000, 0x0111, 0x0000, 0x0444, 0x0000, + 0x0446, 0x0000, 0x1441, 0x0000, 0x1771, 0x0000, 0x0111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x2897, + 0x8100, 0x8389, 0x2371, 0x7122, 0x2281, 0x2171, 0x2227, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0011, 0x0001, 0x8972, 0x0001, + 0x7717, 0x0111, 0x2211, 0x1722, 0x2222, 0x0182, 0x3222, 0x0787, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x9222, 0x2222, 0x2282, 0x2227, 0x2222, 0x2777, 0x2222, + 0x7777, 0x7777, 0x7777, 0x7177, 0x7777, 0x9817, 0x7710, 0x8717, + 0x1922, 0x1171, 0x1199, 0x0171, 0x9122, 0x0011, 0x3112, 0x0019, + 0x1972, 0x0019, 0x4971, 0x0019, 0x4981, 0x0019, 0x9981, 0x0019, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7100, 0x2177, 0x0000, 0x1111, 0x0000, 0x4410, 0x0000, 0x4440, + 0x0000, 0x6441, 0x0000, 0x4441, 0x0000, 0x4141, 0x0000, 0x4141, + 0x9981, 0x0001, 0x1711, 0x0000, 0x1764, 0x0000, 0x1616, 0x0000, + 0x1166, 0x0000, 0x6166, 0x0000, 0x6166, 0x0000, 0x6166, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1440, 0x0000, 0x1410, 0x0000, 0x4100, 0x0000, 0x4100, + 0x0000, 0x4100, 0x0000, 0x1100, 0x0000, 0x7100, 0x0000, 0x1000, + 0x1154, 0x0000, 0x9993, 0x0000, 0x1987, 0x0000, 0x0111, 0x0000, + 0x0166, 0x0000, 0x0166, 0x0000, 0x0182, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, + 0x7100, 0x2787, 0x8710, 0x1739, 0x2271, 0x1217, 0x1227, 0x1211, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1001, 0x0000, + 0x7711, 0x0000, 0x1721, 0x0111, 0x2222, 0x1772, 0x2222, 0x0112, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2227, 0x2222, 0x2222, 0x2992, 0x2222, 0x2222, 0x2227, 0x2227, + 0x7777, 0x2227, 0x7777, 0x1117, 0x7777, 0x9337, 0x7771, 0x9977, + 0x7222, 0x0172, 0x7782, 0x1771, 0x1189, 0x0171, 0x1722, 0x0001, + 0x3771, 0x0001, 0x4191, 0x0001, 0x5192, 0x0001, 0x9992, 0x0001, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x9317, 0x1000, 0x1111, 0x0000, 0x4441, 0x0000, 0x4441, + 0x1000, 0x4444, 0x1000, 0x6444, 0x1000, 0x4144, 0x1000, 0x4144, + 0x9992, 0x0001, 0x1277, 0x0000, 0x0171, 0x0000, 0x0116, 0x0000, + 0x0116, 0x0000, 0x1616, 0x0000, 0x1566, 0x0000, 0x1566, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x1444, 0x1000, 0x4444, 0x0000, 0x1110, 0x0000, 0x4410, + 0x0000, 0x4410, 0x0000, 0x4441, 0x0000, 0x8771, 0x0000, 0x1110, + 0x1765, 0x0000, 0x9944, 0x0000, 0x9921, 0x0000, 0x1214, 0x0000, + 0x0114, 0x0000, 0x0144, 0x0000, 0x0011, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x2293, + 0x3100, 0x1288, 0x2871, 0x1712, 0x2271, 0x1711, 0x2222, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1011, 0x0000, 0x8712, 0x0001, + 0x7111, 0x0111, 0x2221, 0x1722, 0x2222, 0x0122, 0x2922, 0x0227, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x2992, 0x2222, 0x2222, 0x2227, 0x2227, 0x7777, 0x2227, + 0x7777, 0x7777, 0x7777, 0x1117, 0x7777, 0x8927, 0x7771, 0x3717, + 0x7792, 0x1117, 0x3129, 0x0101, 0x9722, 0x0001, 0x2771, 0x0001, + 0x1131, 0x0001, 0x1187, 0x0001, 0x5492, 0x0013, 0x9998, 0x0019, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7710, 0x7117, 0x1000, 0x1111, 0x0000, 0x4410, 0x0000, 0x4440, + 0x0000, 0x4441, 0x0000, 0x4441, 0x0000, 0x4411, 0x0000, 0x4111, + 0x9937, 0x0001, 0x1171, 0x0000, 0x7715, 0x0000, 0x1615, 0x0000, + 0x6166, 0x0000, 0x6166, 0x0000, 0x6666, 0x0000, 0x6644, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x4410, 0x0000, 0x9810, 0x0000, 0x8100, 0x0000, 0x1100, + 0x0000, 0x4100, 0x0000, 0x4100, 0x0000, 0x7100, 0x0000, 0x1000, + 0x6611, 0x0000, 0x1619, 0x0000, 0x4119, 0x0000, 0x1557, 0x0000, + 0x9666, 0x0000, 0x1166, 0x0000, 0x0066, 0x0000, 0x0011, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1110, 0x1000, 0x2299, + 0x3100, 0x1232, 0x2871, 0x1212, 0x2271, 0x1711, 0x2277, 0x2222, + 0x0000, 0x0000, 0x0000, 0x0000, 0x1011, 0x0000, 0x7212, 0x0001, + 0x7111, 0x0111, 0x2221, 0x1722, 0x2222, 0x0182, 0x9222, 0x0787, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, 0x0000, 0x1000, + 0x0000, 0x1000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x2222, 0x9922, 0x2222, 0x2222, 0x2227, 0x2227, 0x2777, 0x2227, + 0x7777, 0x2277, 0x7777, 0x1777, 0x7777, 0x9917, 0x7711, 0x9817, + 0x1322, 0x1717, 0x7138, 0x1117, 0x7177, 0x0171, 0x1971, 0x0171, + 0x1112, 0x0001, 0x1181, 0x0001, 0x1531, 0x0019, 0x9937, 0x0019, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x7110, 0x3177, 0x0000, 0x1111, 0x0000, 0x5440, 0x0000, 0x1141, + 0x0000, 0x5141, 0x1000, 0x6514, 0x1000, 0x6614, 0x1000, 0x6114, + 0x9937, 0x0001, 0x1771, 0x0001, 0x7111, 0x0017, 0x7644, 0x0012, + 0x1466, 0x0012, 0x6666, 0x0001, 0x6666, 0x0000, 0x6616, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x1000, 0x6641, 0x0000, 0x4991, 0x0000, 0x8991, 0x0000, 0x2871, + 0x0000, 0x1110, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x6611, 0x0000, 0x6661, 0x0000, 0x1111, 0x0000, 0x1411, 0x0000, + 0x1444, 0x0000, 0x1814, 0x0000, 0x7971, 0x0000, 0x1111, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, + 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x1100, 0x0000, 0x1100, 0x1000, 0x2211, diff --git a/vapor/runtime/vapor.h b/vapor/runtime/vapor.h index 8ad43ebc..c91f212f 100644 --- a/vapor/runtime/vapor.h +++ b/vapor/runtime/vapor.h @@ -88,9 +88,10 @@ u8 vp_rpg_blocked(const vp_rpg_map *map, s32 x, s32 y); u8 vp_rpg_event_at(const vp_rpg_map *map, s32 x, s32 y); void vp_rpg_video_init(void); void vp_rpg_render(const vp_rpg_map *map, u8 mode, s32 player_x, - s32 player_y, u8 facing, s32 quest, s32 dialog, - s32 choice, s32 hero_hp, s32 enemy_hp, - s32 battle_cursor); + s32 player_y, s32 player_offset_x, + s32 player_offset_y, u8 facing, u8 player_frame, + s32 quest, s32 dialog, s32 choice, s32 hero_hp, + s32 enemy_hp, s32 battle_cursor); void vp_rpg_video_commit(void); /* ---- grid (runtime-owned) -------------------------------------------------- */ @@ -136,6 +137,8 @@ extern const u32 vp_bit32[32]; /* vp_bit32[n] == 1UL << n */ #define VP_RELATIVE_AXIS_SECONDARY 1 void app_init(void); /* seed state + first paint (all effects) */ void app_on_button(u8 b); /* one press edge, GBA key bit index */ +/* One fixed semantic tick with the hardware-neutral framework BTN mask. */ +void app_on_frame(u32 buttons); void app_on_button_repeat(u8 b); /* normalized held-D-pad repeat */ /* Signed physical motion on a hardware-neutral relative axis. Axis 0 is * RelativeAxis.Primary. Rotary hosts use millidegrees and preserve the diff --git a/vapor/scripts/rpg-assets.ts b/vapor/scripts/rpg-assets.ts index 0b5586ff..d58cfefd 100644 --- a/vapor/scripts/rpg-assets.ts +++ b/vapor/scripts/rpg-assets.ts @@ -26,6 +26,8 @@ const WORLD_TILE_SIZE = 16; const WORLD_TILE_COLUMNS = 10; const WORLD_ACTOR_SIZE = 32; const BATTLE_ACTOR_SIZE = 64; +const WALK_DIRECTIONS = ["south", "north", "west", "east"] as const; +const WALK_FRAME_COUNT = 4; type Rgb = readonly [number, number, number]; type Palette = readonly Rgb[]; @@ -69,6 +71,9 @@ const UI_TILE_NAMES = [ "box-br", "battle-sky", "battle-ground", "hp-empty", "hp-full", "hud", ] as const; const ACTOR_NAMES = ["hero-south", "hero-north", "hero-west", "hero-east", "elder", "slime"] as const; +const WALK_SOURCE_FILES = WALK_DIRECTIONS.flatMap((direction) => + Array.from({ length: WALK_FRAME_COUNT }, (_, frame) => `hero-walk-${direction}-${frame}.png`) +); const STYLE = [ "Original cheerful early-2000s handheld cartridge RPG pixel art.", @@ -98,7 +103,7 @@ const EXCLUSIONS = [ ].join(", "); const GENERATION = { - version: 2, + version: 3, provider: "PixelLab", apiBase: API, artDirection: "Vapor Quest close-up: 16px world cells, 32px actors, deep-navy information layer", @@ -154,6 +159,15 @@ const GENERATION = { seed: 23054, prompt: "The same expressive blue-tunic adventurer with short brown hair, orange-red scarf, gloves and boots, rotated consistently for a high top-down RPG; preserve face, head height, shoulder width, clothing blocks, palette, outline and foot anchor in every direction.", }, + heroWalk: { + endpoint: "/characters/animations", + seed: 23071, + templateAnimationId: "walking-4-frames", + animationName: "Vapor Quest Walk", + directions: WALK_DIRECTIONS, + frameCount: WALK_FRAME_COUNT, + prompt: "A compact, cyclic four-frame walk with alternating arms and legs, steady high top-down camera, stable body proportions, fixed ground plane and no translation inside the source canvas.", + }, elder: { endpoint: "/create-image-pixen", seed: 23060, @@ -354,20 +368,114 @@ async function generateHeroRotations(reference: Buffer): Promise<{ }; } -async function waitForJob(jobId: string, label: string): Promise { +async function waitForJob(jobId: string, label: string): Promise> { for (;;) { await Bun.sleep(5000); const response = await fetch(`${API}/background-jobs/${jobId}`, { headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, }); if (!response.ok) throw new Error(`PixelLab ${label} job poll failed (${response.status})`); - const job = await response.json() as { status: string; last_response?: unknown }; + const job = await response.json() as { status: string; last_response?: Record }; console.log(`PixelLab: ${label} ${job.status}`); if (job.status === "failed") throw new Error(`PixelLab ${label} failed: ${JSON.stringify(job.last_response)}`); - if (job.status === "completed") return; + if (job.status === "completed") return job.last_response ?? {}; } } +type CharacterAnimationGroup = { + animation_type: string; + display_name?: string | null; + animation_group_id?: string | null; + directions: Array<{ direction: string; frame_count: number; frames: string[] }>; +}; + +async function generateHeroWalk(characterId: string): Promise<{ + characterId: string; + animationGroupId: string; + backgroundJobIds: Record; + hashes: Record; +}> { + const spec = GENERATION.assets.heroWalk; + console.log("PixelLab: generating consistent four-direction hero walk"); + const created = await pixelLab<{ + background_job_ids: string[]; + directions: string[]; + }>(spec.endpoint, { + character_id: characterId, + mode: "template", + template_animation_id: spec.templateAnimationId, + animation_name: spec.animationName, + action_description: spec.prompt, + directions: spec.directions, + outline: "selective outline", + shading: "basic shading", + detail: "medium detail", + color_image: base64Image(palettePng(HERO)), + force_colors: true, + seed: spec.seed, + }); + if (created.background_job_ids.length !== WALK_DIRECTIONS.length || + created.directions.length !== WALK_DIRECTIONS.length) { + throw new Error("PixelLab hero walk did not queue exactly four directions"); + } + const backgroundJobIds: Record = {}; + const groupIds = new Set(); + for (let index = 0; index < created.background_job_ids.length; index++) { + const direction = created.directions[index]; + const jobId = created.background_job_ids[index]; + if (!WALK_DIRECTIONS.includes(direction as typeof WALK_DIRECTIONS[number])) { + throw new Error(`PixelLab hero walk returned unexpected direction ${direction}`); + } + backgroundJobIds[direction] = jobId; + const result = await waitForJob(jobId, `hero walk ${direction}`); + const groupId = result.animation_group_id; + if (typeof groupId === "string") groupIds.add(groupId); + } + if (Object.keys(backgroundJobIds).length !== WALK_DIRECTIONS.length) { + throw new Error("PixelLab hero walk returned duplicate directions"); + } + + let group: CharacterAnimationGroup | undefined; + for (let attempt = 0; attempt < 12 && !group; attempt++) { + const detailResponse = await fetch(`${API}/characters/${characterId}`, { + headers: { Authorization: `Bearer ${process.env.PIXELLAB_API_KEY}` }, + }); + if (!detailResponse.ok) throw new Error(`PixelLab hero walk character fetch failed (${detailResponse.status})`); + const detail = await detailResponse.json() as { animations?: CharacterAnimationGroup[] }; + const animations = detail.animations ?? []; + group = animations.find((candidate) => + !!candidate.animation_group_id && groupIds.has(candidate.animation_group_id) + ) ?? [...animations].reverse().find((candidate) => + candidate.display_name === spec.animationName && candidate.animation_type === spec.templateAnimationId + ) ?? [...animations].reverse().find((candidate) => candidate.animation_type === spec.templateAnimationId); + if (!group) await Bun.sleep(5000); + } + if (!group) throw new Error("PixelLab hero walk animation group did not become available"); + if (!group.animation_group_id) throw new Error("PixelLab hero walk animation has no group ID"); + + const hashes: Record = {}; + for (const direction of WALK_DIRECTIONS) { + const sequence = group.directions.find((candidate) => candidate.direction === direction); + if (!sequence || sequence.frame_count !== WALK_FRAME_COUNT || sequence.frames.length !== WALK_FRAME_COUNT) { + throw new Error(`PixelLab hero walk ${direction} must contain exactly ${WALK_FRAME_COUNT} frames`); + } + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + const response = await fetch(sequence.frames[frame]); + if (!response.ok) throw new Error(`PixelLab hero walk ${direction} frame ${frame} download failed (${response.status})`); + const bytes = Buffer.from(await response.arrayBuffer()); + const file = `hero-walk-${direction}-${frame}.png`; + writeFileSync(join(SOURCE, file), bytes); + hashes[file] = sha256(bytes); + } + } + return { + characterId, + animationGroupId: group.animation_group_id, + backgroundJobIds, + hashes, + }; +} + async function generateTileset(spec: typeof GENERATION.assets.terrainSets[number]): Promise> { console.log(`PixelLab: generating ${spec.name} terrain tileset`); const created = await pixelLab<{ background_job_id: string; tileset_id: string }>("/tilesets", { @@ -480,8 +588,8 @@ async function generate(force: boolean): Promise { ensureDirs(); const onlyArg = process.argv.find((arg) => arg.startsWith("--only=")); const only = onlyArg?.slice("--only=".length) ?? "missing"; - if (!["missing", "all", "style", "world", "tree", "flower", "characters", "hero", "elder", "slime"].includes(only)) { - throw new Error("--only must be one of missing, all, style, world, tree, flower, characters, hero, elder, slime"); + if (!["missing", "all", "style", "world", "tree", "flower", "characters", "hero", "walk", "elder", "slime"].includes(only)) { + throw new Error("--only must be one of missing, all, style, world, tree, flower, characters, hero, walk, elder, slime"); } const previous = existsSync(MANIFEST) ? JSON.parse(readFileSync(MANIFEST, "utf8")) as { records?: Record } @@ -528,6 +636,7 @@ async function generate(force: boolean): Promise { const charactersMissing = characterFiles.some((file) => !existsSync(join(SOURCE, file))); const charactersSelected = only === "all" || only === "characters" || only === "hero" || only === "elder" || only === "slime" || charactersMissing; + let heroRegenerated = false; if (charactersSelected) { const forceHero = force && (only === "all" || only === "characters" || only === "hero"); if (forceHero || !existsSync(join(SOURCE, "hero-south-reference.png"))) { @@ -537,6 +646,7 @@ async function generate(force: boolean): Promise { .some((file) => !existsSync(join(SOURCE, file))); if (forceHero || rotationsMissing) { records.heroRotations = await generateHeroRotations(readFileSync(join(SOURCE, "hero-south-reference.png"))); + heroRegenerated = true; } const forceElder = force && (only === "all" || only === "characters" || only === "elder"); if (forceElder || !existsSync(join(SOURCE, "elder.png"))) { @@ -547,9 +657,22 @@ async function generate(force: boolean): Promise { records.slime = await generateMapSprite("slime.png", GENERATION.assets.slime, SLIME); } } + const walkRecord = records.heroWalk as { characterId?: unknown } | undefined; + const walkMissing = WALK_SOURCE_FILES.some((file) => !existsSync(join(SOURCE, file))) + || typeof walkRecord?.characterId !== "string"; + const walkSelected = only === "all" || only === "characters" || only === "hero" || only === "walk" || walkMissing; + const forceWalk = heroRegenerated || (force && ["all", "characters", "hero", "walk"].includes(only)); + if (walkSelected && (forceWalk || walkMissing)) { + const rotations = records.heroRotations as { characterId?: unknown } | undefined; + if (typeof rotations?.characterId !== "string") { + throw new Error("hero walk needs a generated PixelLab character; run --only=hero first"); + } + records.heroWalk = await generateHeroWalk(rotations.characterId); + } delete records.backgroundAtlas; const sourceHashes = Object.fromEntries( - ["style-anchor.png", ...worldFiles, ...characterFiles].map((file) => [file, sha256(readFileSync(join(SOURCE, file)))]), + ["style-anchor.png", ...worldFiles, ...characterFiles, ...WALK_SOURCE_FILES] + .map((file) => [file, sha256(readFileSync(join(SOURCE, file)))]), ); writeFileSync(MANIFEST, `${JSON.stringify({ ...GENERATION, records, sourceHashes }, null, 2)}\n`); await build(false); @@ -901,6 +1024,70 @@ async function normalizeSprite( return indicesFromContext(ctx, size, size, palette, true); } +async function normalizeWalkDirection(direction: typeof WALK_DIRECTIONS[number]): Promise { + const sources: Array<{ + image: Awaited>; + width: number; + height: number; + minX: number; + minY: number; + maxX: number; + maxY: number; + }> = []; + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + const path = join(SOURCE, `hero-walk-${direction}-${frame}.png`); + if (!existsSync(path)) throw new Error(`missing source asset: ${path}; run vapor:rpg:assets:generate --only=walk`); + const image = await loadImage(path); + const canvas = createCanvas(image.width, image.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + const raw = ctx.getImageData(0, 0, image.width, image.height) as ImageDataLike; + let minX = image.width; + let minY = image.height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < image.height; y++) for (let x = 0; x < image.width; x++) { + if (raw.data[(y * image.width + x) * 4 + 3] < 128) continue; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + if (maxX < minX || maxY < minY) throw new Error(`${path} has no opaque actor pixels`); + if (minX === 0 || minY === 0 || maxX === image.width - 1 || maxY === image.height - 1) { + throw new Error(`${path} actor touches the source canvas edge; reject scene/background leakage`); + } + sources.push({ image, width: image.width, height: image.height, minX, minY, maxX, maxY }); + } + const sourceWidth = sources[0].width; + const sourceHeight = sources[0].height; + if (sources.some((source) => source.width !== sourceWidth || source.height !== sourceHeight)) { + throw new Error(`hero walk ${direction} source canvases must have identical dimensions`); + } + + // One horizontal crop and one scale per direction keep the torso anchored + // while limbs alternate. Each frame's lowest contacting foot is then placed + // on y=31, so generated canvas drift never becomes world-space vibration. + const minX = Math.min(...sources.map((source) => source.minX)); + const maxX = Math.max(...sources.map((source) => source.maxX)); + const cropWidth = maxX - minX + 1; + const maxHeight = Math.max(...sources.map((source) => source.maxY - source.minY + 1)); + const scale = Math.min(28 / cropWidth, 30 / maxHeight); + const width = Math.max(1, Math.round(cropWidth * scale)); + const x = Math.floor((WORLD_ACTOR_SIZE - width) / 2); + return sources.map((source) => { + const cropHeight = source.maxY - source.minY + 1; + const height = Math.max(1, Math.round(cropHeight * scale)); + const y = WORLD_ACTOR_SIZE - height; + const target = createCanvas(WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE); + const ctx = target.getContext("2d"); + ctx.clearRect(0, 0, WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(source.image, minX, source.minY, cropWidth, cropHeight, x, y, width, height); + return indicesFromContext(ctx, WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE, HERO, true); + }); +} + function polishSlime(slime: Uint8Array, size: number): void { const dark = 2; const mid = 3; @@ -956,6 +1143,8 @@ function polishSlime(slime: Uint8Array, size: number): void { async function buildActors(): Promise<{ png: Buffer; pixels: Uint8Array[]; + walkPng: Buffer; + walkPixels: Uint8Array[]; battlePng: Buffer; battlePixels: Uint8Array[]; }> { @@ -987,6 +1176,27 @@ async function buildActors(): Promise<{ const palette = i < 4 ? HERO : i === 4 ? ELDER : SLIME; drawIndices(ctx, pixels[i], WORLD_ACTOR_SIZE, WORLD_ACTOR_SIZE, palette, i * WORLD_ACTOR_SIZE, 0, true); } + const walkPixels: Uint8Array[] = []; + for (const direction of WALK_DIRECTIONS) { + walkPixels.push(...await normalizeWalkDirection(direction)); + } + const walkCanvas = createCanvas(WALK_FRAME_COUNT * WORLD_ACTOR_SIZE, WALK_DIRECTIONS.length * WORLD_ACTOR_SIZE); + const walkCtx = walkCanvas.getContext("2d"); + walkCtx.clearRect(0, 0, walkCanvas.width, walkCanvas.height); + for (let direction = 0; direction < WALK_DIRECTIONS.length; direction++) { + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + drawIndices( + walkCtx, + walkPixels[direction * WALK_FRAME_COUNT + frame], + WORLD_ACTOR_SIZE, + WORLD_ACTOR_SIZE, + HERO, + frame * WORLD_ACTOR_SIZE, + direction * WORLD_ACTOR_SIZE, + true, + ); + } + } const battlePixels = [ await normalizeSprite(join(SOURCE, "hero-east.png"), HERO, BATTLE_ACTOR_SIZE, 48, 60), await normalizeSprite(join(SOURCE, "slime.png"), SLIME, BATTLE_ACTOR_SIZE, 54, 44), @@ -1000,6 +1210,8 @@ async function buildActors(): Promise<{ return { png: canvas.toBuffer("image/png"), pixels, + walkPng: walkCanvas.toBuffer("image/png"), + walkPixels, battlePng: battleCanvas.toBuffer("image/png"), battlePixels, }; @@ -1040,9 +1252,9 @@ function wordsForBackground(pixels: Uint8Array): number[] { return words; } -function wordsForActors(actors: Uint8Array[], battleActors: Uint8Array[]): number[] { +function wordsForActors(actors: Uint8Array[], walkActors: Uint8Array[], battleActors: Uint8Array[]): number[] { const words: number[] = []; - for (const pixels of actors) { + for (const pixels of [...actors, ...walkActors]) { for (let ty = 0; ty < 4; ty++) for (let tx = 0; tx < 4; tx++) words.push(...packTile(pixels, WORLD_ACTOR_SIZE, tx * 8, ty * 8)); } for (const pixels of battleActors) { @@ -1059,7 +1271,12 @@ function formatWords(name: string, words: readonly number[], columns = 8): strin return `static const u16 ${name}[${words.length}] = {\n${lines.join("\n")}\n};`; } -function generatedHeader(bgPixels: Uint8Array, actorPixels: Uint8Array[], battlePixels: Uint8Array[]): string { +function generatedHeader( + bgPixels: Uint8Array, + actorPixels: Uint8Array[], + walkPixels: Uint8Array[], + battlePixels: Uint8Array[], +): string { const paddedPalette = (palette: Palette): number[] => [ ...palette.map(bgr555), ...Array(Math.max(0, 16 - palette.length)).fill(0), @@ -1075,8 +1292,12 @@ function generatedHeader(bgPixels: Uint8Array, actorPixels: Uint8Array[], battle `#define VP_RPG_UI_TILE_COUNT ${UI_TILE_NAMES.length}`, "#define VP_RPG_UI_TILE_BASE (VP_RPG_WORLD_TILE_FRAME_COUNT * VP_RPG_WORLD_TILE_FRAME_TILES)", "#define VP_RPG_BG_TILE_COUNT (VP_RPG_UI_TILE_BASE + VP_RPG_UI_TILE_COUNT)", - `#define VP_RPG_WORLD_ACTOR_FRAME_COUNT ${ACTOR_NAMES.length}`, "#define VP_RPG_WORLD_ACTOR_FRAME_TILES 16", + `#define VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT ${ACTOR_NAMES.length}`, + `#define VP_RPG_WORLD_WALK_DIRECTION_COUNT ${WALK_DIRECTIONS.length}`, + `#define VP_RPG_WORLD_WALK_FRAMES ${WALK_FRAME_COUNT}`, + "#define VP_RPG_WORLD_WALK_TILE_BASE (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES)", + "#define VP_RPG_WORLD_ACTOR_FRAME_COUNT (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT + VP_RPG_WORLD_WALK_DIRECTION_COUNT * VP_RPG_WORLD_WALK_FRAMES)", "#define VP_RPG_WORLD_ELDER_TILE (4 * VP_RPG_WORLD_ACTOR_FRAME_TILES)", "#define VP_RPG_WORLD_SLIME_TILE (5 * VP_RPG_WORLD_ACTOR_FRAME_TILES)", "#define VP_RPG_BATTLE_HERO_TILE (VP_RPG_WORLD_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES)", @@ -1089,14 +1310,19 @@ function generatedHeader(bgPixels: Uint8Array, actorPixels: Uint8Array[], battle "", formatWords("vp_rpg_bg_tiles", wordsForBackground(bgPixels)), "", - formatWords("vp_rpg_obj_tiles", wordsForActors(actorPixels, battlePixels)), + formatWords("vp_rpg_obj_tiles", wordsForActors(actorPixels, walkPixels, battlePixels)), "", "#endif", "", ].join("\n"); } -function assertAssetSemantics(bg: Uint8Array, actors: Uint8Array[], battleActors: Uint8Array[]): void { +function assertAssetSemantics( + bg: Uint8Array, + actors: Uint8Array[], + walkActors: Uint8Array[], + battleActors: Uint8Array[], +): void { const bounds = (frame: Uint8Array, size: number): { width: number; height: number } => { let minX = size; let minY = size; @@ -1171,6 +1397,55 @@ function assertAssetSemantics(bg: Uint8Array, actors: Uint8Array[], battleActors const slimeTeals = new Set(actors[5].filter((pixel) => pixel >= 2 && pixel <= 5)); if (slimeTeals.size < 3) throw new Error("slime must retain at least three teal shades"); if (actors[5].filter((pixel) => pixel === 6).length !== 2) throw new Error("slime must have exactly two white eye pixels"); + if (walkActors.length !== WALK_DIRECTIONS.length * WALK_FRAME_COUNT || + walkActors.some((frame) => frame.length !== WORLD_ACTOR_SIZE ** 2)) { + throw new Error("hero walk sheet must contain four frames for each of four directions"); + } + for (let direction = 0; direction < WALK_DIRECTIONS.length; direction++) { + const sequence = walkActors.slice(direction * WALK_FRAME_COUNT, (direction + 1) * WALK_FRAME_COUNT); + const hashes = new Set(); + const heights: number[] = []; + const centers: number[] = []; + for (const [frameIndex, frame] of sequence.entries()) { + const visible = frame.filter((pixel) => pixel !== 0).length; + if (visible < 260) throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} is too sparse (${visible} pixels)`); + if (!frame.slice(31 * WORLD_ACTOR_SIZE).some((pixel) => pixel !== 0)) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} does not touch y=31`); + } + const box = bounds(frame, WORLD_ACTOR_SIZE); + if (box.width < 16 || box.height < 26) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} bbox is too small (${box.width}x${box.height})`); + } + if (frame.some((pixel) => pixel >= HERO.length)) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} exceeds the hero OBJ palette`); + } + if (connectedComponents(frame, WORLD_ACTOR_SIZE) !== 1) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frame ${frameIndex} must be one connected silhouette`); + } + let weightedX = 0; + for (let at = 0; at < frame.length; at++) if (frame[at] !== 0) weightedX += at % WORLD_ACTOR_SIZE; + centers.push(weightedX / visible); + heights.push(box.height); + hashes.add(sha256(frame)); + } + if (hashes.size !== WALK_FRAME_COUNT) throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} frames must be distinct`); + if (Math.max(...heights) - Math.min(...heights) > 3) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} height drifts by more than three pixels`); + } + if (Math.max(...centers) - Math.min(...centers) > 3) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} horizontal center drifts by more than three pixels`); + } + for (let frame = 0; frame < WALK_FRAME_COUNT; frame++) { + const next = (frame + 1) % WALK_FRAME_COUNT; + let changed = 0; + for (let at = 0; at < sequence[frame].length; at++) { + if (sequence[frame][at] !== sequence[next][at]) changed++; + } + if (changed < 40 || changed > 520) { + throw new Error(`hero walk ${WALK_DIRECTIONS[direction]} transition ${frame}->${next} changes ${changed} pixels`); + } + } + } if (battleActors.length !== 2 || battleActors.some((frame) => frame.length !== BATTLE_ACTOR_SIZE ** 2)) { throw new Error("battle actor sheet must contain two 64x64 frames"); } @@ -1192,11 +1467,12 @@ async function build(check: boolean): Promise { ensureDirs(); const background = await buildBackground(); const actors = await buildActors(); - assertAssetSemantics(background.pixels, actors.pixels, actors.battlePixels); - const header = generatedHeader(background.pixels, actors.pixels, actors.battlePixels); + assertAssetSemantics(background.pixels, actors.pixels, actors.walkPixels, actors.battlePixels); + const header = generatedHeader(background.pixels, actors.pixels, actors.walkPixels, actors.battlePixels); const targets: Array<[string, Buffer | string]> = [ [join(FINAL, "background.png"), background.png], [join(FINAL, "actors.png"), actors.png], + [join(FINAL, "hero-walk.png"), actors.walkPng], [join(FINAL, "battle-actors.png"), actors.battlePng], [join(FINAL, "palette-guide.png"), paletteGuidePng()], [HEADER, header], @@ -1208,12 +1484,13 @@ async function build(check: boolean): Promise { const bytes = typeof expected === "string" ? Buffer.from(expected) : expected; if (!actual.equals(bytes)) throw new Error(`generated asset is stale: ${path}`); } - console.log(`RPG assets OK: 54 BG tiles, ${ACTOR_NAMES.length} 32x32 world actors + 2 64x64 battle actors, four fixed 4bpp palettes`); + console.log(`RPG assets OK: 54 BG tiles, ${ACTOR_NAMES.length} static + ${actors.walkPixels.length} walking 32x32 world actors + 2 64x64 battle actors, four fixed 4bpp palettes`); return; } for (const [path, contents] of targets) writeFileSync(path, contents); console.log(`Built ${join(FINAL, "background.png")}`); console.log(`Built ${join(FINAL, "actors.png")}`); + console.log(`Built ${join(FINAL, "hero-walk.png")}`); console.log(`Built ${join(FINAL, "battle-actors.png")}`); console.log(`Built ${HEADER}`); } diff --git a/vapor/tests/compiler.test.ts b/vapor/tests/compiler.test.ts index 51408945..df15ee42 100644 --- a/vapor/tests/compiler.test.ts +++ b/vapor/tests/compiler.test.ts @@ -57,6 +57,7 @@ describe("pocket vapor compiler", () => { expect(a.c).toBe(b.c); expect(a.rpgEnabled).toBe(false); expect(a.c).toContain("vp_mark"); + expect(a.c).toContain("vp_row_clear"); expect(a.graph).toContain("visible: view(maxLen 12)"); expect(a.plan).toContain("pools"); }); @@ -193,6 +194,37 @@ export default () => { ); }); + test("official Vue Vapor onFrame and BTN imports lower to the fixed GBA frame hook", () => { + const source = `${HEADER} +import { onFrame } from "@pocketjs/framework/vue-vapor/lifecycle"; +import { BTN } from "@pocketjs/framework/vue-vapor/input"; +export default () => { + const count = ref(0); + onFrame((buttons) => { + if (buttons & (BTN.RIGHT | BTN.CROSS)) count.value = count.value + 2; + }); + onButton((b) => { + if (b === Button.A) count.value = count.value + 1; + }); + return (<>{count.value}); +}; +`; + const app = compileVaporApp("frame.tsx", source, "FRAME", "gba"); + expect(app.c).toContain("void app_on_frame(u32 buttons)"); + expect(app.c).toMatch(/app_on_frame[\s\S]*buttons_arg & \(32 \| 16384\)/); + expect(app.buttonsUsed).toEqual([0, 4]); + expect(app.graph).toContain("frame hook: fixed (gba)"); + expect(() => compileVaporApp("frame.tsx", source, "FRAME", "gb")).toThrow( + /onFrame is currently supported only on the gba target/, + ); + }); + + test("negative numeric ref seeds survive native initialization", () => { + const source = minimal("const idle = ref(-1);"); + const app = compileVaporApp("negative-seed.tsx", source, "NEGATIVE", "gba"); + expect(app.c).toContain("g_idle = -1;"); + }); + test("oracle repeat dispatch stays separate from physical press edges", () => { let presses = 0; let repeats = 0; diff --git a/vapor/tests/harness/mgba_runner.c b/vapor/tests/harness/mgba_runner.c index 01d5a7c6..50273dac 100644 --- a/vapor/tests/harness/mgba_runner.c +++ b/vapor/tests/harness/mgba_runner.c @@ -10,6 +10,7 @@ * run release frames * R read 1/2/4 bytes little-endian * D read len bytes, emit as hex string + * H hash the current 240x160 video buffer * S screenshot (PPM P6) * Output: one JSON object on stdout: {"ok":true,"reads":{...}}. */ @@ -60,6 +61,23 @@ static void screenshot(const char *path) { fclose(f); } +static uint32_t video_hash(void) { + uint32_t hash = 2166136261u; + unsigned i; + for (i = 0; i < vw * vh; i++) { + uint32_t pixel = videoBuffer[i]; + hash ^= pixel & 0xff; + hash *= 16777619u; + hash ^= (pixel >> 8) & 0xff; + hash *= 16777619u; + hash ^= (pixel >> 16) & 0xff; + hash *= 16777619u; + hash ^= (pixel >> 24) & 0xff; + hash *= 16777619u; + } + return hash; +} + int main(int argc, char **argv) { FILE *sc; char line[1024]; @@ -136,6 +154,11 @@ int main(int argc, char **argv) { for (i = 0; i < len; i++) printf("%02x", core->busRead8(core, addr + (unsigned)i)); printf("\""); first_read = 0; + } else if (op == 'H') { + char name[256]; + sscanf(line + 1, "%255s", name); + printf("%s\"%s\":%u", first_read ? "" : ",", name, video_hash()); + first_read = 0; } else if (op == 'S') { char path[512]; sscanf(line + 1, "%511s", path); diff --git a/vapor/tests/rpg-assets.test.ts b/vapor/tests/rpg-assets.test.ts index 2252d2c4..f3b5fa9f 100644 --- a/vapor/tests/rpg-assets.test.ts +++ b/vapor/tests/rpg-assets.test.ts @@ -29,6 +29,15 @@ const ELDER = [ "156,165,173", "198,214,222", "255,255,255", "66,132,255", ]; const SLIME = ["24,41,74", "0,90,90", "0,173,173", "57,214,198", "148,247,222", "255,255,255"]; +const WALK_FILES = ["south", "north", "west", "east"] + .flatMap((direction) => Array.from({ length: 4 }, (_, frame) => `hero-walk-${direction}-${frame}.png`)); +const SOURCE_FILES = [ + "style-anchor.png", + "grass.png", "path.png", "wall.png", "water.png", "tree.png", "flower.png", + "hero-south-reference.png", "hero-south.png", "hero-north.png", "hero-west.png", "hero-east.png", + "elder.png", "slime.png", + ...WALK_FILES, +].sort(); async function rgba(path: string): Promise<{ width: number; height: number; data: Uint8ClampedArray }> { const image = await loadImage(path); @@ -52,7 +61,11 @@ describe("Vapor Quest GBA art pipeline", () => { expect(generatedWordCount("vp_rpg_bg_palette")).toBe(16); expect(generatedWordCount("vp_rpg_obj_palettes")).toBe(48); expect(generatedWordCount("vp_rpg_bg_tiles")).toBe(54 * 16); - expect(generatedWordCount("vp_rpg_obj_tiles")).toBe(224 * 16); + expect(generatedWordCount("vp_rpg_obj_tiles")).toBe(480 * 16); + const header = readFileSync(HEADER, "utf8"); + expect(header).toContain("#define VP_RPG_WORLD_WALK_DIRECTION_COUNT 4"); + expect(header).toContain("#define VP_RPG_WORLD_WALK_FRAMES 4"); + expect(header).toContain("#define VP_RPG_WORLD_WALK_TILE_BASE (VP_RPG_WORLD_STATIC_ACTOR_FRAME_COUNT * VP_RPG_WORLD_ACTOR_FRAME_TILES)"); }); test("generation provenance is complete and contains no credential", () => { @@ -61,17 +74,55 @@ describe("Vapor Quest GBA art pipeline", () => { const manifest = JSON.parse(text) as { provider: string; apiBase: string; - records: { world: Record; heroRotations: { characterId: string } }; + assets: { + heroWalk: { + endpoint: string; + templateAnimationId: string; + animationName: string; + directions: string[]; + frameCount: number; + prompt: string; + seed: number; + }; + }; + records: { + world: Record; + heroRotations: { characterId: string }; + heroWalk: { + characterId: string; + animationGroupId: string; + backgroundJobIds: Record; + hashes: Record; + }; + }; sourceHashes: Record; }; expect(manifest.provider).toBe("PixelLab"); expect(manifest.apiBase).toBe("https://api.pixellab.ai/v2"); expect(Object.keys(manifest.records.world).sort()).toEqual(["barriers", "field", "flower", "tree"]); expect(manifest.records.heroRotations.characterId).toMatch(/^[0-9a-f-]{36}$/); + expect(manifest.assets.heroWalk).toEqual({ + endpoint: "/characters/animations", + templateAnimationId: "walking-4-frames", + directions: ["south", "north", "west", "east"], + frameCount: 4, + animationName: "Vapor Quest Walk", + prompt: expect.any(String), + seed: 23071, + }); + expect(manifest.records.heroWalk.characterId).toBe(manifest.records.heroRotations.characterId); + expect(manifest.records.heroWalk.animationGroupId).toMatch(/^[0-9a-f-]{36}$/); + expect(Object.keys(manifest.records.heroWalk.backgroundJobIds)).toEqual(["south", "north", "west", "east"]); + expect(Object.keys(manifest.sourceHashes).sort()).toEqual(SOURCE_FILES); + expect(Object.keys(manifest.records.heroWalk.hashes)).toEqual(WALK_FILES); + for (const id of Object.values(manifest.records.heroWalk.backgroundJobIds)) expect(id).toMatch(/^[0-9a-f-]{36}$/); for (const [file, expected] of Object.entries(manifest.sourceHashes)) { const actual = createHash("sha256").update(readFileSync(join(SOURCE, file))).digest("hex"); expect(actual, file).toBe(expected); } + for (const file of WALK_FILES) { + expect(manifest.records.heroWalk.hashes[file], file).toBe(manifest.sourceHashes[file]); + } }); test("the background sheet combines ten 16x16 world cells with fourteen 8x8 UI tiles", async () => { @@ -170,7 +221,70 @@ describe("Vapor Quest GBA art pipeline", () => { expect(frameColors[5].has("255,255,255")).toBe(true); }); + test("four directional walk cycles stay anchored, distinct and inside the hero palette", async () => { + const image = await rgba(join(FINAL, "hero-walk.png")); + expect([image.width, image.height]).toEqual([128, 128]); + const allowed = new Set(HERO); + for (let direction = 0; direction < 4; direction++) { + const frames: string[][] = []; + const hashes = new Set(); + const centers: number[] = []; + for (let frame = 0; frame < 4; frame++) { + const source = await rgba(join(SOURCE, `hero-walk-${["south", "north", "west", "east"][direction]}-${frame}.png`)); + expect([source.width, source.height]).toEqual([88, 88]); + expect(source.data[3]).toBe(0); + expect(source.data[source.data.length - 1]).toBe(0); + + const pixels: string[] = []; + const colors = new Set(); + const bytes: number[] = []; + let visible = 0; + let weightedX = 0; + let minX = 32; + let minY = 32; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const at = ((direction * 32 + y) * image.width + frame * 32 + x) * 4; + const alpha = image.data[at + 3]; + expect(alpha === 0 || alpha === 255).toBe(true); + const color = alpha === 0 ? "" : `${image.data[at]},${image.data[at + 1]},${image.data[at + 2]}`; + pixels.push(color); + bytes.push(image.data[at], image.data[at + 1], image.data[at + 2], alpha); + if (alpha === 0) continue; + expect(allowed.has(color)).toBe(true); + colors.add(color); + visible++; + weightedX += x; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + expect(visible).toBeGreaterThanOrEqual(260); + expect(maxY).toBe(31); + expect(maxX - minX + 1).toBeGreaterThanOrEqual(16); + expect(maxY - minY + 1).toBeGreaterThanOrEqual(28); + expect(colors.size).toBeGreaterThanOrEqual(8); + centers.push(weightedX / visible); + frames.push(pixels); + hashes.add(createHash("sha256").update(Uint8Array.from(bytes)).digest("hex")); + } + expect(hashes.size).toBe(4); + expect(Math.max(...centers) - Math.min(...centers)).toBeLessThanOrEqual(1); + for (let frame = 0; frame < 4; frame++) { + const changed = frames[frame].filter((pixel, at) => pixel !== frames[(frame + 1) % 4][at]).length; + expect(changed).toBeGreaterThanOrEqual(90); + expect(changed).toBeLessThanOrEqual(400); + } + } + }); + test("battle reuses the same actors and palettes at a readable 64x64 scale", async () => { + expect(createHash("sha256").update(readFileSync(join(FINAL, "battle-actors.png"))).digest("hex")) + .toBe("854493423c1c2c070bc7ca70c27fc4049e8ed9630e9c20a0508b3c6b9f14a521"); const image = await rgba(join(FINAL, "battle-actors.png")); expect([image.width, image.height]).toEqual([128, 64]); for (let frame = 0; frame < 2; frame++) { diff --git a/vapor/tests/rpg.test.ts b/vapor/tests/rpg.test.ts index eef3ede8..003b6467 100644 --- a/vapor/tests/rpg.test.ts +++ b/vapor/tests/rpg.test.ts @@ -22,10 +22,14 @@ let app: CompiledApp; let runReads: Record; let holdReads: Record; let cameraReads: Record; -let repeatBoundaryX: number[]; +let motionReads: Record; +let motionReplayReads: Record; +let cameraMotionReads: Record; function press(button: number): string { - return `P ${(1 << button).toString(16)} 2 4`; + // A cardinal step needs one acceptance tick plus eight 2px motion ticks. + // The release tail also absorbs libmGBA's one-frame key-sampling boundary. + return `P ${(1 << button).toString(16)} 2 10`; } function stateAddress(name: string): number { @@ -60,6 +64,49 @@ function littleEndianU16(hex: string, byteOffset: number): number { return Number.parseInt(`${hex.slice(at + 2, at + 4)}${hex.slice(at, at + 2)}`, 16); } +function signed32(value: number): number { + return value > 0x7fffffff ? value - 0x1_0000_0000 : value; +} + +function hexAscii(hex: string): string { + let out = ""; + for (let at = 0; at < hex.length; at += 2) { + out += String.fromCharCode(Number.parseInt(hex.slice(at, at + 2), 16)); + } + return out; +} + +interface MotionSample { + frame: number; + x: number; + walk: number; + oam: string; + scroll?: number; + video?: number; +} + +function semanticMotion(reads: Record): MotionSample[] { + const samples: MotionSample[] = []; + let lastFrame = -1; + for (let tick = 0; tick < 20; tick++) { + const frame = reads[`t${tick}_frame`] as number; + if (frame === lastFrame) continue; + lastFrame = frame; + const sample: MotionSample = { + frame, + x: reads[`t${tick}_x`] as number, + walk: signed32(reads[`t${tick}_walk`] as number), + oam: reads[`t${tick}_oam`] as string, + }; + const scroll = reads[`t${tick}_scroll`]; + if (typeof scroll === "number") sample.scroll = scroll; + const video = reads[`t${tick}_video`]; + if (typeof video === "number") sample.video = video; + samples.push(sample); + } + return samples; +} + async function countPpmColors(path: string): Promise { const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); const head = new TextDecoder().decode(bytes.slice(0, 64)); @@ -90,6 +137,7 @@ beforeAll(async () => { "heroHp", "enemyHp", "battleCursor", + "walkPx", ] as const; const worldShot = join(OUT, "rpg-world.ppm"); const dialogShot = join(OUT, "rpg-dialog.ppm"); @@ -100,8 +148,9 @@ beforeAll(async () => { lines.push(`S ${worldShot}`); lines.push("D world_bg1 0x06004800 2048"); lines.push("D world_oam 0x07000000 24"); + lines.push("R world_dispcnt 0x04000000 2"); lines.push("D asset_bg_tiles 0x06008000 1728"); - lines.push("D asset_obj_tiles 0x06010000 7168"); + lines.push("D asset_obj_tiles 0x06010000 15360"); lines.push("D asset_bg_palette 0x050001e0 32"); lines.push("D asset_obj_palettes 0x05000200 96"); @@ -127,9 +176,15 @@ beforeAll(async () => { readState(lines, "accepted", ["mode", "quest", "dialog", "choice"]); lines.push(press(Button.A)); - // Return to y=2 and walk east onto S at (8,2). + // Return to y=2 and approach S at (8,2). The final step is sampled in + // flight: integer occupancy and its battle event must stay at x=7/world + // until the full 16px transition arrives. lines.push(press(Button.Up)); - for (let i = 0; i < 5; i++) lines.push(press(Button.Right)); + for (let i = 0; i < 4; i++) lines.push(press(Button.Right)); + readState(lines, "slimeBefore", ["mode", "playerX", "playerY", "walkPx"]); + lines.push(`P ${(1 << Button.Right).toString(16)} 5 0`); + readState(lines, "slimeMid", ["mode", "playerX", "playerY", "walkPx"]); + lines.push("A 10"); readState(lines, "battle", ["mode", "playerX", "playerY", "quest", "heroHp", "enemyHp", "battleCursor"]); lines.push(`S ${battleShot}`); lines.push("D battle_bg1 0x06004800 2048"); @@ -159,19 +214,22 @@ beforeAll(async () => { expect(parsed.ok).toBe(true); runReads = parsed.reads; - // A single held direction must move immediately, wait for the normalized - // delay, then continue at the fixed repeat cadence. Releasing must stop it. + // Held movement is sampled every semantic frame. Release in mid-step must + // finish that accepted cell and then settle at idle rather than freezing + // on a sub-cell offset. const holdScenario = join(OUT, "rpg-hold-tape.txt"); await Bun.write( holdScenario, [ "A 8", - `P ${(1 << Button.Right).toString(16)} 25 4`, + `P ${(1 << Button.Right).toString(16)} 22 0`, `R held_x 0x${stateAddress("playerX").toString(16)} 4`, `R held_y 0x${stateAddress("playerY").toString(16)} 4`, `R held_facing 0x${stateAddress("facing").toString(16)} 4`, - "A 30", + `R held_walk 0x${stateAddress("walkPx").toString(16)} 4`, + "A 16", `R released_x 0x${stateAddress("playerX").toString(16)} 4`, + `R released_walk 0x${stateAddress("walkPx").toString(16)} 4`, "R hold_trips 0x0200000c 1", "", ].join("\n"), @@ -206,30 +264,67 @@ beforeAll(async () => { expect(cameraParsed.ok).toBe(true); cameraReads = cameraParsed.reads; - // Host video scheduling means setKeys() and the runtime input loop do not - // share a frame boundary. Adjacent holds pin the first two observed repeats. - repeatBoundaryX = []; - for (const hold of [14, 15, 22, 23]) { - const boundaryScenario = join(OUT, `rpg-hold-${hold}-tape.txt`); - await Bun.write( - boundaryScenario, - [ - "A 8", - `P ${(1 << Button.Right).toString(16)} ${hold} 4`, - `R x 0x${stateAddress("playerX").toString(16)} 4`, - "R trips 0x0200000c 1", - "", - ].join("\n"), + // Sample one continuous hold after every libmGBA video frame. DBG_FRAME + // lets assertions discard only the initial host key-sampling duplicate; + // every semantic tick then has matching reactive state and committed OAM. + const motionScenario = join(OUT, "rpg-motion-tape.txt"); + const motionShot = join(OUT, "rpg-motion.ppm"); + const motionLines = ["A 8"]; + for (let tick = 0; tick < 20; tick++) { + motionLines.push( + `P ${(1 << Button.Right).toString(16)} 1 0`, + `R t${tick}_frame 0x02000004 4`, + `R t${tick}_x 0x${stateAddress("playerX").toString(16)} 4`, + `R t${tick}_walk 0x${stateAddress("walkPx").toString(16)} 4`, + `D t${tick}_oam 0x07000000 16`, + `H t${tick}_video`, ); - const boundaryOutput = await $`${RUNNER} ${ROM} ${boundaryScenario}`.text(); - const boundaryParsed = JSON.parse(boundaryOutput) as { - ok: boolean; - reads: Record; - }; - expect(boundaryParsed.ok).toBe(true); - expect(boundaryParsed.reads.trips).toBe(0); - repeatBoundaryX.push(boundaryParsed.reads.x as number); + if (tick === 6) motionLines.push(`D t${tick}_hud 0x02000100 30`); + if (tick === 6) motionLines.push(`S ${motionShot}`); } + motionLines.push("R motion_trips 0x0200000c 1", ""); + await Bun.write(motionScenario, motionLines.join("\n")); + const motionOutput = await $`${RUNNER} ${ROM} ${motionScenario}`.text(); + const motionParsed = JSON.parse(motionOutput) as { + ok: boolean; + reads: Record; + }; + expect(motionParsed.ok).toBe(true); + motionReads = motionParsed.reads; + const motionReplayOutput = await $`${RUNNER} ${ROM} ${motionScenario}`.text(); + const motionReplayParsed = JSON.parse(motionReplayOutput) as { + ok: boolean; + reads: Record; + }; + expect(motionReplayParsed.ok).toBe(true); + motionReplayReads = motionReplayParsed.reads; + + // Put the hero on the camera focus at (8,4), then sample a full step. The + // camera scroll should advance by the same 2px timeline while hero OAM stays + // pinned at the focus pixel. + const cameraMotionScenario = join(OUT, "rpg-camera-motion-tape.txt"); + const cameraMotionLines = ["A 8", press(Button.Down), press(Button.Down)]; + for (let i = 0; i < 6; i++) cameraMotionLines.push(press(Button.Right)); + for (let tick = 0; tick < 20; tick++) { + cameraMotionLines.push( + `P ${(1 << Button.Right).toString(16)} 1 0`, + `R t${tick}_frame 0x02000004 4`, + `R t${tick}_x 0x${stateAddress("playerX").toString(16)} 4`, + `R t${tick}_walk 0x${stateAddress("walkPx").toString(16)} 4`, + `R t${tick}_scroll 0x020005c0 2`, + `D t${tick}_oam 0x07000000 16`, + `H t${tick}_video`, + ); + } + cameraMotionLines.push("R camera_motion_trips 0x0200000c 1", ""); + await Bun.write(cameraMotionScenario, cameraMotionLines.join("\n")); + const cameraMotionOutput = await $`${RUNNER} ${ROM} ${cameraMotionScenario}`.text(); + const cameraMotionParsed = JSON.parse(cameraMotionOutput) as { + ok: boolean; + reads: Record; + }; + expect(cameraMotionParsed.ok).toBe(true); + cameraMotionReads = cameraMotionParsed.reads; }, 120000); describe("Pocket Vapor RPG host", () => { @@ -257,11 +352,19 @@ describe("Pocket Vapor RPG host", () => { expect(app.c).toContain("vp_rpg_blocked(&RPG_RPG_MAP"); expect(app.c).toContain("vp_rpg_event_at(&RPG_RPG_MAP"); expect(app.c).toContain("vp_rpg_render(&RPG_RPG_MAP"); + const rpgEffect = [...app.c.matchAll(/static void eff_\d+\(void\) \{([\s\S]*?)\n\}/g)] + .find((match) => match[1].includes("vp_rpg_render")); + expect(rpgEffect).toBeDefined(); + expect(rpgEffect![1]).not.toContain("vp_row_clear"); expect(app.c).toContain("SLIME BLOCKS EAST ROAD."); expect(app.graph).toContain("mode (num)"); expect(app.graph).toContain("questActive:"); - expect(app.plan).toContain("RPG host RAM: 3076 B"); - expect(app.plan).toContain("RPG art: 54 BG tiles + 6 32x32 world and 2 64x64 battle OBJ frames, 4 palette banks; 9024 B ROM"); + expect(app.graph).toContain("walkPx (num)"); + expect(app.graph).toContain("frame hook: fixed (gba)"); + expect(app.c).toContain("void app_on_frame(u32 buttons)"); + expect(app.plan).toContain("RPG host RAM: 3136 B"); + expect(app.plan).toContain("6 static and 16 walking 32x32 world frames"); + expect(app.plan).toContain("17216 B ROM"); }); test("pixel RPG host is explicitly GBA-only", () => { @@ -285,18 +388,55 @@ describe("Pocket Vapor RPG host", () => { expect(() => compileVaporApp(ENTRY, missingProp, "VAPOR QUEST", "gba")).toThrow( /missing required prop "battleCursor"/, ); + + const siblingRow = source.replace( + " {"not native UI"}\n compileVaporApp(ENTRY, siblingRow, "VAPOR QUEST", "gba")).toThrow( + /must be the only root render unit/, + ); }); }); describe("native GBA RPG play tape", () => { - test("holding a direction repeats world movement and release stops it", () => { - expect([holdReads.held_x, holdReads.held_y, holdReads.held_facing]).toEqual([5, 2, 3]); + test("reactive walking advances 2px per semantic frame and cycles native walk tiles", async () => { + const step = semanticMotion(motionReads).filter((sample) => sample.walk >= 0).slice(0, 9); + expect(step.map((sample) => sample.walk)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 0]); + expect(step.map((sample) => sample.x)).toEqual([2, 2, 2, 2, 2, 2, 2, 2, 3]); + expect(step.map((sample) => littleEndianU16(sample.oam, 10) & 0x01ff)).toEqual([ + 24, 26, 28, 30, 32, 34, 36, 38, 40, + ]); + expect(step.map((sample) => littleEndianU16(sample.oam, 12) & 0x03ff)).toEqual([ + 288, 288, 304, 304, 320, 320, 336, 336, 288, + ]); + expect(motionReads.motion_trips).toBe(0); + expect(motionReplayReads).toEqual(motionReads); + expect(hexAscii(motionReads.t6_hud as string).trim()).toBe("QUEST: TALK TO THE ELDER"); + expect(await countPpmColors(join(OUT, "rpg-motion.ppm"))).toBeGreaterThanOrEqual(8); + }); + + test("holding chains steps and release completes only the accepted step", () => { + expect([holdReads.held_x, holdReads.held_y, holdReads.held_facing]).toEqual([4, 2, 3]); + expect(signed32(holdReads.held_walk as number)).toBeGreaterThanOrEqual(0); + expect(signed32(holdReads.held_walk as number)).toBeLessThan(16); expect(holdReads.released_x).toBe(5); + expect(signed32(holdReads.released_walk as number)).toBe(-1); expect(holdReads.hold_trips).toBe(0); }); - test("libmGBA pacing crosses the first two held-movement repeat boundaries", () => { - expect(repeatBoundaryX).toEqual([3, 4, 4, 5]); + test("the fractional camera follows the same timeline without moving hero focus", () => { + const step = semanticMotion(cameraMotionReads).filter((sample) => sample.walk >= 0).slice(0, 9); + expect(step.map((sample) => sample.walk)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 0]); + expect(step.map((sample) => sample.scroll)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 0]); + expect(step.map((sample) => littleEndianU16(sample.oam, 2) & 0x01ff)).toEqual( + Array(9).fill(104), + ); + const physicalFrames = semanticMotion(cameraMotionReads).filter((sample) => sample.walk >= 0); + for (let at = 1; at < physicalFrames.length; at++) { + expect(physicalFrames[at].video).not.toBe(physicalFrames[at - 1].video); + } + expect(cameraMotionReads.camera_motion_trips).toBe(0); }); test("the 15x10 camera follows the player and culls off-window NPCs", () => { @@ -321,6 +461,25 @@ describe("native GBA RPG play tape", () => { ]).toEqual([1, 3, 3, 3, 0, 1]); }); + test("destination occupancy and the Slime event commit only on arrival", () => { + expect([ + value("slimeBefore", "mode"), + value("slimeBefore", "playerX"), + value("slimeBefore", "playerY"), + signed32(value("slimeBefore", "walkPx")), + ]).toEqual([0, 7, 2, -1]); + expect([ + value("slimeMid", "mode"), + value("slimeMid", "playerX"), + value("slimeMid", "playerY"), + ]).toEqual([0, 7, 2]); + expect(signed32(value("slimeMid", "walkPx"))).toBeGreaterThanOrEqual(0); + expect(signed32(value("slimeMid", "walkPx"))).toBeLessThan(16); + expect([value("battle", "mode"), value("battle", "playerX"), value("battle", "playerY")]).toEqual([ + 2, 8, 2, + ]); + }); + test("choice, quest gate, heal, battle, and report form one complete loop", () => { expect(value("offerHeld", "choice")).toBe(1); expect([value("declined", "quest"), value("declined", "dialog")]).toEqual([0, 3]); @@ -374,7 +533,8 @@ describe("native GBA RPG play tape", () => { expect([(worldAttr1[0] & 0x01ff), (worldAttr1[1] & 0x01ff)]).toEqual([56, 24]); }); - test("the dialog hardware window clips enlarged actors behind screen-space UI", () => { + test("hardware windows keep the scrolling world behind fixed HUD and dialog UI", () => { + expect(runReads.world_dispcnt).toBe(0x3340); expect(runReads.dialog_dispcnt).toBe(0x3340); expect(runReads.battle_dispcnt).toBe(0x1340); });