your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { onMount, onDestroy } from 'svelte';
3
4 let canvas: HTMLCanvasElement;
5 let container: HTMLDivElement;
6 let ctx: CanvasRenderingContext2D | null = null;
7 let animationId: number;
8
9 // Game state
10 let gameState = $state<'idle' | 'playing' | 'gameover'>('idle');
11 let score = $state(0);
12 let highScore = $state(0);
13
14 // Sprite images (processed with transparent backgrounds)
15 let spritesLoaded = $state(false);
16 const sprites: Record<string, HTMLCanvasElement> = {};
17
18 // Tile size (original is 16x16)
19 const TILE_SIZE = 16;
20
21 // Dynamic scaling - will be calculated based on canvas size
22 let scale = 2.5;
23 let scaledTile = TILE_SIZE * scale;
24
25 // Game constants (will be recalculated on resize)
26 const GRAVITY_BASE = 0.6;
27 const JUMP_FORCE_BASE = -12;
28 let gravity = GRAVITY_BASE;
29 let jumpForce = JUMP_FORCE_BASE;
30 let groundHeight = scaledTile + 10;
31
32 // Game objects
33 let player = {
34 x: 50,
35 y: 0,
36 width: scaledTile,
37 height: scaledTile,
38 velocityY: 0,
39 isJumping: false,
40 isDucking: false,
41 frame: 0
42 };
43
44 let obstacles: Array<{
45 x: number;
46 y: number;
47 width: number;
48 height: number;
49 type: 'ground' | 'air';
50 sprite: string;
51 frame?: number;
52 }> = [];
53
54 let groundTiles: Array<{ x: number }> = [];
55
56 let gameSpeed = 5;
57 let frameCount = 0;
58 let lastFrameTimestamp = 0;
59 let lastSpawnFrame = 0;
60 let lastWalkFrame = 0;
61 let lastBatFrame = 0;
62 let lastSpeedScore = 0;
63 const FRAME_TIME_MS = 1000 / 60;
64 const MAX_SPEED_BASE = 10.5;
65
66 // Sprite positions in tilemap (row, column - 1-indexed based on cells.txt)
67 const SPRITE_POSITIONS: Record<string, { row: number; col: number }> = {
68 // Player - row 14: walk (col 2-4), jump (col 5), fall (col 6)
69 playerWalk1: { row: 14, col: 2 },
70 playerWalk2: { row: 14, col: 3 },
71 playerWalk3: { row: 14, col: 4 },
72 playerJump: { row: 14, col: 5 },
73 playerFall: { row: 14, col: 6 },
74 playerDuck: { row: 14, col: 6 }, // Use fall sprite for duck
75 // Floor - row 5, column 6
76 floor: { row: 5, col: 6 },
77 // Mushroom obstacle - row 3, column 15
78 mushroom: { row: 3, col: 15 },
79 // Spikes obstacle - row 10, column 4
80 spikes: { row: 10, col: 4 },
81 // Plants obstacles - columns 17-19, rows 1-2
82 plant1: { row: 1, col: 17 },
83 plant2: { row: 1, col: 18 },
84 plant3: { row: 1, col: 19 },
85 plant4: { row: 2, col: 17 },
86 plant5: { row: 2, col: 18 },
87 plant6: { row: 2, col: 19 },
88 // Flying obstacles - row 20, columns 1-2
89 bat1: { row: 20, col: 1 },
90 bat2: { row: 20, col: 2 }
91 };
92
93 // Extract a tile from the tilemap and process it (white to black)
94 function extractTile(img: HTMLImageElement, row: number, col: number): HTMLCanvasElement {
95 const offscreen = document.createElement('canvas');
96 offscreen.width = TILE_SIZE;
97 offscreen.height = TILE_SIZE;
98 const offCtx = offscreen.getContext('2d')!;
99
100 // Calculate position (1-indexed to 0-indexed, with 1px spacing between tiles)
101 const TILE_SPACING = 1;
102 const sx = (col - 1) * (TILE_SIZE + TILE_SPACING);
103 const sy = (row - 1) * (TILE_SIZE + TILE_SPACING);
104
105 offCtx.drawImage(img, sx, sy, TILE_SIZE, TILE_SIZE, 0, 0, TILE_SIZE, TILE_SIZE);
106
107 return offscreen;
108 }
109
110 async function loadSprites() {
111 return new Promise<void>((resolve) => {
112 const img = new Image();
113 img.onload = () => {
114 for (const [key, pos] of Object.entries(SPRITE_POSITIONS)) {
115 sprites[key] = extractTile(img, pos.row, pos.col);
116 }
117 spritesLoaded = true;
118 resolve();
119 };
120 img.onerror = () => resolve();
121 img.src = '/dino/Tilemap/monochrome_tilemap_transparent.png';
122 });
123 }
124
125 function calculateScale() {
126 if (!canvas) return;
127
128 // Scale based on canvas height - aim for ~4 tiles vertically for gameplay area
129 const targetTilesVertical = 5;
130 scale = Math.max(1.5, Math.min(4, canvas.height / (TILE_SIZE * targetTilesVertical)));
131 scaledTile = TILE_SIZE * scale;
132
133 // Recalculate physics based on scale
134 const scaleRatio = scale / 2.5;
135 gravity = GRAVITY_BASE * scaleRatio;
136 jumpForce = JUMP_FORCE_BASE * scaleRatio;
137 groundHeight = scaledTile + 10 * scaleRatio;
138
139 // Update player dimensions
140 player.width = scaledTile;
141 player.height = scaledTile;
142 player.x = Math.max(30, scaledTile);
143 }
144
145 function resetGame() {
146 calculateScale();
147 player = {
148 x: Math.max(30, scaledTile),
149 y: 0,
150 width: scaledTile,
151 height: scaledTile,
152 velocityY: 0,
153 isJumping: false,
154 isDucking: false,
155 frame: 0
156 };
157 obstacles = [];
158 gameSpeed = 4.2 * (scale / 2.5);
159 score = 0;
160 frameCount = 0;
161 lastSpawnFrame = 0;
162 lastWalkFrame = 0;
163 lastBatFrame = 0;
164 lastSpeedScore = 0;
165 initGroundTiles();
166 }
167
168 function initGroundTiles() {
169 if (!canvas) return;
170 groundTiles = [];
171 // Add extra tiles to ensure no gaps
172 const numTiles = Math.ceil(canvas.width / scaledTile) + 4;
173 for (let i = 0; i < numTiles; i++) {
174 groundTiles.push({ x: i * scaledTile });
175 }
176 }
177
178 function startGame() {
179 resetGame();
180 gameState = 'playing';
181 // Focus container so keyboard events work for this game
182 container?.focus();
183 }
184
185 function jump() {
186 if (gameState === 'idle') {
187 startGame();
188 return;
189 }
190 if (gameState === 'gameover') {
191 startGame();
192 return;
193 }
194 if (!player.isJumping && !player.isDucking) {
195 player.velocityY = jumpForce;
196 player.isJumping = true;
197 }
198 }
199
200 function duck(ducking: boolean) {
201 if (gameState !== 'playing') return;
202 if (ducking && !player.isJumping) {
203 player.isDucking = true;
204 } else if (!ducking) {
205 player.isDucking = false;
206 }
207 }
208
209 // Handle keyboard input (only responds when this game container is focused)
210 function handleKeyDown(e: KeyboardEvent) {
211 if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
212 e.preventDefault();
213 jump();
214 }
215 if (e.code === 'ArrowDown' || e.code === 'KeyS') {
216 e.preventDefault();
217 duck(true);
218 }
219 }
220
221 function handleKeyUp(e: KeyboardEvent) {
222 if (e.code === 'ArrowDown' || e.code === 'KeyS') {
223 duck(false);
224 }
225 }
226
227 function handleTouch(e: TouchEvent) {
228 e.preventDefault();
229 // Simple tap to jump, could add swipe down for duck
230 jump();
231 }
232
233 function spawnObstacle(canvasWidth: number, groundY: number) {
234 const rand = Math.random();
235
236 // After score 100, start spawning flying obstacles
237 const canSpawnFlying = score > 100;
238
239 if (canSpawnFlying && rand < 0.2) {
240 // Flying bat - requires ducking
241 const batFrame = Math.random() > 0.5 ? 1 : 2;
242 obstacles.push({
243 x: canvasWidth,
244 y: groundY - scaledTile * 1.8, // Float above ground at player head height
245 width: scaledTile,
246 height: scaledTile,
247 type: 'air',
248 sprite: `bat${batFrame}`,
249 frame: batFrame
250 });
251 } else if (rand < 0.4) {
252 // Spikes
253 obstacles.push({
254 x: canvasWidth,
255 y: groundY - scaledTile,
256 width: scaledTile,
257 height: scaledTile,
258 type: 'ground',
259 sprite: 'spikes'
260 });
261 } else if (rand < 0.55) {
262 // Mushroom
263 obstacles.push({
264 x: canvasWidth,
265 y: groundY - scaledTile,
266 width: scaledTile,
267 height: scaledTile,
268 type: 'ground',
269 sprite: 'mushroom'
270 });
271 } else {
272 // Plants
273 const plantSprites = ['plant1', 'plant2', 'plant3', 'plant4', 'plant5', 'plant6'];
274 const sprite = plantSprites[Math.floor(Math.random() * plantSprites.length)];
275 obstacles.push({
276 x: canvasWidth,
277 y: groundY - scaledTile,
278 width: scaledTile,
279 height: scaledTile,
280 type: 'ground',
281 sprite
282 });
283 }
284 }
285
286 function checkCollision(
287 rect1: { x: number; y: number; width: number; height: number },
288 rect2: { x: number; y: number; width: number; height: number }
289 ) {
290 const padding = scaledTile * 0.3;
291 return (
292 rect1.x + padding < rect2.x + rect2.width - padding &&
293 rect1.x + rect1.width - padding > rect2.x + padding &&
294 rect1.y + padding < rect2.y + rect2.height - padding &&
295 rect1.y + rect1.height - padding > rect2.y + padding
296 );
297 }
298
299 function drawSprite(spriteKey: string, x: number, y: number, width: number, height: number) {
300 if (!ctx || !sprites[spriteKey]) return;
301 ctx.imageSmoothingEnabled = false;
302 ctx.drawImage(sprites[spriteKey], x, y, width, height);
303 }
304
305 function gameLoop(timestamp = 0) {
306 if (!ctx || !canvas || !spritesLoaded) {
307 animationId = requestAnimationFrame(gameLoop);
308 return;
309 }
310
311 if (!lastFrameTimestamp) {
312 lastFrameTimestamp = timestamp;
313 }
314
315 const deltaMs = timestamp - lastFrameTimestamp;
316 lastFrameTimestamp = timestamp;
317 const deltaFrames = Math.min(deltaMs / FRAME_TIME_MS, 3);
318
319 const canvasWidth = canvas.width;
320 const canvasHeight = canvas.height;
321 const groundY = canvasHeight - groundHeight;
322
323 // Clear canvas (transparent to show card background)
324 ctx.clearRect(0, 0, canvasWidth, canvasHeight);
325
326 // Draw ground tiles - continuous floor (slight overlap to prevent gaps)
327 for (const tile of groundTiles) {
328 drawSprite('floor', Math.floor(tile.x), groundY, Math.ceil(scaledTile) + 1, scaledTile);
329 }
330
331 if (gameState === 'playing') {
332 frameCount += deltaFrames;
333
334 // Update ground tiles - seamless scrolling
335 for (const tile of groundTiles) {
336 tile.x -= gameSpeed * deltaFrames;
337 }
338
339 // Find the rightmost tile and reposition tiles that went off-screen
340 const rightmostX = Math.max(...groundTiles.map((t) => t.x));
341 for (const tile of groundTiles) {
342 if (tile.x < -scaledTile) {
343 tile.x = rightmostX + scaledTile;
344 }
345 }
346
347 // Update player physics
348 if (player.isJumping) {
349 player.velocityY += gravity * deltaFrames;
350 player.y += player.velocityY * deltaFrames;
351
352 if (player.y >= groundY - player.height) {
353 player.y = groundY - player.height;
354 player.isJumping = false;
355 player.velocityY = 0;
356 }
357 } else {
358 player.y = groundY - player.height;
359 }
360
361 // Animate player (3 walk frames)
362 if (frameCount - lastWalkFrame >= 8) {
363 player.frame = (player.frame + 1) % 3;
364 lastWalkFrame = frameCount;
365 }
366
367 // Animate flying obstacles
368 for (const obs of obstacles) {
369 if (obs.type === 'air' && frameCount - lastBatFrame >= 12) {
370 obs.frame = obs.frame === 1 ? 2 : 1;
371 obs.sprite = `bat${obs.frame}`;
372 lastBatFrame = frameCount;
373 }
374 }
375
376 // Spawn obstacles
377 const baseSpawnRate = 120;
378 const spawnRate = Math.max(60, baseSpawnRate - Math.floor(score / 100) * 5);
379 if (frameCount - lastSpawnFrame >= spawnRate || (obstacles.length === 0 && frameCount > 60)) {
380 spawnObstacle(canvasWidth, groundY);
381 lastSpawnFrame = frameCount;
382 }
383
384 // Update obstacles
385 obstacles = obstacles.filter((obs) => {
386 obs.x -= gameSpeed * deltaFrames;
387 return obs.x > -obs.width;
388 });
389
390 // Check collisions
391 for (const obstacle of obstacles) {
392 let playerHitbox;
393
394 if (player.isDucking) {
395 // Ducking hitbox - lower and shorter
396 playerHitbox = {
397 x: player.x,
398 y: groundY - player.height * 0.5,
399 width: player.width,
400 height: player.height * 0.5
401 };
402 } else if (player.isJumping) {
403 playerHitbox = {
404 x: player.x,
405 y: player.y,
406 width: player.width,
407 height: player.height
408 };
409 } else {
410 playerHitbox = {
411 x: player.x,
412 y: groundY - player.height,
413 width: player.width,
414 height: player.height
415 };
416 }
417
418 if (checkCollision(playerHitbox, obstacle)) {
419 gameState = 'gameover';
420 if (score > highScore) {
421 highScore = score;
422 }
423 break;
424 }
425 }
426
427 // Update score
428 score = Math.floor(frameCount / 5);
429
430 // Increase speed every 100 points up to a cap
431 if (score >= lastSpeedScore + 100) {
432 gameSpeed = Math.min(gameSpeed + 0.25 * (scale / 2.5), MAX_SPEED_BASE * (scale / 2.5));
433 lastSpeedScore = score - (score % 100);
434 }
435 }
436
437 // Draw obstacles
438 for (const obstacle of obstacles) {
439 drawSprite(obstacle.sprite, obstacle.x, obstacle.y, obstacle.width, obstacle.height);
440 }
441
442 // Draw player
443 const playerY = player.isJumping
444 ? player.y
445 : player.isDucking
446 ? groundY - player.height * 0.6
447 : groundY - player.height;
448
449 let playerSprite: string;
450 if (player.isDucking) {
451 playerSprite = 'playerDuck';
452 } else if (player.isJumping) {
453 if (player.velocityY < 0) {
454 playerSprite = 'playerJump';
455 } else {
456 playerSprite = 'playerFall';
457 }
458 } else {
459 // 3-frame walk cycle
460 const walkFrames = ['playerWalk1', 'playerWalk2', 'playerWalk3'];
461 playerSprite = walkFrames[player.frame];
462 }
463
464 // When ducking, draw shorter
465 const drawHeight = player.isDucking ? player.height * 0.6 : player.height;
466 drawSprite(playerSprite, player.x, playerY, player.width, drawHeight);
467
468 // Draw score
469 ctx.fillStyle = '#ffffff';
470 ctx.font = `bold ${Math.max(12, Math.floor(14 * (scale / 2.5)))}px monospace`;
471 ctx.textAlign = 'right';
472 ctx.fillText(String(score).padStart(5, '0'), canvasWidth - 10, 25);
473
474 if (highScore > 0) {
475 ctx.fillStyle = 'rgba(256, 256, 256, 0.5)';
476 ctx.fillText(
477 'HI ' + String(highScore).padStart(5, '0'),
478 canvasWidth - 70 * (scale / 2.5),
479 25
480 );
481 }
482
483 // Draw game over text (no overlay background)
484 if (gameState === 'gameover') {
485 ctx.fillStyle = '#ffffff';
486 ctx.font = `bold ${Math.max(14, Math.floor(20 * (scale / 2.5)))}px monospace`;
487 ctx.textAlign = 'center';
488 ctx.fillText('GAME OVER', canvasWidth / 2, canvasHeight / 2 - 40);
489 }
490
491 animationId = requestAnimationFrame(gameLoop);
492 }
493
494 function resizeCanvas() {
495 if (!canvas) return;
496 const container = canvas.parentElement;
497 if (!container) return;
498 canvas.width = container.clientWidth;
499 canvas.height = container.clientHeight;
500 calculateScale();
501 initGroundTiles();
502 }
503
504 let resizeObserver: ResizeObserver | undefined = $state();
505
506 onMount(async () => {
507 ctx = canvas.getContext('2d');
508 await loadSprites();
509 resizeCanvas();
510
511 resizeObserver = new ResizeObserver(() => {
512 resizeCanvas();
513 });
514 resizeObserver.observe(canvas.parentElement!);
515
516 gameLoop();
517 });
518
519 onDestroy(() => {
520 resizeObserver?.disconnect();
521
522 if (animationId) {
523 cancelAnimationFrame(animationId);
524 }
525 });
526</script>
527
528<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
529<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
530<div
531 bind:this={container}
532 class="relative h-full w-full overflow-hidden outline-none"
533 tabindex="0"
534 role="application"
535 aria-label="Dino game"
536 onkeydown={handleKeyDown}
537 onkeyup={handleKeyUp}
538>
539 <canvas
540 bind:this={canvas}
541 class="h-full w-full touch-none invert select-none dark:invert-0"
542 ontouchstart={handleTouch}
543 ></canvas>
544
545 {#if gameState === 'idle' || gameState === 'gameover'}
546 <button
547 onclick={startGame}
548 class="bg-base-50/80 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform cursor-pointer rounded-lg border-2 border-black px-6 py-3 font-mono font-bold text-black transition-colors duration-200 hover:bg-black hover:text-white"
549 >
550 {gameState === 'gameover' ? 'PLAY AGAIN' : 'START'}
551 </button>
552 {/if}
553</div>