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.
Files changed (41) hide show
  1. package/dist/index.js +5029 -0
  2. package/package.json +23 -0
  3. package/templates/hello/index.html +16 -0
  4. package/templates/hello/package.json +20 -0
  5. package/templates/hello/src/config.ts +1 -0
  6. package/templates/hello/src/draw.ts +26 -0
  7. package/templates/hello/src/game.ts +28 -0
  8. package/templates/hello/src/main.ts +30 -0
  9. package/templates/hello/src/style.css +15 -0
  10. package/templates/hello/test/game.test.ts +19 -0
  11. package/templates/hello/tsconfig.json +26 -0
  12. package/templates/hello/vite.config.ts +5 -0
  13. package/templates/mario/.claude/prototype-next-steps.md +19 -0
  14. package/templates/mario/.claude/sidequests.md +10 -0
  15. package/templates/mario/index.html +17 -0
  16. package/templates/mario/package.json +22 -0
  17. package/templates/mario/public/sprites/MarioIdle.json +26 -0
  18. package/templates/mario/public/sprites/MarioIdle.png +0 -0
  19. package/templates/mario/public/sprites/MarioJump.json +26 -0
  20. package/templates/mario/public/sprites/MarioJump.png +0 -0
  21. package/templates/mario/public/sprites/MarioSkid.json +26 -0
  22. package/templates/mario/public/sprites/MarioSkid.png +0 -0
  23. package/templates/mario/public/sprites/MarioWalk.json +54 -0
  24. package/templates/mario/public/sprites/MarioWalk.png +0 -0
  25. package/templates/mario/src/chromatic-aberration.ts +107 -0
  26. package/templates/mario/src/config.ts +26 -0
  27. package/templates/mario/src/draw.ts +312 -0
  28. package/templates/mario/src/flipbook.ts +159 -0
  29. package/templates/mario/src/game.ts +171 -0
  30. package/templates/mario/src/main.ts +126 -0
  31. package/templates/mario/src/sprites.ts +14 -0
  32. package/templates/mario/src/style.css +7 -0
  33. package/templates/mario/src/systems/animation.ts +30 -0
  34. package/templates/mario/src/systems/collision.ts +41 -0
  35. package/templates/mario/src/systems/inputs.ts +66 -0
  36. package/templates/mario/src/systems/phase.ts +12 -0
  37. package/templates/mario/src/systems/physics.ts +22 -0
  38. package/templates/mario/src/tape-load.ts +165 -0
  39. package/templates/mario/tape-load.html +68 -0
  40. package/templates/mario/tsconfig.json +27 -0
  41. package/templates/mario/vite.config.ts +5 -0
