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,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];
|