From b43ea2cd3f66639ea320d33a4fe9d2ccaf9a369c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 06:15:49 +0200 Subject: [PATCH] Add portable game cleanup and fix canonical example startup --- README.md | 8 +- docs/evidence/windows-game-cleanup-v1.md | 58 ++++++++++++ docs/game-loop.md | 44 +++++++++ docs/web-target.md | 13 +++ docs/windows-engine-plan.md | 13 ++- examples/dungeon-crawl/main.ts | 12 +-- examples/isometric-rpg/main.ts | 26 +++--- examples/kart-racer/main.ts | 20 ++-- examples/pong/main.ts | 36 ++++---- examples/space-blaster/main.ts | 14 +-- examples/voxel-sandbox/main.ts | 10 +- native/shared/src/ffi_core/game_loop.rs | 7 ++ native/watchos/gen_stubs.js | 2 +- native/watchos/src/lib.rs | 2 + native/web/bloom_glue.js | 76 ++++++---------- native/web/build.cjs | 2 +- native/web/game_loop.mjs | 65 +++++++++++++ native/web/src/game_loop.rs | 9 ++ native/web/src/lib.rs | 14 +-- package.json | 9 ++ scripts/ci-check.sh | 1 + src/core/index.ts | 12 ++- tools/ci/compile_examples.py | 16 ++++ tools/ci/fixtures/native-package.ts | 12 ++- tools/ci/native_package_smoke.py | 8 +- tools/ci/test_compile_examples.py | 20 ++++ tools/ci/test_game_loop.cjs | 111 +++++++++++++++++++++++ tools/ci/test_web_build.cjs | 2 +- tools/ci/web_smoke.py | 1 + tools/validate-docs.js | 1 + 30 files changed, 490 insertions(+), 134 deletions(-) create mode 100644 docs/evidence/windows-game-cleanup-v1.md create mode 100644 docs/game-loop.md create mode 100644 native/web/game_loop.mjs create mode 100644 native/web/src/game_loop.rs create mode 100644 tools/ci/test_game_loop.cjs diff --git a/README.md b/README.md index c91663cf..5456e223 100644 --- a/README.md +++ b/README.md @@ -59,16 +59,22 @@ while (!windowShouldClose()) { Use `runGame()` for code that works on both native and web: ```typescript -import { initWindow, runGame, clearBackground, drawText, Colors } from "@bloomengine/engine"; +import { initWindow, runGame, clearBackground, drawText, closeWindow, Colors } from "@bloomengine/engine"; initWindow(800, 450, "My Game"); runGame((dt) => { clearBackground(Colors.SNOW); drawText("Hello, Bloom!", 190, 200, 20, Colors.DARKGRAY); +}, () => { + closeWindow(); }); ``` +Put resource disposal in the optional cleanup callback. It runs after the final +frame on native and web; code following `runGame()` runs immediately on web. +See the [game-loop contract](docs/game-loop.md) for timing and shutdown behavior. + Build for web: ```bash diff --git a/docs/evidence/windows-game-cleanup-v1.md b/docs/evidence/windows-game-cleanup-v1.md new file mode 100644 index 00000000..be48de37 --- /dev/null +++ b/docs/evidence/windows-game-cleanup-v1.md @@ -0,0 +1,58 @@ +# Shared game cleanup and canonical example startup + +`runGame(updateAndDraw, cleanup)` adds an optional cleanup callback without +changing existing callback-only calls. Native invokes cleanup after the normal +loop exits. The browser scheduler defers it until an active frame ends, cancels +future frames and invokes it once. Frame failures stop scheduling and report an +error; cleanup is attempted. The [public contract](../game-loop.md) records +timing, duplicate-start, focus and fatal-process limitations. + +Pong now uses this loop, disposes audio/window resources in cleanup and toggles +pause with `isKeyPressed(Key.P)`. Its actual runtime check also found an earlier +example correction was incomplete: `Colors.White` and similar names are +undefined because `Colors` exposes uppercase aliases. All 51 such references +in six canonical examples now use valid names. The example inventory checks +references against the exported palette, with invalid mixed-case names as a +negative test. This catches a failure that Perry's native linking did not. + +## Local acceptance + +- A fresh actual installed package simulates Jolt, captures all 16,384 expected + pixels and records exactly one cleanup call in both scene and direct-2D modes, + on Radeon DX12 and Vulkan. All four PNGs are byte-identical. Native compiles + take 270.609 and 3.390 seconds; these are test durations, not FPS. +- A separate real native callback-only probe still renders the same exact + physics frame, returns from the loop and reaches its existing post-loop + cleanup. The optional parameter does not break this older native pattern. +- Compiled Pong input replay records nine frames after staging a held P key. + The old held-key control yields `RPRPRPRPR`; the edge-triggered version yields + `RPPPPPPPP` (R=running, P=paused). The first frame precedes application of the + queued input. Both variants start and close on Radeon DX12. +- All 20 native canonical examples compile and link in 134.884 seconds. This + does not claim runtime acceptance for every example. +- The same corrected Pong source and actual engine complete the web build in + 72.875 seconds, with the new scheduler copied into the assembled distribution. + The 7,829,878-byte engine WASM and generated bindings include the new bridge. + Browser rendering is not claimed by compilation and assembly. +- Nine scheduler tests and nine web-command regression tests pass. The example + gate rejects invalid palette names while accepting the public uppercase keys. + +Repository contracts, the complete quality-contract component, formatting, +strict Clippy and the shared WASM compile check pass. The complete web build +also compiles the web crate and generates its new FFI export. Hosted validation +is retained with the final published evidence. + +The initial Pong runtime failure and the intermediate pause-audit expectation +error remain recorded. That audit initially counted eight frames as eight held +updates; input staging makes it seven. The final replay records the full state +sequence instead of inferring it from an ambiguous last frame. + +The runs use the qualified Perry 0.5.1220 source/runtime profile and local SDK +DXC on PATH. A fresh installed native project has its own Cargo outputs; the +canonical example audit reuses the native build cache. Native presentation, +packaged DXC, actual compiled-game browser startup, fixed updates, a complete +lifecycle and one-command creation remain separate requirements. + +Raw diagnostics are retained under +`tools/quality/out/windows-engine-plan/starter-lifecycle/`. Test executables, +installed trees and generated HTML/WASM stay outside release-evidence archives. diff --git a/docs/game-loop.md b/docs/game-loop.md new file mode 100644 index 00000000..9946ad4d --- /dev/null +++ b/docs/game-loop.md @@ -0,0 +1,44 @@ +# Game loop and cleanup + +Call `initWindow()` and create resources before starting the game. Use +`runGame(updateAndDraw, cleanup)` for a source entry that works on native and +web. The cleanup callback is optional; existing callback-only games keep working. + +The engine begins drawing, calls `updateAndDraw(dt)` and ends drawing once per +frame. Do not add another `beginDrawing()`/`endDrawing()` pair inside this +callback. Native drives a blocking platform loop. Web registers the Perry +closure and returns immediately, then drives it through animation frames. + +Release owned textures, models, audio and physics objects in `cleanup`, not in +code following `runGame()`. Native calls it after the normal loop exits. Web +calls it once after a stop request and after any active frame finishes. Calling +`closeWindow()` again from cleanup is safe. A browser frame error stops further +scheduling, reports the error and attempts cleanup. A fatal native process trap +does not guarantee cleanup. + +One browser game loop can be active at a time. Starting another while the first +is active reports an error. Stop and finish cleanup before starting a new loop; +cancelled callbacks from the old loop cannot run the new game's update. + +## Timing and platform events + +- `dt` is variable frame time in seconds. This API does not add fixed updates, + interpolation or a catch-up policy. Keep deterministic physics steps explicit. +- Pause is game policy. Pong uses `isKeyPressed(Key.P)` so holding P toggles once + per press; paddle movement continues to use held-key state. +- Focus loss does not automatically pause the game. Browsers can throttle + animation frames in background tabs, so games should choose how to handle a + large delta when returning. +- Existing native/window and browser/canvas resize handling stays in place. +- Browser frame failures stop the scheduler. Automatic device-loss recovery and + restoration of game resources are separate requirements. +- Browser page/process termination does not promise a final JavaScript callback. + +Pong now uses this shared entry and cleanup instead of a blocking source loop. +Canonical examples use the palette's public uppercase names, such as +`Colors.WHITE`; the inventory rejects undefined names such as `Colors.White`. +The installed native gate verifies cleanup runs exactly once after its physics +and capture fixture. Scheduler tests cover the asynchronous ordering, stop, +failure, re-entry and stale-callback behavior without claiming browser rendering. +Actual compiled-game browser startup, a complete init/update/fixed-update/draw +lifecycle and the one-command starter remain open under #142/#74. diff --git a/docs/web-target.md b/docs/web-target.md index 8a0d2b94..1370cbfb 100644 --- a/docs/web-target.md +++ b/docs/web-target.md @@ -84,6 +84,19 @@ runGame((dt) => { On native, `runGame()` enters a blocking loop. On web, it passes the callback to the JS runtime which drives it via `requestAnimationFrame`. +An optional second callback disposes game resources after the final frame: + +```typescript +runGame(updateAndDraw, () => { + unloadTexture(playerTexture); + closeWindow(); +}); +``` + +Do not put cleanup after `runGame()`; that code executes immediately on web. +`closeWindow()` during a frame requests a stop, and browser cleanup waits until +`endDrawing()` finishes. See the [game-loop contract](game-loop.md). + The traditional `while (!windowShouldClose())` pattern still works on native but is not supported on web. ## Asset Loading diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 91ece2b3..f9fcc8f7 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -96,11 +96,18 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json fixes Jolt directory lookup and redundant final-link metadata. A diagnostic installed fixture simulates Jolt and renders an exact frame on DX12 and Vulkan. The complete fresh-package checker also passes both backends with - all 16,384 pixels matching and no CMake fallback. Hosted startup checks are - pending. The [direct-frame capture correction](evidence/windows-direct-frame-capture-v1.md) + all 16,384 pixels matching and no CMake fallback. All 22 hosted Tests jobs at + #167 pass, including installed startup, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-installed-native-20260911). + The [direct-frame capture correction](evidence/windows-direct-frame-capture-v1.md) also passes the installed physics/image fixture in both scene and direct-2D modes on DX12 and Vulkan. A render-target regression checks capture deferral - and fresh output pixels. Browser starter + and fresh output pixels. All 22 hosted Tests jobs at #168 pass, including + both installed rendering modes. The [game-loop cleanup](game-loop.md) adds an optional + final callback on native and web and moves Pong onto the shared loop with + edge-triggered pause input. [Native cleanup and Pong replay evidence](evidence/windows-game-cleanup-v1.md) + also verifies the corrected palette names and all 20 example links. The same + Pong source completes the real web build; its browser frame remains unproven. + Browser starter rendering, visible native presentation, shared lifecycle, general long-path support remain open. 3. Complete the wider temporal/geometry, performance, memory, resize, and diff --git a/examples/dungeon-crawl/main.ts b/examples/dungeon-crawl/main.ts index 98d31f59..041d7ccd 100644 --- a/examples/dungeon-crawl/main.ts +++ b/examples/dungeon-crawl/main.ts @@ -383,7 +383,7 @@ while (!windowShouldClose()) { ); // HP bar const hpRatio = enemies[i].hp / enemies[i].maxHp; - drawRect(enemies[i].x * TILE_SIZE, enemies[i].y * TILE_SIZE - 4, Math.floor(TILE_SIZE * hpRatio), 3, Colors.Red); + drawRect(enemies[i].x * TILE_SIZE, enemies[i].y * TILE_SIZE - 4, Math.floor(TILE_SIZE * hpRatio), 3, Colors.RED); } // Draw player @@ -398,9 +398,9 @@ while (!windowShouldClose()) { // HUD drawRect(0, 0, SCREEN_WIDTH, 35, { r: 0, g: 0, b: 0, a: 180 }); - drawText("HP: " + player.hp.toString() + "/" + player.maxHp.toString(), 10, 8, 20, player.hp > player.maxHp / 3 ? Colors.Green : Colors.Red); - drawText("Floor: " + floor.toString(), 200, 8, 20, Colors.White); - drawText("Turns: " + turnCount.toString(), 350, 8, 20, Colors.LightGray); + drawText("HP: " + player.hp.toString() + "/" + player.maxHp.toString(), 10, 8, 20, player.hp > player.maxHp / 3 ? Colors.GREEN : Colors.RED); + drawText("Floor: " + floor.toString(), 200, 8, 20, Colors.WHITE); + drawText("Turns: " + turnCount.toString(), 350, 8, 20, Colors.LIGHTGRAY); // Message log if (messageTimer > 0) { @@ -412,9 +412,9 @@ while (!windowShouldClose()) { if (player.hp <= 0) { drawRect(0, SCREEN_HEIGHT / 2 - 50, SCREEN_WIDTH, 100, { r: 0, g: 0, b: 0, a: 200 }); const deathMsg = "You have perished on floor " + floor.toString(); - drawText(deathMsg, SCREEN_WIDTH / 2 - measureText(deathMsg, 24) / 2, SCREEN_HEIGHT / 2 - 20, 24, Colors.Red); + drawText(deathMsg, SCREEN_WIDTH / 2 - measureText(deathMsg, 24) / 2, SCREEN_HEIGHT / 2 - 20, 24, Colors.RED); const restartMsg = "Press ENTER to try again"; - drawText(restartMsg, SCREEN_WIDTH / 2 - measureText(restartMsg, 18) / 2, SCREEN_HEIGHT / 2 + 15, 18, Colors.LightGray); + drawText(restartMsg, SCREEN_WIDTH / 2 - measureText(restartMsg, 18) / 2, SCREEN_HEIGHT / 2 + 15, 18, Colors.LIGHTGRAY); } endDrawing(); diff --git a/examples/isometric-rpg/main.ts b/examples/isometric-rpg/main.ts index 76d8a470..3f5340ba 100644 --- a/examples/isometric-rpg/main.ts +++ b/examples/isometric-rpg/main.ts @@ -138,7 +138,7 @@ function itemColor(t: number): Color { if (t === ITEM_SHIELD) return { r: 100, g: 100, b: 200, a: 255 }; if (t === ITEM_KEY) return { r: 255, g: 220, b: 50, a: 255 }; if (t === ITEM_COIN) return { r: 255, g: 200, b: 0, a: 255 }; - return Colors.White; + return Colors.WHITE; } function itemName(t: number): string { @@ -409,7 +409,7 @@ while (!windowShouldClose()) { // HP bar const barW = TILE_W * camera.zoom * 0.6; const hpRatio = npcs[i].hp / npcs[i].maxHp; - drawRect(sx - barW / 2, sy - size - 4 + TILE_H * camera.zoom * 0.3, barW * hpRatio, 3, Colors.Red); + drawRect(sx - barW / 2, sy - size - 4 + TILE_H * camera.zoom * 0.3, barW * hpRatio, 3, Colors.RED); } // Draw player @@ -424,15 +424,15 @@ while (!windowShouldClose()) { // HUD panel drawRect(0, 0, SCREEN_WIDTH, 45, { r: 20, g: 20, b: 30, a: 220 }); - drawText(player.name + " Lv." + level.toString(), 10, 5, 18, Colors.White); + drawText(player.name + " Lv." + level.toString(), 10, 5, 18, Colors.WHITE); // HP bar drawRect(10, 28, 120, 10, { r: 60, g: 0, b: 0, a: 255 }); - drawRect(10, 28, Math.floor(120 * player.hp / player.maxHp), 10, Colors.Red); - drawText(player.hp.toString() + "/" + player.maxHp.toString(), 15, 27, 10, Colors.White); + drawRect(10, 28, Math.floor(120 * player.hp / player.maxHp), 10, Colors.RED); + drawText(player.hp.toString() + "/" + player.maxHp.toString(), 15, 27, 10, Colors.WHITE); drawText("ATK: " + player.attack.toString(), 150, 8, 16, { r: 255, g: 150, b: 50, a: 255 }); drawText("DEF: " + player.defense.toString(), 240, 8, 16, { r: 50, g: 150, b: 255, a: 255 }); - drawText("Gold: " + gold.toString(), 330, 8, 16, Colors.Yellow); + drawText("Gold: " + gold.toString(), 330, 8, 16, Colors.YELLOW); drawText("EXP: " + exp.toString() + "/" + (level * 20).toString(), 430, 8, 16, { r: 150, g: 255, b: 150, a: 255 }); // Inventory @@ -442,17 +442,17 @@ while (!windowShouldClose()) { if (i > 0) invStr = invStr + ", "; invStr = invStr + itemName(inventory[i]); } - drawText(invStr, 550, 8, 14, Colors.LightGray); + drawText(invStr, 550, 8, 14, Colors.LIGHTGRAY); } // Dialogue box if (showDialogue) { drawRect(50, SCREEN_HEIGHT - 120, SCREEN_WIDTH - 100, 100, { r: 10, g: 10, b: 30, a: 230 }); - drawRectLines(50, SCREEN_HEIGHT - 120, SCREEN_WIDTH - 100, 100, 2, Colors.White); + drawRectLines(50, SCREEN_HEIGHT - 120, SCREEN_WIDTH - 100, 100, 2, Colors.WHITE); const npcName = npcs[dialogueNpc].name; - drawText(npcName, 70, SCREEN_HEIGHT - 110, 20, Colors.Yellow); - drawText(dialogueText, 70, SCREEN_HEIGHT - 80, 18, Colors.White); - drawText("[SPACE] to continue", 70, SCREEN_HEIGHT - 35, 14, Colors.LightGray); + drawText(npcName, 70, SCREEN_HEIGHT - 110, 20, Colors.YELLOW); + drawText(dialogueText, 70, SCREEN_HEIGHT - 80, 18, Colors.WHITE); + drawText("[SPACE] to continue", 70, SCREEN_HEIGHT - 35, 14, Colors.LIGHTGRAY); } // Message log @@ -464,8 +464,8 @@ while (!windowShouldClose()) { // Death if (player.hp <= 0) { drawRect(0, SCREEN_HEIGHT / 2 - 40, SCREEN_WIDTH, 80, { r: 0, g: 0, b: 0, a: 200 }); - drawText("YOU DIED", SCREEN_WIDTH / 2 - measureText("YOU DIED", 50) / 2, SCREEN_HEIGHT / 2 - 30, 50, Colors.Red); - drawText("Press ENTER to respawn", SCREEN_WIDTH / 2 - measureText("Press ENTER to respawn", 18) / 2, SCREEN_HEIGHT / 2 + 25, 18, Colors.LightGray); + drawText("YOU DIED", SCREEN_WIDTH / 2 - measureText("YOU DIED", 50) / 2, SCREEN_HEIGHT / 2 - 30, 50, Colors.RED); + drawText("Press ENTER to respawn", SCREEN_WIDTH / 2 - measureText("Press ENTER to respawn", 18) / 2, SCREEN_HEIGHT / 2 + 25, 18, Colors.LIGHTGRAY); } // Controls hint diff --git a/examples/kart-racer/main.ts b/examples/kart-racer/main.ts index 619ea1e7..f9229f6b 100644 --- a/examples/kart-racer/main.ts +++ b/examples/kart-racer/main.ts @@ -305,12 +305,12 @@ while (!windowShouldClose()) { // Start/finish line const startCp = checkpoints[0]; - drawCube({ x: startCp.x, y: 0.2, z: startCp.z }, 1, 0.3, TRACK_WIDTH, Colors.White); + drawCube({ x: startCp.x, y: 0.2, z: startCp.z }, 1, 0.3, TRACK_WIDTH, Colors.WHITE); // Checkpoint markers (small posts on track edges) for (let i = 0; i < NUM_CHECKPOINTS; i++) { const cp = checkpoints[i]; - const color = i === 0 ? Colors.White : { r: 200, g: 200, b: 50, a: 255 }; + const color = i === 0 ? Colors.WHITE : { r: 200, g: 200, b: 50, a: 255 }; drawCube({ x: cp.x, y: 1, z: cp.z - TRACK_WIDTH * 0.5 - 1 }, 0.5, 2, 0.5, color); drawCube({ x: cp.x, y: 1, z: cp.z + TRACK_WIDTH * 0.5 + 1 }, 0.5, 2, 0.5, color); } @@ -357,21 +357,21 @@ while (!windowShouldClose()) { // Speed const speedKmh = Math.floor(Math.abs(player.speed) * 3.6); - drawText(speedKmh.toString() + " km/h", 10, 10, 22, Colors.White); + drawText(speedKmh.toString() + " km/h", 10, 10, 22, Colors.WHITE); // Position - drawText(placeSuffix(playerPlace), 200, 10, 22, Colors.Yellow); + drawText(placeSuffix(playerPlace), 200, 10, 22, Colors.YELLOW); // Lap const lapText = "Lap " + Math.min(player.lap + 1, TOTAL_LAPS).toString() + "/" + TOTAL_LAPS.toString(); - drawText(lapText, 350, 10, 22, Colors.White); + drawText(lapText, 350, 10, 22, Colors.WHITE); // Time - drawText(formatTime(raceTime), SCREEN_WIDTH - 150, 10, 22, Colors.LightGray); + drawText(formatTime(raceTime), SCREEN_WIDTH - 150, 10, 22, Colors.LIGHTGRAY); // Best lap if (bestLapTime > 0) { - drawText("Best: " + formatTime(bestLapTime), SCREEN_WIDTH - 150, 35, 16, Colors.Green); + drawText("Best: " + formatTime(bestLapTime), SCREEN_WIDTH - 150, 35, 16, Colors.GREEN); } // Countdown @@ -380,16 +380,16 @@ while (!windowShouldClose()) { const countText = countNum > 0 ? countNum.toString() : "GO!"; const fontSize = 80; drawText(countText, SCREEN_WIDTH / 2 - measureText(countText, fontSize) / 2, SCREEN_HEIGHT / 2 - 50, fontSize, - countNum <= 1 ? Colors.Green : Colors.Red); + countNum <= 1 ? Colors.GREEN : Colors.RED); } // Race finish if (raceFinished) { drawRect(0, SCREEN_HEIGHT / 2 - 60, SCREEN_WIDTH, 120, { r: 0, g: 0, b: 0, a: 200 }); const finishText = "RACE COMPLETE!"; - drawText(finishText, SCREEN_WIDTH / 2 - measureText(finishText, 50) / 2, SCREEN_HEIGHT / 2 - 45, 50, Colors.Gold); + drawText(finishText, SCREEN_WIDTH / 2 - measureText(finishText, 50) / 2, SCREEN_HEIGHT / 2 - 45, 50, Colors.GOLD); const resultText = "Finished " + placeSuffix(playerPlace) + " — Time: " + formatTime(raceTime); - drawText(resultText, SCREEN_WIDTH / 2 - measureText(resultText, 24) / 2, SCREEN_HEIGHT / 2 + 15, 24, Colors.White); + drawText(resultText, SCREEN_WIDTH / 2 - measureText(resultText, 24) / 2, SCREEN_HEIGHT / 2 + 15, 24, Colors.WHITE); } // Controls hint (first few seconds) diff --git a/examples/pong/main.ts b/examples/pong/main.ts index f5e8588d..27297492 100644 --- a/examples/pong/main.ts +++ b/examples/pong/main.ts @@ -1,6 +1,6 @@ import { - initWindow, windowShouldClose, beginDrawing, endDrawing, - clearBackground, setTargetFPS, getDeltaTime, isKeyDown, + initWindow, runGame, + clearBackground, setTargetFPS, isKeyDown, isKeyPressed, getScreenWidth, getScreenHeight, closeWindow, } from "bloom/core"; import { Colors, Key } from "bloom/core"; @@ -44,11 +44,9 @@ setTargetFPS(60); initAudioDevice(); // Main game loop -while (!windowShouldClose()) { - const dt = getDeltaTime(); - +runGame((dt) => { // Pause toggle - if (isKeyDown(Key.P)) { + if (isKeyPressed(Key.P)) { paused = !paused; } @@ -129,8 +127,7 @@ while (!windowShouldClose()) { } // Drawing - beginDrawing(); - clearBackground(Colors.Black); + clearBackground(Colors.BLACK); // Center line const segments = 20; @@ -141,31 +138,30 @@ while (!windowShouldClose()) { i * segHeight * 2, 2, segHeight, - Colors.DarkGray, + Colors.DARKGRAY, ); } // Paddles - drawRect(PADDLE_MARGIN, leftPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT, Colors.White); - drawRect(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, rightPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT, Colors.White); + drawRect(PADDLE_MARGIN, leftPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT, Colors.WHITE); + drawRect(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, rightPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT, Colors.WHITE); // Ball - drawCircle(ballX, ballY, BALL_RADIUS, Colors.White); + drawCircle(ballX, ballY, BALL_RADIUS, Colors.WHITE); // Scores const leftScoreText = leftScore.toString(); const rightScoreText = rightScore.toString(); - drawText(leftScoreText, SCREEN_WIDTH / 4 - measureText(leftScoreText, 40) / 2, 20, 40, Colors.White); - drawText(rightScoreText, 3 * SCREEN_WIDTH / 4 - measureText(rightScoreText, 40) / 2, 20, 40, Colors.White); + drawText(leftScoreText, SCREEN_WIDTH / 4 - measureText(leftScoreText, 40) / 2, 20, 40, Colors.WHITE); + drawText(rightScoreText, 3 * SCREEN_WIDTH / 4 - measureText(rightScoreText, 40) / 2, 20, 40, Colors.WHITE); // Pause text if (paused) { const pauseText = "PAUSED"; - drawText(pauseText, SCREEN_WIDTH / 2 - measureText(pauseText, 30) / 2, SCREEN_HEIGHT / 2 - 15, 30, Colors.LightGray); + drawText(pauseText, SCREEN_WIDTH / 2 - measureText(pauseText, 30) / 2, SCREEN_HEIGHT / 2 - 15, 30, Colors.LIGHTGRAY); } - endDrawing(); -} - -closeAudioDevice(); -closeWindow(); +}, () => { + closeAudioDevice(); + closeWindow(); +}); diff --git a/examples/space-blaster/main.ts b/examples/space-blaster/main.ts index a93436a5..7321d72f 100644 --- a/examples/space-blaster/main.ts +++ b/examples/space-blaster/main.ts @@ -86,7 +86,7 @@ for (let i = 0; i < MAX_ENEMIES; i++) { const particles: Particle[] = []; for (let i = 0; i < MAX_PARTICLES; i++) { - particles.push({ x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 0, color: Colors.White, active: false }); + particles.push({ x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 0, color: Colors.WHITE, active: false }); } // Scrolling star background @@ -381,8 +381,8 @@ while (!windowShouldClose()) { } // HUD - drawText("SCORE: " + score.toString(), 10, 10, 20, Colors.White); - drawText("WAVE: " + wave.toString(), SCREEN_WIDTH / 2 - 40, 10, 20, Colors.White); + drawText("SCORE: " + score.toString(), 10, 10, 20, Colors.WHITE); + drawText("WAVE: " + wave.toString(), SCREEN_WIDTH / 2 - 40, 10, 20, Colors.WHITE); // Lives for (let i = 0; i < lives; i++) { @@ -397,16 +397,16 @@ while (!windowShouldClose()) { // Wave announcement if (waveTimer < 0) { const waveText = "WAVE " + wave.toString(); - drawText(waveText, SCREEN_WIDTH / 2 - measureText(waveText, 40) / 2, SCREEN_HEIGHT / 2 - 20, 40, Colors.Yellow); + drawText(waveText, SCREEN_WIDTH / 2 - measureText(waveText, 40) / 2, SCREEN_HEIGHT / 2 - 20, 40, Colors.YELLOW); } // Game over screen if (gameOver) { - drawText("GAME OVER", SCREEN_WIDTH / 2 - measureText("GAME OVER", 60) / 2, SCREEN_HEIGHT / 2 - 60, 60, Colors.Red); + drawText("GAME OVER", SCREEN_WIDTH / 2 - measureText("GAME OVER", 60) / 2, SCREEN_HEIGHT / 2 - 60, 60, Colors.RED); const finalScore = "Score: " + score.toString(); - drawText(finalScore, SCREEN_WIDTH / 2 - measureText(finalScore, 30) / 2, SCREEN_HEIGHT / 2 + 10, 30, Colors.White); + drawText(finalScore, SCREEN_WIDTH / 2 - measureText(finalScore, 30) / 2, SCREEN_HEIGHT / 2 + 10, 30, Colors.WHITE); const restartText = "Press ENTER to restart"; - drawText(restartText, SCREEN_WIDTH / 2 - measureText(restartText, 20) / 2, SCREEN_HEIGHT / 2 + 60, 20, Colors.LightGray); + drawText(restartText, SCREEN_WIDTH / 2 - measureText(restartText, 20) / 2, SCREEN_HEIGHT / 2 + 60, 20, Colors.LIGHTGRAY); } endDrawing(); diff --git a/examples/voxel-sandbox/main.ts b/examples/voxel-sandbox/main.ts index b7dbe83f..8a1516df 100644 --- a/examples/voxel-sandbox/main.ts +++ b/examples/voxel-sandbox/main.ts @@ -284,15 +284,15 @@ function renderBlocks(): void { function drawHUD(): void { const cx = SCREEN_WIDTH / 2; const cy = SCREEN_HEIGHT / 2; - drawRect(cx - 10, cy - 1, 20, 2, Colors.White); - drawRect(cx - 1, cy - 10, 2, 20, Colors.White); + drawRect(cx - 10, cy - 1, 20, 2, Colors.WHITE); + drawRect(cx - 1, cy - 10, 2, 20, Colors.WHITE); const blockNames = ["", "Grass", "Dirt", "Stone", "Wood", "Leaves", "Sand", "Water"]; drawRect(5, SCREEN_HEIGHT - 35, 200, 30, { r: 0, g: 0, b: 0, a: 150 }); - drawText("Block: " + blockNames[selectedBlock] + " [1-7]", 10, SCREEN_HEIGHT - 30, 18, Colors.White); + drawText("Block: " + blockNames[selectedBlock] + " [1-7]", 10, SCREEN_HEIGHT - 30, 18, Colors.WHITE); drawText( "Pos: " + Math.floor(camX).toString() + ", " + Math.floor(camY).toString() + ", " + Math.floor(camZ).toString(), - 10, 10, 16, Colors.White, + 10, 10, 16, Colors.WHITE, ); } @@ -308,7 +308,7 @@ while (!windowShouldClose()) { if (highlightX >= 0) { drawCubeWires( { x: highlightX + 0.5, y: highlightY + 0.5, z: highlightZ + 0.5 }, - 1.02, 1.02, 1.02, Colors.White, + 1.02, 1.02, 1.02, Colors.WHITE, ); } endMode3D(); diff --git a/native/shared/src/ffi_core/game_loop.rs b/native/shared/src/ffi_core/game_loop.rs index 3e919bf6..80ab17ce 100644 --- a/native/shared/src/ffi_core/game_loop.rs +++ b/native/shared/src/ffi_core/game_loop.rs @@ -16,6 +16,13 @@ macro_rules! __bloom_ffi_game_loop { #[no_mangle] pub extern "C" fn bloom_run_game(_callback: extern "C" fn(f64)) {} + #[no_mangle] + pub extern "C" fn bloom_run_game_with_cleanup( + _callback: extern "C" fn(f64), + _cleanup: extern "C" fn(), + ) { + } + #[no_mangle] pub extern "C" fn bloom_register_frame_callback( priority: f64, diff --git a/native/watchos/gen_stubs.js b/native/watchos/gen_stubs.js index c518934b..f470755f 100644 --- a/native/watchos/gen_stubs.js +++ b/native/watchos/gen_stubs.js @@ -18,7 +18,7 @@ const OVERRIDES = new Set([ 'bloom_get_delta_time', 'bloom_get_time', 'bloom_get_fps', 'bloom_init_window', 'bloom_close_window', 'bloom_window_should_close', 'bloom_begin_drawing', 'bloom_end_drawing', 'bloom_clear_background', - 'bloom_run_game', 'bloom_is_any_input_pressed', + 'bloom_run_game', 'bloom_run_game_with_cleanup', 'bloom_is_any_input_pressed', 'bloom_is_key_pressed', 'bloom_is_key_down', 'bloom_is_key_released', 'bloom_set_target_fps', 'bloom_inject_key_down', 'bloom_inject_key_up', diff --git a/native/watchos/src/lib.rs b/native/watchos/src/lib.rs index 96ec5a5a..91abea0b 100644 --- a/native/watchos/src/lib.rs +++ b/native/watchos/src/lib.rs @@ -402,6 +402,8 @@ pub extern "C" fn bloom_clear_background(r: f64, g: f64, b: f64, a: f64) { #[no_mangle] pub extern "C" fn bloom_run_game(_callback: f64) {} +#[no_mangle] +pub extern "C" fn bloom_run_game_with_cleanup(_callback: f64, _cleanup: f64) {} #[no_mangle] pub extern "C" fn bloom_is_any_input_pressed() -> f64 { diff --git a/native/web/bloom_glue.js b/native/web/bloom_glue.js index 9641bb50..20515840 100644 --- a/native/web/bloom_glue.js +++ b/native/web/bloom_glue.js @@ -31,14 +31,36 @@ */ import init, * as bloom from './pkg/bloom_web.js'; +import { createGameLoop } from './game_loop.mjs'; let bloomModule = null; let booted = false; // --- Game loop state --- -let gameCallback = null; // Perry closure handle captured from bloom_run_game -let gameRunning = false; -let rafId = null; +let loadingRemoved = false; +const gameLoop = createGameLoop({ + requestFrame: (callback) => requestAnimationFrame(callback), + cancelFrame: (id) => cancelAnimationFrame(id), + beginFrame: () => { + if (!loadingRemoved) { + document.getElementById('loading')?.remove(); + loadingRemoved = true; + } + flushInput(); + bloom.bloom_begin_drawing(); + }, + endFrame: () => bloom.bloom_end_drawing(), + update: (callback) => callGameClosure(callback, bloom.bloom_get_delta_time()), + cleanup: (callback) => callGameClosure(callback), + onError: (error) => console.error('[bloom] game loop failed:', error), +}); + +function callGameClosure(callback, ...args) { + if (typeof globalThis.callWasmClosure !== 'function') { + throw new Error('Perry runtime is missing callWasmClosure; cannot run the game callback.'); + } + return globalThis.callWasmClosure(callback, ...args); +} // --- Asset prefetch cache --- // path → Uint8Array. Filled from assets_manifest.json before the game boots; @@ -332,16 +354,13 @@ function buildFfiImports() { // to bloom_run_game and returns; the blocking native loop is never entered. // We capture the closure and drive it from requestAnimationFrame. imports.bloom_run_game = (callback) => { - gameCallback = callback; - if (!gameRunning) { - gameRunning = true; - startRafLoop(); - } + gameLoop.start(callback); }; + imports.bloom_run_game_with_cleanup = (callback, cleanup) => gameLoop.start(callback, cleanup); // Safety net for a game that still spins `while (!windowShouldClose())`: // report "should close" once the rAF loop owns frame pacing, so the stray // loop exits after one iteration instead of hanging the tab. - imports.bloom_window_should_close = () => (gameRunning ? 1 : 0); + imports.bloom_window_should_close = () => (gameLoop.running ? 1 : 0); imports.bloom_close_window = () => stopGame(); // Last: wrap every entry so (a) a throw names the FFI call that produced @@ -366,44 +385,9 @@ function buildFfiImports() { return imports; } -/** - * Drive the captured Perry game closure once per animation frame: - * flush queued input → begin_drawing → callback(dt) → end_drawing. - * - * The closure is invoked through Perry's `callWasmClosure`, a global helper its - * runtime exposes that resolves the closure's function-table index + captures - * against the live game WASM instance. By the time the first frame runs, the - * runtime classic