@@ -0,0 +1,312 @@
1
+ import { unwrap } from "@bloopjs/bloop";
2
+ import type { Color, QuadNode, SceneNode, Text, Toodle } from "@bloopjs/toodle";
3
+ import { Colors } from "@bloopjs/toodle";
4
+ import {
5
+ BLOCK_SIZE,
6
+ BLOCK_Y,
7
+ COIN_SIZE,
8
+ GROUND_Y,
9
+ PLAYER_HEIGHT,
10
+ PLAYER_WIDTH,
11
+ } from "./config";
12
+ import type { game, Player, Pose } from "./game";
13
+
14
+ // Placeholder colors until sprites are added
15
+ const MARIO_COLOR = Colors.web.red;
16
+ const LUIGI_COLOR = Colors.web.green;
17
+ const BLOCK_COLOR = Colors.web.sienna;
18
+ const COIN_COLOR = Colors.web.gold;
19
+ const GROUND_COLOR = Colors.web.saddleBrown;
20
+
21
+ /** Quads for each pose animation */
22
+ type PoseQuads = Record<Pose, QuadNode>;
23
+
24
+ export interface DrawState {
25
+ root: SceneNode;
26
+ // Screen containers
27
+ titleScreen: SceneNode;
28
+ gameScreen: SceneNode;
29
+ // Game elements (under gameScreen)
30
+ ground: QuadNode;
31
+ block: SceneNode;
32
+ coin: QuadNode;
33
+ p1: PoseQuads;
34
+ p2: PoseQuads;
35
+ viewport: SceneNode;
36
+ p1Score: Text.TextNode;
37
+ p2Score: Text.TextNode;
38
+ // Title elements (under titleScreen)
39
+ titleText: Text.TextNode;
40
+ subtitleText: Text.TextNode;
41
+ }
42
+
43
+ export function createDrawState(toodle: Toodle): DrawState {
44
+ const root = toodle.Node({ scale: 3 });
45
+
46
+ // Title screen container
47
+ const titleScreen = root.add(toodle.Node({}));
48
+ const titleText = titleScreen.add(
49
+ toodle.Text("ComicNeue", "COIN CHASE", {
50
+ fontSize: 24,
51
+ color: { r: 1, g: 1, b: 1, a: 1 },
52
+ position: { x: 0, y: 20 },
53
+ }),
54
+ );
55
+ const subtitleText = titleScreen.add(
56
+ toodle.Text("ComicNeue", "[Space] Local [Enter] Online", {
57
+ fontSize: 10,
58
+ color: { r: 1, g: 1, b: 1, a: 1 },
59
+ position: { x: 0, y: 0 },
60
+ }),
61
+ );
62
+
63
+ // Game screen container
64
+ const gameScreen = root.add(toodle.Node({}));
65
+ gameScreen.isActive = false;
66
+
67
+ const ground = gameScreen.add(
68
+ toodle.shapes.Rect({
69
+ size: { width: 400, height: 40 },
70
+ position: { x: 0, y: GROUND_Y - 20 },
71
+ color: GROUND_COLOR,
72
+ }),
73
+ );
74
+
75
+ const block = gameScreen.add(
76
+ toodle.shapes.Rect({
77
+ size: { width: BLOCK_SIZE, height: BLOCK_SIZE },
78
+ color: BLOCK_COLOR,
79
+ }),
80
+ );
81
+
82
+ const coin = gameScreen.add(
83
+ toodle.shapes.Circle({
84
+ radius: COIN_SIZE / 2,
85
+ color: COIN_COLOR,
86
+ }),
87
+ );
88
+
89
+ // Map pose to texture name
90
+ const poseTextures: Record<Pose, string> = {
91
+ idle: "marioIdle",
92
+ run: "marioWalk",
93
+ jump: "marioJump",
94
+ skid: "marioSkid",
95
+ };
96
+
97
+ // Create pose quads for a player
98
+ const createPoseQuads = (color?: typeof LUIGI_COLOR): PoseQuads => {
99
+ const poses = {} as PoseQuads;
100
+ for (const pose of ["idle", "run", "jump", "skid"] satisfies Pose[]) {
101
+ const quad = gameScreen.add(
102
+ toodle.Quad(poseTextures[pose], {
103
+ size: { width: PLAYER_WIDTH, height: PLAYER_HEIGHT },
104
+ region: { x: 0, y: 0, width: PLAYER_WIDTH, height: PLAYER_HEIGHT },
105
+ color,
106
+ }),
107
+ );
108
+ quad.isActive = false; // Start inactive, draw will activate the right one
109
+ poses[pose] = quad;
110
+ }
111
+ return poses;
112
+ };
113
+
114
+ const p1 = createPoseQuads();
115
+ const p2 = createPoseQuads(LUIGI_COLOR);
116
+
117
+ const p1Score = gameScreen.add(
118
+ toodle.Text("ComicNeue", "P9: 0", {
119
+ fontSize: 16,
120
+ color: MARIO_COLOR,
121
+ }),
122
+ );
123
+
124
+ const p2Score = gameScreen.add(
125
+ toodle.Text("ComicNeue", "P2: 0", {
126
+ fontSize: 16,
127
+ color: LUIGI_COLOR,
128
+ }),
129
+ );
130
+
131
+ const viewport = toodle.Node({
132
+ size: { width: toodle.resolution.width, height: toodle.resolution.height },
133
+ });
134
+
135
+ return {
136
+ root,
137
+ titleScreen,
138
+ gameScreen,
139
+ ground,
140
+ block,
141
+ coin,
142
+ p1,
143
+ p2,
144
+ p1Score,
145
+ p2Score,
146
+ titleText,
147
+ subtitleText,
148
+ viewport,
149
+ };
150
+ }
151
+
152
+ export function draw(g: typeof game, toodle: Toodle, state: DrawState) {
153
+ const { bag } = g.context;
154
+
155
+ // Toggle screens
156
+ state.titleScreen.isActive = bag.phase !== "playing";
157
+ state.gameScreen.isActive = bag.phase === "playing";
158
+
159
+ // Update title text based on phase
160
+ if (bag.phase === "title") {
161
+ state.subtitleText.text = "[Space] Local [Enter] Online";
162
+ } else if (bag.phase === "waiting") {
163
+ state.subtitleText.text = "Waiting for opponent...";
164
+ }
165
+
166
+ // Update game positions only when playing
167
+ if (bag.phase === "playing") {
168
+ state.block.position = { x: bag.block.x, y: BLOCK_Y + BLOCK_SIZE / 2 };
169
+
170
+ state.coin.position = {
171
+ x: bag.coin.x,
172
+ y: bag.coin.y,
173
+ };
174
+ state.coin.isActive = bag.coin.visible;
175
+ state.coin.color =
176
+ bag.coin.winner === 1
177
+ ? MARIO_COLOR
178
+ : bag.coin.winner === 2
179
+ ? LUIGI_COLOR
180
+ : COIN_COLOR;
181
+ // Update player sprites
182
+ updatePlayerQuads(state.p1, bag.p1);
183
+ updatePlayerQuads(state.p2, bag.p2);
184
+
185
+ const padding = 20;
186
+
187
+ state.p1Score.text = `P1: ${bag.p1.score}`;
188
+ state.p2Score.text = `P2: ${bag.p2.score}`;
189
+
190
+ state.viewport.size = {
191
+ width: toodle.resolution.width,
192
+ height: toodle.resolution.height,
193
+ };
194
+
195
+ state.ground.size.width = state.viewport.size.width;
196
+
197
+ state.p1Score.setBounds({
198
+ left: state.viewport.bounds.left + padding,
199
+ top: state.viewport.bounds.top - padding,
200
+ });
201
+
202
+ state.p2Score.setBounds({
203
+ right: state.viewport.bounds.right - padding,
204
+ top: state.viewport.bounds.top - padding,
205
+ });
206
+ }
207
+
208
+ toodle.startFrame();
209
+ toodle.draw(state.root);
210
+ if (bag.debugHitboxes) {
211
+ drawHitboxes(toodle, state, bag);
212
+ }
213
+ toodle.endFrame();
214
+ }
215
+
216
+ /** Updates a player's pose quads based on their current state */
217
+ function updatePlayerQuads(quads: PoseQuads, player: Player) {
218
+ const poses: Pose[] = ["idle", "run", "jump", "skid"];
219
+
220
+ for (const pose of poses) {
221
+ const quad = quads[pose];
222
+ const isActive = pose === player.pose;
223
+ quad.isActive = isActive;
224
+ if (!isActive) {
225
+ continue;
226
+ }
227
+
228
+ // Update position
229
+ quad.position = {
230
+ x: player.x,
231
+ y: player.y + PLAYER_HEIGHT / 2,
232
+ };
233
+
234
+ // Update flip based on facing direction
235
+ quad.flipX = player.facingDir === -1;
236
+
237
+ // Update region from flipbook frame using static flipbooks + bag AnimState
238
+ const flipbook = unwrap(
239
+ player.anims[pose],
240
+ `No runtime flipbook for pose ${pose}`,
241
+ );
242
+ const frameIndex = flipbook.frameIndex % flipbook.frames.length;
243
+ const frame = flipbook.frames[frameIndex];
244
+ quad.region.x = frame.pos.x;
245
+ quad.region.y = frame.pos.y;
246
+ quad.region.width = frame.width;
247
+ quad.region.height = frame.height;
248
+ }
249
+ }
250
+
251
+ function drawHitboxes(toodle: Toodle, state: DrawState, bag: typeof game.bag) {
252
+ const p1Quad = state.p1[bag.p1.pose];
253
+ const p2Quad = state.p2[bag.p2.pose];
254
+
255
+ toodle.draw(makeHitbox(toodle, p1Quad.bounds));
256
+ toodle.draw(makeHitbox(toodle, p2Quad.bounds));
257
+ toodle.draw(makeHitbox(toodle, state.block.bounds));
258
+
259
+ if (bag.coin.visible) {
260
+ toodle.draw(makeHitbox(toodle, state.coin.bounds));
261
+ }
262
+ }
263
+
264
+ const hitboxDefaultColor = { r: 1, g: 0, b: 1, a: 0.4 };
265
+
266
+ let hitboxShader: ReturnType<Toodle["QuadShader"]> | null = null;
267
+
268
+ function getHitboxShader(toodle: Toodle) {
269
+ if (!hitboxShader) {
270
+ hitboxShader = toodle.QuadShader(
271
+ "hitbox-border",
272
+ 16,
273
+ /*wgsl*/ `
274
+ @fragment
275
+ fn frag(vertex: VertexOutput) -> @location(0) vec4f {
276
+ let color = default_fragment_shader(vertex, linearSampler);
277
+ let uv = vertex.engine_uv.zw;
278
+ let border = 0.1;
279
+
280
+ let nearLeft = uv.x < border;
281
+ let nearRight = uv.x > (1.0 - border);
282
+ let nearBottom = uv.y < border;
283
+ let nearTop = uv.y > (1.0 - border);
284
+
285
+ if (nearLeft || nearRight || nearBottom || nearTop) {
286
+ return vec4f(color.rgb, 1.0);
287
+ }
288
+
289
+ return color;
290
+ }
291
+ `,
292
+ );
293
+ }
294
+ return hitboxShader;
295
+ }
296
+
297
+ function makeHitbox(
298
+ toodle: Toodle,
299
+ bounds: { left: number; right: number; top: number; bottom: number },
300
+ color: Color = hitboxDefaultColor,
301
+ ) {
302
+ return toodle.shapes
303
+ .Rect({
304
+ color,
305
+ size: {
306
+ width: bounds.right - bounds.left,
307
+ height: bounds.top - bounds.bottom,
308
+ },
309
+ shader: getHitboxShader(toodle),
310
+ })
311
+ .setBounds(bounds);
312
+ }
@@ -0,0 +1,159 @@
1
+ export type Flipbook = {
2
+ /** The total time */
3
+ time: number;
4
+ /** The accumulated time */
5
+ acc: number;
6
+ /** The index of the current frame */
7
+ frameIndex: number;
8
+ /** The available frames for this flipbook */
9
+ frames: AnimatedSpriteFrame[];
10
+ /** The current frame */
11
+ frame: AnimatedSpriteFrame;
12
+ /** The total duration */
13
+ duration: number;
14
+ };
15
+
16
+ export function createFlipbook(frames: AnimatedSpriteFrame[]): Flipbook {
17
+ return {
18
+ time: 0,
19
+ acc: 0,
20
+ frameIndex: 0,
21
+ frames,
22
+ frame: frames[0],
23
+ duration: frames.reduce((acc, f) => acc + f.duration, 0),
24
+ };
25
+ }
26
+
27
+ export function step(flipbook: Flipbook, dt: number) {
28
+ flipbook.acc += dt;
29
+ while (flipbook.acc > flipbook.frames[flipbook.frameIndex].duration) {
30
+ flipbook.acc -= flipbook.frames[flipbook.frameIndex].duration;
31
+ flipbook.frameIndex++;
32
+ flipbook.frameIndex %= flipbook.frames.length;
33
+ }
34
+ flipbook.time += dt;
35
+ update(flipbook);
36
+ }
37
+
38
+ export function update(flipbook: Flipbook) {
39
+ flipbook.frame =
40
+ flipbook.frames[flipbook.frameIndex % flipbook.frames.length];
41
+ }
42
+
43
+ export function setFrame(flipbook: Flipbook, index: number) {
44
+ if (index < 0 || index >= flipbook.frames.length) {
45
+ throw new Error(
46
+ `InvalidFrame: ${index} not in range 0..${flipbook.frames.length}`,
47
+ );
48
+ }
49
+
50
+ flipbook.time = 0;
51
+ for (let i = 0; i < index; i++) {
52
+ flipbook.time += flipbook.frames[i].duration;
53
+ }
54
+ flipbook.frameIndex = index;
55
+ update(flipbook);
56
+ }
57
+
58
+ export function setTime(flipbook: Flipbook, time: number) {
59
+ flipbook.time = time;
60
+ flipbook.frameIndex = 0;
61
+ while (flipbook.time > flipbook.frames[flipbook.frameIndex].duration) {
62
+ flipbook.time -= flipbook.frames[flipbook.frameIndex].duration;
63
+ flipbook.frameIndex++;
64
+ }
65
+ flipbook.frameIndex %= flipbook.frames.length;
66
+ update(flipbook);
67
+ }
68
+
69
+ export function reset(flipbook: Flipbook) {
70
+ flipbook.frameIndex = 0;
71
+ flipbook.time = 0;
72
+ flipbook.acc = 0;
73
+ update(flipbook);
74
+ }
75
+
76
+ export type AnimatedSpriteFrame = {
77
+ pos: { x: number; y: number };
78
+ width: number;
79
+ height: number;
80
+ duration: number;
81
+ };
82
+
83
+ /**
84
+ * constructs an AnimatedSprite from a json object exported from aseprite
85
+ *
86
+ *
87
+ * @param aseprite
88
+ * @returns
89
+ *
90
+ * @example
91
+ *
92
+ * import asepriteJson from "./path/to/exported-sprite.json"
93
+ * const sprite = AsepriteFlipbook(asepriteJson)
94
+ */
95
+ export function AsepriteFlipbook(aseprite: AsepriteImport): Flipbook {
96
+ const asepriteFramesArray = Array.isArray(aseprite.frames)
97
+ ? aseprite.frames
98
+ : Object.values(aseprite.frames);
99
+ const frames: AnimatedSpriteFrame[] = asepriteFramesArray.map(
100
+ (asepriteFrame) => ({
101
+ pos: { x: asepriteFrame.frame.x, y: asepriteFrame.frame.y },
102
+ width: asepriteFrame.frame.w,
103
+ height: asepriteFrame.frame.h,
104
+ duration: asepriteFrame.duration,
105
+ }),
106
+ );
107
+
108
+ return createFlipbook(frames);
109
+ }
110
+
111
+ export type AsepriteImport = {
112
+ // Aseprite has a "Hash" and "Array" export format
113
+ frames:
114
+ | {
115
+ [key: string]: AsepriteFrame;
116
+ }
117
+ | AsepriteFrameWithFilename[];
118
+ meta: AsepriteMetadata;
119
+ };
120
+
121
+ type AsepriteFrameWithFilename = AsepriteFrame & { filename: string };
122
+
123
+ type AsepriteFrame = {
124
+ frame: {
125
+ x: number;
126
+ y: number;
127
+ w: number;
128
+ h: number;
129
+ };
130
+ rotated: boolean;
131
+ trimmed: boolean;
132
+ spriteSourceSize: {
133
+ x: number;
134
+ y: number;
135
+ w: number;
136
+ h: number;
137
+ };
138
+ sourceSize: {
139
+ w: number;
140
+ h: number;
141
+ };
142
+ duration: number;
143
+ };
144
+
145
+ type AsepriteMetadata = {
146
+ app: string;
147
+ version: string;
148
+ image: string;
149
+ format: string;
150
+ size: { w: number; h: number };
151
+ scale: string;
152
+ layers: {
153
+ name: string;
154
+ opacity: number;
155
+ blendMode: string;
156
+ }[];
157
+ frameTags: unknown[];
158
+ slices: unknown[];
159
+ };
@@ -0,0 +1,171 @@
1
+ import { Bloop } from "@bloopjs/bloop";
2
+ import * as cfg from "./config";
3
+ import type { Flipbook } from "./flipbook";
4
+ import { FLIPBOOKS } from "./sprites";
5
+ import { AnimationSystem } from "./systems/animation";
6
+ import { CollisionSystem } from "./systems/collision";
7
+ import { InputsSystem } from "./systems/inputs";
8
+ import { PhaseSystem } from "./systems/phase";
9
+ import { PhysicsSystem } from "./systems/physics";
10
+
11
+ export type Player = {
12
+ x: number;
13
+ y: number;
14
+ vx: number;
15
+ vy: number;
16
+ grounded: boolean;
17
+ facingDir: 1 | -1;
18
+ pose: Pose;
19
+ score: number;
20
+ anims: {
21
+ idle: Flipbook;
22
+ run: Flipbook;
23
+ jump: Flipbook;
24
+ skid: Flipbook;
25
+ };
26
+ };
27
+
28
+ function createPlayer(x: number, facingDir: 1 | -1 = 1): Player {
29
+ return {
30
+ x,
31
+ y: cfg.GROUND_Y,
32
+ vx: 0,
33
+ vy: 0,
34
+ grounded: true,
35
+ facingDir,
36
+ pose: "idle",
37
+ score: 0,
38
+ anims: {
39
+ idle: FLIPBOOKS.idle,
40
+ run: FLIPBOOKS.run,
41
+ jump: FLIPBOOKS.jump,
42
+ skid: FLIPBOOKS.skid,
43
+ },
44
+ };
45
+ }
46
+
47
+ export type Pose = "idle" | "run" | "jump" | "skid";
48
+
49
+ export type Phase = "title" | "waiting" | "playing";
50
+
51
+ export const game = Bloop.create({
52
+ bag: {
53
+ phase: "title" as Phase,
54
+ mode: null as "local" | "online" | null,
55
+ p1: createPlayer(cfg.P1_START_X, 1),
56
+ p2: createPlayer(cfg.P2_START_X, -1),
57
+ block: {
58
+ x: (cfg.BLOCK_MIN_X + cfg.BLOCK_MAX_X) / 2,
59
+ direction: 1 as 1 | -1,
60
+ },
61
+ coin: {
62
+ visible: true as boolean,
63
+ hitTime: 0,
64
+ winner: null as 1 | 2 | null,
65
+ x: (cfg.BLOCK_MIN_X + cfg.BLOCK_MAX_X) / 2,
66
+ y: 0,
67
+ },
68
+ debugHitboxes: false as boolean,
69
+ },
70
+ });
71
+
72
+ export function resetGameState(bag: typeof game.bag) {
73
+ bag.p1 = createPlayer(cfg.P1_START_X, 1);
74
+ bag.p2 = createPlayer(cfg.P2_START_X, -1);
75
+ bag.block = { x: (cfg.BLOCK_MIN_X + cfg.BLOCK_MAX_X) / 2, direction: 1 };
76
+ bag.coin = {
77
+ visible: false,
78
+ x: (cfg.BLOCK_MIN_X + cfg.BLOCK_MAX_X) / 2,
79
+ y: 0,
80
+ hitTime: 0,
81
+ winner: null,
82
+ };
83
+ }
84
+
85
+ // Handle online session transitions (runs in all phases)
86
+ game.system("session-watcher", {
87
+ update({ bag, net }) {
88
+ // Waiting for connection → connected, start playing
89
+ if (bag.phase === "waiting" && net.isInSession) {
90
+ resetGameState(bag);
91
+ bag.phase = "playing";
92
+ }
93
+
94
+ // Was playing online → disconnected, back to title
95
+ if (bag.phase === "playing" && bag.mode === "online" && !net.isInSession) {
96
+ bag.phase = "title";
97
+ bag.mode = null;
98
+ }
99
+ },
100
+ });
101
+
102
+ game.system(
103
+ "title-screen",
104
+ PhaseSystem("title", {
105
+ keydown({ bag, event }) {
106
+ if (event.key === "Space") {
107
+ // Local multiplayer - start immediately
108
+ bag.mode = "local";
109
+ bag.phase = "playing";
110
+ resetGameState(bag);
111
+ }
112
+ },
113
+ }),
114
+ );
115
+
116
+ game.system("inputs", InputsSystem);
117
+
118
+ game.system("physics", PhysicsSystem);
119
+
120
+ game.system("collision", CollisionSystem);
121
+
122
+ game.system("animation", AnimationSystem);
123
+
124
+ // Debug hitbox toggle (H key)
125
+ game.system("debug", {
126
+ keydown({ bag, event }) {
127
+ if (event.key === "KeyH") {
128
+ bag.debugHitboxes = !bag.debugHitboxes;
129
+ }
130
+ },
131
+ });
132
+
133
+ // Block movement (oscillates left/right)
134
+ game.system(
135
+ "block",
136
+ PhaseSystem("playing", {
137
+ update({ bag }) {
138
+ const block = bag.block;
139
+ block.x += cfg.BLOCK_SPEED * block.direction;
140
+
141
+ if (block.x >= cfg.BLOCK_MAX_X) {
142
+ block.x = cfg.BLOCK_MAX_X;
143
+ block.direction = -1;
144
+ } else if (block.x <= cfg.BLOCK_MIN_X) {
145
+ block.x = cfg.BLOCK_MIN_X;
146
+ block.direction = 1;
147
+ }
148
+ },
149
+ }),
150
+ );
151
+
152
+ game.system(
153
+ "coin",
154
+ PhaseSystem("playing", {
155
+ update({ bag, time }) {
156
+ // Coin follows block unless coin animation is active
157
+ if (!bag.coin.visible) {
158
+ bag.coin.x = bag.block.x;
159
+ bag.coin.y = cfg.BLOCK_Y + cfg.BLOCK_SIZE;
160
+ return;
161
+ }
162
+
163
+ bag.coin.y += cfg.COIN_V_Y;
164
+ if (time.time - bag.coin.hitTime >= cfg.COIN_VISIBLE_DURATION) {
165
+ bag.coin.visible = false;
166
+ }
167
+ },
168
+ }),
169
+ );
170
+
171
+ export type GameSystem = Parameters<typeof game.system>[1];