create-bloop 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +5029 -0
- package/package.json +23 -0
- package/templates/hello/index.html +16 -0
- package/templates/hello/package.json +20 -0
- package/templates/hello/src/config.ts +1 -0
- package/templates/hello/src/draw.ts +26 -0
- package/templates/hello/src/game.ts +28 -0
- package/templates/hello/src/main.ts +30 -0
- package/templates/hello/src/style.css +15 -0
- package/templates/hello/test/game.test.ts +19 -0
- package/templates/hello/tsconfig.json +26 -0
- package/templates/hello/vite.config.ts +5 -0
- package/templates/mario/.claude/prototype-next-steps.md +19 -0
- package/templates/mario/.claude/sidequests.md +10 -0
- package/templates/mario/index.html +17 -0
- package/templates/mario/package.json +22 -0
- package/templates/mario/public/sprites/MarioIdle.json +26 -0
- package/templates/mario/public/sprites/MarioIdle.png +0 -0
- package/templates/mario/public/sprites/MarioJump.json +26 -0
- package/templates/mario/public/sprites/MarioJump.png +0 -0
- package/templates/mario/public/sprites/MarioSkid.json +26 -0
- package/templates/mario/public/sprites/MarioSkid.png +0 -0
- package/templates/mario/public/sprites/MarioWalk.json +54 -0
- package/templates/mario/public/sprites/MarioWalk.png +0 -0
- package/templates/mario/src/chromatic-aberration.ts +107 -0
- package/templates/mario/src/config.ts +26 -0
- package/templates/mario/src/draw.ts +312 -0
- package/templates/mario/src/flipbook.ts +159 -0
- package/templates/mario/src/game.ts +171 -0
- package/templates/mario/src/main.ts +126 -0
- package/templates/mario/src/sprites.ts +14 -0
- package/templates/mario/src/style.css +7 -0
- package/templates/mario/src/systems/animation.ts +30 -0
- package/templates/mario/src/systems/collision.ts +41 -0
- package/templates/mario/src/systems/inputs.ts +66 -0
- package/templates/mario/src/systems/phase.ts +12 -0
- package/templates/mario/src/systems/physics.ts +22 -0
- package/templates/mario/src/tape-load.ts +165 -0
- package/templates/mario/tape-load.html +68 -0
- package/templates/mario/tsconfig.json +27 -0
- package/templates/mario/vite.config.ts +5 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import "./style.css";
|
|
2
|
+
import { Toodle } from "@bloopjs/toodle";
|
|
3
|
+
import { joinRollbackRoom, start } from "@bloopjs/web";
|
|
4
|
+
import { createChromaticAberrationEffect } from "./chromatic-aberration";
|
|
5
|
+
import { createDrawState, draw as drawFn } from "./draw";
|
|
6
|
+
import { game } from "./game";
|
|
7
|
+
|
|
8
|
+
// In dev, vite serves wasm from /bloop-wasm/. In prod, it's bundled at ./bloop.wasm
|
|
9
|
+
const wasmUrl = import.meta.env.DEV
|
|
10
|
+
? new URL("/bloop-wasm/bloop.wasm", window.location.href)
|
|
11
|
+
: new URL("./bloop.wasm", import.meta.url);
|
|
12
|
+
|
|
13
|
+
let draw = drawFn;
|
|
14
|
+
|
|
15
|
+
const app = await start({
|
|
16
|
+
game,
|
|
17
|
+
engineWasmUrl: wasmUrl,
|
|
18
|
+
startRecording: false,
|
|
19
|
+
debugUi: {
|
|
20
|
+
initiallyVisible: false,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const canvas = app.canvas;
|
|
25
|
+
if (!canvas) throw new Error("No canvas element found");
|
|
26
|
+
|
|
27
|
+
const toodle = await Toodle.attach(canvas, {
|
|
28
|
+
filter: "nearest",
|
|
29
|
+
backend: "webgpu",
|
|
30
|
+
limits: { textureArrayLayers: 5 },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
toodle.clearColor = { r: 0.36, g: 0.58, b: 0.99, a: 1 }; // Mario sky blue
|
|
34
|
+
|
|
35
|
+
// Load sprites
|
|
36
|
+
const spriteUrl = (name: string) =>
|
|
37
|
+
new URL(
|
|
38
|
+
`${import.meta.env.BASE_URL}sprites/${name}.png`,
|
|
39
|
+
window.location.href,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await toodle.assets.registerBundle("main", {
|
|
43
|
+
textures: {
|
|
44
|
+
marioIdle: spriteUrl("MarioIdle"),
|
|
45
|
+
marioWalk: spriteUrl("MarioWalk"),
|
|
46
|
+
marioJump: spriteUrl("MarioJump"),
|
|
47
|
+
marioSkid: spriteUrl("MarioSkid"),
|
|
48
|
+
},
|
|
49
|
+
autoLoad: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await toodle.assets.loadFont(
|
|
53
|
+
"ComicNeue",
|
|
54
|
+
new URL("https://toodle.gg/fonts/ComicNeue-Regular-msdf.json"),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const drawState = createDrawState(toodle);
|
|
58
|
+
|
|
59
|
+
requestAnimationFrame(function frame() {
|
|
60
|
+
draw(app.game, toodle, drawState);
|
|
61
|
+
requestAnimationFrame(frame);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let networkJoined = false;
|
|
65
|
+
|
|
66
|
+
// Debug: Press R to start recording mid-game
|
|
67
|
+
window.addEventListener("keydown", (e) => {
|
|
68
|
+
if (e.key === "r" && !app.sim.isRecording) {
|
|
69
|
+
app.sim.record();
|
|
70
|
+
console.log("Started recording at frame", app.sim.time.frame);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Debug: Press G to toggle glitch effect
|
|
75
|
+
const glitchEffect = createChromaticAberrationEffect(toodle);
|
|
76
|
+
let glitchEnabled = false;
|
|
77
|
+
window.addEventListener("keydown", (e) => {
|
|
78
|
+
if (e.key === "g") {
|
|
79
|
+
glitchEnabled = !glitchEnabled;
|
|
80
|
+
toodle.postprocess = glitchEnabled ? glitchEffect : null;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
game.system("title-input", {
|
|
85
|
+
update({ bag, inputs }) {
|
|
86
|
+
if (bag.phase !== "title") return;
|
|
87
|
+
|
|
88
|
+
if ((inputs.keys.enter.down || inputs.mouse.left.down) && !networkJoined) {
|
|
89
|
+
// Online multiplayer - wait for connection
|
|
90
|
+
bag.mode = "online";
|
|
91
|
+
bag.phase = "waiting";
|
|
92
|
+
networkJoined = true;
|
|
93
|
+
|
|
94
|
+
// Phase transitions are handled by the session-watcher system
|
|
95
|
+
joinRollbackRoom("mario-demo", app, {
|
|
96
|
+
onSessionStart() {
|
|
97
|
+
bag.phase = "playing";
|
|
98
|
+
|
|
99
|
+
app.sim.record(100_000);
|
|
100
|
+
console.log(
|
|
101
|
+
"Network session started, recording at frame",
|
|
102
|
+
app.sim.time.frame,
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
onSessionEnd() {
|
|
106
|
+
networkJoined = false;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// HMR support
|
|
114
|
+
if (import.meta.hot) {
|
|
115
|
+
import.meta.hot.accept("./game", async (newModule) => {
|
|
116
|
+
if (newModule?.game) {
|
|
117
|
+
await app.acceptHmr(newModule.game, { wasmUrl });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
import.meta.hot.accept("./draw", async (newModule) => {
|
|
122
|
+
if (newModule?.draw && newModule?.createDrawState) {
|
|
123
|
+
draw = newModule.draw;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// TODO: allow loading animated sprite JSON from async boot
|
|
2
|
+
import marioIdleJson from "../public/sprites/MarioIdle.json";
|
|
3
|
+
import marioJumpJson from "../public/sprites/MarioJump.json";
|
|
4
|
+
import marioSkidJson from "../public/sprites/MarioSkid.json";
|
|
5
|
+
import marioWalkJson from "../public/sprites/MarioWalk.json";
|
|
6
|
+
import { AsepriteFlipbook } from "./flipbook";
|
|
7
|
+
|
|
8
|
+
/** Static flipbook data - frame definitions from Aseprite */
|
|
9
|
+
export const FLIPBOOKS = {
|
|
10
|
+
idle: AsepriteFlipbook(marioIdleJson),
|
|
11
|
+
run: AsepriteFlipbook(marioWalkJson),
|
|
12
|
+
jump: AsepriteFlipbook(marioJumpJson),
|
|
13
|
+
skid: AsepriteFlipbook(marioSkidJson),
|
|
14
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { reset, step } from "../flipbook";
|
|
2
|
+
import type { Player, Pose } from "../game";
|
|
3
|
+
import { PhaseSystem } from "./phase";
|
|
4
|
+
|
|
5
|
+
export const AnimationSystem = PhaseSystem("playing", {
|
|
6
|
+
update({ bag, time }) {
|
|
7
|
+
updatePlayerAnimation(bag.p1, time.dt);
|
|
8
|
+
updatePlayerAnimation(bag.p2, time.dt);
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function updatePlayerAnimation(player: Player, dt: number) {
|
|
13
|
+
const newPose = determinePose(player);
|
|
14
|
+
|
|
15
|
+
if (newPose !== player.pose) {
|
|
16
|
+
player.pose = newPose;
|
|
17
|
+
reset(player.anims[player.pose]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!player.anims[player.pose]) {
|
|
21
|
+
throw new Error(`No animation found for pose ${player.pose}`);
|
|
22
|
+
}
|
|
23
|
+
step(player.anims[player.pose], dt * 1000);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function determinePose(player: Player): Pose {
|
|
27
|
+
if (!player.grounded) return "jump";
|
|
28
|
+
if (player.vx !== 0) return "run";
|
|
29
|
+
return "idle";
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as cfg from "../config";
|
|
2
|
+
import { PhaseSystem } from "./phase";
|
|
3
|
+
|
|
4
|
+
export const CollisionSystem = PhaseSystem("playing", {
|
|
5
|
+
update({ bag, time }) {
|
|
6
|
+
const block = bag.block;
|
|
7
|
+
|
|
8
|
+
for (const p of [bag.p1, bag.p2]) {
|
|
9
|
+
// Only check if player is moving upward (positive vy)
|
|
10
|
+
if (p.vy <= 0) continue;
|
|
11
|
+
|
|
12
|
+
// Check if player's head intersects with block
|
|
13
|
+
// Player's feet are at p.y, head is at p.y + PLAYER_HEIGHT
|
|
14
|
+
const playerTop = p.y + cfg.PLAYER_HEIGHT;
|
|
15
|
+
const playerLeft = p.x - cfg.PLAYER_WIDTH / 2;
|
|
16
|
+
const playerRight = p.x + cfg.PLAYER_WIDTH / 2;
|
|
17
|
+
|
|
18
|
+
// Block bottom is at BLOCK_Y, top is at BLOCK_Y + BLOCK_SIZE
|
|
19
|
+
const blockBottom = cfg.BLOCK_Y;
|
|
20
|
+
const blockLeft = block.x - cfg.BLOCK_SIZE / 2;
|
|
21
|
+
const blockRight = block.x + cfg.BLOCK_SIZE / 2;
|
|
22
|
+
|
|
23
|
+
// AABB collision - head hitting bottom of block
|
|
24
|
+
const hitX = playerRight > blockLeft && playerLeft < blockRight;
|
|
25
|
+
const hitY =
|
|
26
|
+
playerTop > blockBottom && playerTop < blockBottom + cfg.BLOCK_SIZE;
|
|
27
|
+
|
|
28
|
+
if (hitX && hitY && bag.coin.visible === false) {
|
|
29
|
+
// Bonk! Stop upward movement
|
|
30
|
+
p.vy = 0;
|
|
31
|
+
p.y = blockBottom - cfg.PLAYER_HEIGHT;
|
|
32
|
+
|
|
33
|
+
p.score += 1;
|
|
34
|
+
|
|
35
|
+
bag.coin.hitTime = time.time;
|
|
36
|
+
bag.coin.visible = true;
|
|
37
|
+
bag.coin.winner = p === bag.p1 ? 1 : 2;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as cfg from "../config";
|
|
2
|
+
import { PhaseSystem } from "./phase";
|
|
3
|
+
|
|
4
|
+
// Player 1 input: WASD
|
|
5
|
+
export const InputsSystem = PhaseSystem("playing", {
|
|
6
|
+
update({ bag, players, net }) {
|
|
7
|
+
const p1 = bag.p1;
|
|
8
|
+
const p2 = bag.p2;
|
|
9
|
+
|
|
10
|
+
// P1 Horizontal movement
|
|
11
|
+
p1.vx = 0;
|
|
12
|
+
if (players[0].keys.a.held) {
|
|
13
|
+
p1.x -= cfg.MOVE_SPEED;
|
|
14
|
+
p1.vx = -cfg.MOVE_SPEED;
|
|
15
|
+
p1.facingDir = -1;
|
|
16
|
+
}
|
|
17
|
+
if (players[0].keys.d.held) {
|
|
18
|
+
p1.x += cfg.MOVE_SPEED;
|
|
19
|
+
p1.vx = cfg.MOVE_SPEED;
|
|
20
|
+
p1.facingDir = 1;
|
|
21
|
+
}
|
|
22
|
+
// P1 Jump
|
|
23
|
+
const wantsJump = players[0].keys.w.down || players[0].mouse.left.down;
|
|
24
|
+
if (wantsJump && p1.grounded) {
|
|
25
|
+
p1.vy = cfg.JUMP_VELOCITY;
|
|
26
|
+
p1.grounded = false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// P2 Horizontal movement
|
|
30
|
+
p2.vx = 0;
|
|
31
|
+
if (players[1].keys.a.held) {
|
|
32
|
+
p2.x -= cfg.MOVE_SPEED;
|
|
33
|
+
p2.vx = -cfg.MOVE_SPEED;
|
|
34
|
+
p2.facingDir = -1;
|
|
35
|
+
}
|
|
36
|
+
if (players[1].keys.d.held) {
|
|
37
|
+
p2.x += cfg.MOVE_SPEED;
|
|
38
|
+
p2.vx = cfg.MOVE_SPEED;
|
|
39
|
+
p2.facingDir = 1;
|
|
40
|
+
}
|
|
41
|
+
// P2 Jump
|
|
42
|
+
if ((players[1].keys.w.down || players[1].mouse.left.down) && p2.grounded) {
|
|
43
|
+
p2.vy = cfg.JUMP_VELOCITY;
|
|
44
|
+
p2.grounded = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!net.isInSession) {
|
|
48
|
+
// locally, control second player with ijkl
|
|
49
|
+
if (players[0].keys.j.held) {
|
|
50
|
+
p2.x -= cfg.MOVE_SPEED;
|
|
51
|
+
p2.vx = -cfg.MOVE_SPEED;
|
|
52
|
+
p2.facingDir = -1;
|
|
53
|
+
}
|
|
54
|
+
if (players[0].keys.l.held) {
|
|
55
|
+
p2.x += cfg.MOVE_SPEED;
|
|
56
|
+
p2.vx = cfg.MOVE_SPEED;
|
|
57
|
+
p2.facingDir = 1;
|
|
58
|
+
}
|
|
59
|
+
// Jump
|
|
60
|
+
if (players[0].keys.i.down && p2.grounded) {
|
|
61
|
+
p2.vy = cfg.JUMP_VELOCITY;
|
|
62
|
+
p2.grounded = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GameSystem, Phase } from "../game";
|
|
2
|
+
|
|
3
|
+
export function PhaseSystem(phase: Phase, system: GameSystem): GameSystem {
|
|
4
|
+
const original = system.update;
|
|
5
|
+
return {
|
|
6
|
+
...system,
|
|
7
|
+
update(ctx) {
|
|
8
|
+
if (ctx.bag.phase !== phase) return;
|
|
9
|
+
original?.(ctx);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as cfg from "../config";
|
|
2
|
+
import { PhaseSystem } from "./phase";
|
|
3
|
+
|
|
4
|
+
// Gravity for both players
|
|
5
|
+
export const PhysicsSystem = PhaseSystem("playing", {
|
|
6
|
+
update({ bag }) {
|
|
7
|
+
for (const p of [bag.p1, bag.p2]) {
|
|
8
|
+
if (!p.grounded) {
|
|
9
|
+
p.vy -= cfg.GRAVITY;
|
|
10
|
+
p.vy = Math.max(p.vy, -cfg.MAX_FALL_SPEED);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
p.y += p.vy;
|
|
14
|
+
|
|
15
|
+
if (p.y <= cfg.GROUND_Y) {
|
|
16
|
+
p.y = cfg.GROUND_Y;
|
|
17
|
+
p.vy = 0;
|
|
18
|
+
p.grounded = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import "./style.css";
|
|
2
|
+
import { Toodle } from "@bloopjs/toodle";
|
|
3
|
+
import { start } from "@bloopjs/web";
|
|
4
|
+
import { createDrawState, draw } from "./draw";
|
|
5
|
+
import { game } from "./game";
|
|
6
|
+
|
|
7
|
+
const wasmUrl = import.meta.env.DEV
|
|
8
|
+
? new URL("/bloop-wasm/bloop.wasm", window.location.href)
|
|
9
|
+
: new URL("./bloop.wasm", import.meta.url);
|
|
10
|
+
|
|
11
|
+
const DB_NAME = "mario-tapes";
|
|
12
|
+
const STORE_NAME = "tapes";
|
|
13
|
+
const TAPE_KEY = "last";
|
|
14
|
+
|
|
15
|
+
const statusEl = document.getElementById("status")!;
|
|
16
|
+
const inputEl = document.getElementById("tape-input") as HTMLInputElement;
|
|
17
|
+
const replayBtn = document.getElementById("replay-last") as HTMLButtonElement;
|
|
18
|
+
|
|
19
|
+
function openDB(): Promise<IDBDatabase> {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const request = indexedDB.open(DB_NAME, 1);
|
|
22
|
+
request.onerror = () => reject(request.error);
|
|
23
|
+
request.onsuccess = () => resolve(request.result);
|
|
24
|
+
request.onupgradeneeded = () => {
|
|
25
|
+
request.result.createObjectStore(STORE_NAME);
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadTape(bytes: Uint8Array, fileName: string) {
|
|
31
|
+
statusEl.textContent = "Loading tape...";
|
|
32
|
+
|
|
33
|
+
// Start the app with recording disabled (we're loading a tape)
|
|
34
|
+
const app = await start({
|
|
35
|
+
game,
|
|
36
|
+
engineWasmUrl: wasmUrl,
|
|
37
|
+
debugUi: true,
|
|
38
|
+
startRecording: false,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
import.meta.hot?.accept("./game", async (newModule) => {
|
|
42
|
+
await app.acceptHmr(newModule?.game, {
|
|
43
|
+
wasmUrl,
|
|
44
|
+
files: ["./game"],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Load the tape and pause playback
|
|
49
|
+
app.loadTape(bytes);
|
|
50
|
+
app.sim.pause();
|
|
51
|
+
|
|
52
|
+
const canvas = app.canvas;
|
|
53
|
+
if (!canvas) throw new Error("No canvas element found");
|
|
54
|
+
|
|
55
|
+
const toodle = await Toodle.attach(canvas, {
|
|
56
|
+
filter: "nearest",
|
|
57
|
+
backend: "webgpu",
|
|
58
|
+
limits: { textureArrayLayers: 5 },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
toodle.clearColor = { r: 0.36, g: 0.58, b: 0.99, a: 1 };
|
|
62
|
+
|
|
63
|
+
// Load sprites
|
|
64
|
+
const spriteUrl = (name: string) =>
|
|
65
|
+
new URL(
|
|
66
|
+
`${import.meta.env.BASE_URL}sprites/${name}.png`,
|
|
67
|
+
window.location.href,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await toodle.assets.registerBundle("main", {
|
|
71
|
+
textures: {
|
|
72
|
+
marioIdle: spriteUrl("MarioIdle"),
|
|
73
|
+
marioWalk: spriteUrl("MarioWalk"),
|
|
74
|
+
marioJump: spriteUrl("MarioJump"),
|
|
75
|
+
marioSkid: spriteUrl("MarioSkid"),
|
|
76
|
+
},
|
|
77
|
+
autoLoad: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await toodle.assets.loadFont(
|
|
81
|
+
"ComicNeue",
|
|
82
|
+
new URL("https://toodle.gg/fonts/ComicNeue-Regular-msdf.json"),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const drawState = createDrawState(toodle);
|
|
86
|
+
|
|
87
|
+
requestAnimationFrame(function frame() {
|
|
88
|
+
draw(app.game, toodle, drawState);
|
|
89
|
+
requestAnimationFrame(frame);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
statusEl.textContent = `Loaded tape: ${fileName}. Press Escape to toggle debug UI.`;
|
|
93
|
+
|
|
94
|
+
// Hide the file input and show the game
|
|
95
|
+
inputEl.style.display = "none";
|
|
96
|
+
replayBtn.style.display = "none";
|
|
97
|
+
document.querySelector(".container")!.remove();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function saveTapeToStorage(
|
|
101
|
+
bytes: Uint8Array,
|
|
102
|
+
fileName: string,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const db = await openDB();
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
107
|
+
tx.objectStore(STORE_NAME).put({ bytes, fileName }, TAPE_KEY);
|
|
108
|
+
tx.oncomplete = () => resolve();
|
|
109
|
+
tx.onerror = () => reject(tx.error);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadTapeFromStorage(): Promise<{
|
|
114
|
+
bytes: Uint8Array;
|
|
115
|
+
fileName: string;
|
|
116
|
+
} | null> {
|
|
117
|
+
try {
|
|
118
|
+
const db = await openDB();
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
121
|
+
const request = tx.objectStore(STORE_NAME).get(TAPE_KEY);
|
|
122
|
+
request.onsuccess = () => resolve(request.result ?? null);
|
|
123
|
+
request.onerror = () => reject(request.error);
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check for saved tape on page load
|
|
131
|
+
loadTapeFromStorage().then((savedTape) => {
|
|
132
|
+
if (savedTape) {
|
|
133
|
+
replayBtn.style.display = "block";
|
|
134
|
+
replayBtn.textContent = `Replay last tape (${savedTape.fileName})`;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
replayBtn.addEventListener("click", async () => {
|
|
139
|
+
const saved = await loadTapeFromStorage();
|
|
140
|
+
if (!saved) {
|
|
141
|
+
statusEl.textContent = "No saved tape found";
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await loadTape(saved.bytes, saved.fileName);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
statusEl.textContent = `Error loading tape: ${err}`;
|
|
149
|
+
console.error(err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
inputEl.addEventListener("change", async () => {
|
|
154
|
+
const file = inputEl.files?.[0];
|
|
155
|
+
if (!file) return;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
159
|
+
await saveTapeToStorage(bytes, file.name);
|
|
160
|
+
await loadTape(bytes, file.name);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
statusEl.textContent = `Error loading tape: ${err}`;
|
|
163
|
+
console.error(err);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Load Tape - Mario</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 20px;
|
|
11
|
+
font-family: system-ui, sans-serif;
|
|
12
|
+
background: #1a1a2e;
|
|
13
|
+
color: #eee;
|
|
14
|
+
}
|
|
15
|
+
.container {
|
|
16
|
+
max-width: 600px;
|
|
17
|
+
margin: 0 auto;
|
|
18
|
+
}
|
|
19
|
+
h1 {
|
|
20
|
+
margin-bottom: 20px;
|
|
21
|
+
}
|
|
22
|
+
.file-input-wrapper {
|
|
23
|
+
margin-bottom: 20px;
|
|
24
|
+
}
|
|
25
|
+
input[type="file"] {
|
|
26
|
+
padding: 10px;
|
|
27
|
+
background: #16213e;
|
|
28
|
+
border: 2px dashed #0f3460;
|
|
29
|
+
border-radius: 8px;
|
|
30
|
+
color: #eee;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
width: 100%;
|
|
33
|
+
box-sizing: border-box;
|
|
34
|
+
}
|
|
35
|
+
input[type="file"]:hover {
|
|
36
|
+
border-color: #e94560;
|
|
37
|
+
}
|
|
38
|
+
.status {
|
|
39
|
+
padding: 10px;
|
|
40
|
+
background: #16213e;
|
|
41
|
+
border-radius: 8px;
|
|
42
|
+
margin-bottom: 20px;
|
|
43
|
+
}
|
|
44
|
+
.instructions {
|
|
45
|
+
font-size: 14px;
|
|
46
|
+
color: #888;
|
|
47
|
+
line-height: 1.6;
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<div class="container">
|
|
53
|
+
<h1>Load Tape</h1>
|
|
54
|
+
<div class="file-input-wrapper">
|
|
55
|
+
<input type="file" id="tape-input" accept=".bloop" />
|
|
56
|
+
</div>
|
|
57
|
+
<button id="replay-last" style="display: none; margin-bottom: 20px; padding: 10px 20px; background: #e94560; border: none; border-radius: 8px; color: #eee; cursor: pointer; font-size: 16px;">
|
|
58
|
+
Replay last tape
|
|
59
|
+
</button>
|
|
60
|
+
<div class="status" id="status">Select a .bloop tape file to load</div>
|
|
61
|
+
<div class="instructions">
|
|
62
|
+
<p>To save a tape: Run the mario game and press Ctrl+S (or Cmd+S on Mac)</p>
|
|
63
|
+
<p>Playback controls: Use the bottom bar buttons or hotkeys (5=back, 6=pause, 7=forward)</p>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<script type="module" src="/src/tape-load.ts"></script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"types": ["vite/client", "@webgpu/types"],
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
|
|
10
|
+
/* Bundler mode */
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"moduleDetection": "force",
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
/* JSX for Preact */
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"jsxImportSource": "preact",
|
|
20
|
+
|
|
21
|
+
/* Linting */
|
|
22
|
+
"strict": true,
|
|
23
|
+
"noFallthroughCasesInSwitch": true,
|
|
24
|
+
"noUncheckedSideEffectImports": true
|
|
25
|
+
},
|
|
26
|
+
"include": ["src"]
|
|
27
|
+
}
|