your personal website on atproto - mirror blento.app
at button 553 lines 15 kB view raw
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>