your personal website on atproto - mirror blento.app

Merge pull request #7 from flo-bit/dino

Dino game

authored by Florian and committed by GitHub c72037d9 d6e65ce6

+604 -1
+518
src/lib/cards/GameCards/DinoGameCard/DinoGameCard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../../types'; 3 + import { onMount, onDestroy } from 'svelte'; 4 + 5 + let { item }: ContentComponentProps = $props(); 6 + 7 + let canvas: HTMLCanvasElement; 8 + let ctx: CanvasRenderingContext2D | null = null; 9 + let animationId: number; 10 + 11 + // Game state 12 + let gameState = $state<'idle' | 'playing' | 'gameover'>('idle'); 13 + let score = $state(0); 14 + let highScore = $state(0); 15 + 16 + // Sprite images (processed with transparent backgrounds) 17 + let spritesLoaded = $state(false); 18 + const sprites: Record<string, HTMLCanvasElement> = {}; 19 + 20 + // Tile size (original is 16x16) 21 + const TILE_SIZE = 16; 22 + 23 + // Dynamic scaling - will be calculated based on canvas size 24 + let scale = 2.5; 25 + let scaledTile = TILE_SIZE * scale; 26 + 27 + // Game constants (will be recalculated on resize) 28 + const GRAVITY_BASE = 0.6; 29 + const JUMP_FORCE_BASE = -12; 30 + let gravity = GRAVITY_BASE; 31 + let jumpForce = JUMP_FORCE_BASE; 32 + let groundHeight = scaledTile + 10; 33 + 34 + // Game objects 35 + let player = { 36 + x: 50, 37 + y: 0, 38 + width: scaledTile, 39 + height: scaledTile, 40 + velocityY: 0, 41 + isJumping: false, 42 + isDucking: false, 43 + frame: 0 44 + }; 45 + 46 + let obstacles: Array<{ 47 + x: number; 48 + y: number; 49 + width: number; 50 + height: number; 51 + type: 'ground' | 'air'; 52 + sprite: string; 53 + frame?: number; 54 + }> = []; 55 + 56 + let groundTiles: Array<{ x: number }> = []; 57 + 58 + let gameSpeed = 5; 59 + let frameCount = 0; 60 + 61 + // Sprite positions in tilemap (row, column - 1-indexed based on cells.txt) 62 + const SPRITE_POSITIONS: Record<string, { row: number; col: number }> = { 63 + // Player - row 14: walk (col 2-4), jump (col 5), fall (col 6) 64 + playerWalk1: { row: 14, col: 2 }, 65 + playerWalk2: { row: 14, col: 3 }, 66 + playerWalk3: { row: 14, col: 4 }, 67 + playerJump: { row: 14, col: 5 }, 68 + playerFall: { row: 14, col: 6 }, 69 + playerDuck: { row: 14, col: 6 }, // Use fall sprite for duck 70 + // Floor - row 5, column 6 71 + floor: { row: 5, col: 6 }, 72 + // Mushroom obstacle - row 3, column 15 73 + mushroom: { row: 3, col: 15 }, 74 + // Spikes obstacle - row 10, column 4 75 + spikes: { row: 10, col: 4 }, 76 + // Plants obstacles - columns 17-19, rows 1-2 77 + plant1: { row: 1, col: 17 }, 78 + plant2: { row: 1, col: 18 }, 79 + plant3: { row: 1, col: 19 }, 80 + plant4: { row: 2, col: 17 }, 81 + plant5: { row: 2, col: 18 }, 82 + plant6: { row: 2, col: 19 }, 83 + // Flying obstacles - row 20, columns 1-2 84 + bat1: { row: 20, col: 1 }, 85 + bat2: { row: 20, col: 2 } 86 + }; 87 + 88 + // Extract a tile from the tilemap and process it (white to black) 89 + function extractTile(img: HTMLImageElement, row: number, col: number): HTMLCanvasElement { 90 + const offscreen = document.createElement('canvas'); 91 + offscreen.width = TILE_SIZE; 92 + offscreen.height = TILE_SIZE; 93 + const offCtx = offscreen.getContext('2d')!; 94 + 95 + // Calculate position (1-indexed to 0-indexed, with 1px spacing between tiles) 96 + const TILE_SPACING = 1; 97 + const sx = (col - 1) * (TILE_SIZE + TILE_SPACING); 98 + const sy = (row - 1) * (TILE_SIZE + TILE_SPACING); 99 + 100 + offCtx.drawImage(img, sx, sy, TILE_SIZE, TILE_SIZE, 0, 0, TILE_SIZE, TILE_SIZE); 101 + 102 + return offscreen; 103 + } 104 + 105 + async function loadSprites() { 106 + return new Promise<void>((resolve) => { 107 + const img = new Image(); 108 + img.onload = () => { 109 + for (const [key, pos] of Object.entries(SPRITE_POSITIONS)) { 110 + sprites[key] = extractTile(img, pos.row, pos.col); 111 + } 112 + spritesLoaded = true; 113 + resolve(); 114 + }; 115 + img.onerror = () => resolve(); 116 + img.src = '/dino/Tilemap/monochrome_tilemap_transparent.png'; 117 + }); 118 + } 119 + 120 + function calculateScale() { 121 + if (!canvas) return; 122 + 123 + // Scale based on canvas height - aim for ~4 tiles vertically for gameplay area 124 + const targetTilesVertical = 5; 125 + scale = Math.max(1.5, Math.min(4, canvas.height / (TILE_SIZE * targetTilesVertical))); 126 + scaledTile = TILE_SIZE * scale; 127 + 128 + // Recalculate physics based on scale 129 + const scaleRatio = scale / 2.5; 130 + gravity = GRAVITY_BASE * scaleRatio; 131 + jumpForce = JUMP_FORCE_BASE * scaleRatio; 132 + groundHeight = scaledTile + 10 * scaleRatio; 133 + 134 + // Update player dimensions 135 + player.width = scaledTile; 136 + player.height = scaledTile; 137 + player.x = Math.max(30, scaledTile); 138 + } 139 + 140 + function resetGame() { 141 + calculateScale(); 142 + player = { 143 + x: Math.max(30, scaledTile), 144 + y: 0, 145 + width: scaledTile, 146 + height: scaledTile, 147 + velocityY: 0, 148 + isJumping: false, 149 + isDucking: false, 150 + frame: 0 151 + }; 152 + obstacles = []; 153 + gameSpeed = 3.5 * (scale / 2.5); 154 + score = 0; 155 + frameCount = 0; 156 + initGroundTiles(); 157 + } 158 + 159 + function initGroundTiles() { 160 + if (!canvas) return; 161 + groundTiles = []; 162 + // Add extra tiles to ensure no gaps 163 + const numTiles = Math.ceil(canvas.width / scaledTile) + 4; 164 + for (let i = 0; i < numTiles; i++) { 165 + groundTiles.push({ x: i * scaledTile }); 166 + } 167 + } 168 + 169 + function startGame() { 170 + resetGame(); 171 + gameState = 'playing'; 172 + } 173 + 174 + function jump() { 175 + if (gameState === 'idle') { 176 + startGame(); 177 + return; 178 + } 179 + if (gameState === 'gameover') { 180 + startGame(); 181 + return; 182 + } 183 + if (!player.isJumping && !player.isDucking) { 184 + player.velocityY = jumpForce; 185 + player.isJumping = true; 186 + } 187 + } 188 + 189 + function duck(ducking: boolean) { 190 + if (gameState !== 'playing') return; 191 + if (ducking && !player.isJumping) { 192 + player.isDucking = true; 193 + } else if (!ducking) { 194 + player.isDucking = false; 195 + } 196 + } 197 + 198 + function handleKeyDown(e: KeyboardEvent) { 199 + if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { 200 + e.preventDefault(); 201 + jump(); 202 + } 203 + if (e.code === 'ArrowDown' || e.code === 'KeyS') { 204 + e.preventDefault(); 205 + duck(true); 206 + } 207 + } 208 + 209 + function handleKeyUp(e: KeyboardEvent) { 210 + if (e.code === 'ArrowDown' || e.code === 'KeyS') { 211 + duck(false); 212 + } 213 + } 214 + 215 + function handleTouch(e: TouchEvent) { 216 + e.preventDefault(); 217 + // Simple tap to jump, could add swipe down for duck 218 + jump(); 219 + } 220 + 221 + function spawnObstacle(canvasWidth: number, groundY: number) { 222 + const rand = Math.random(); 223 + 224 + // After score 100, start spawning flying obstacles 225 + const canSpawnFlying = score > 100; 226 + 227 + if (canSpawnFlying && rand < 0.2) { 228 + // Flying bat - requires ducking 229 + const batFrame = Math.random() > 0.5 ? 1 : 2; 230 + obstacles.push({ 231 + x: canvasWidth, 232 + y: groundY - scaledTile * 1.8, // Float above ground at player head height 233 + width: scaledTile, 234 + height: scaledTile, 235 + type: 'air', 236 + sprite: `bat${batFrame}`, 237 + frame: batFrame 238 + }); 239 + } else if (rand < 0.4) { 240 + // Spikes 241 + obstacles.push({ 242 + x: canvasWidth, 243 + y: groundY - scaledTile, 244 + width: scaledTile, 245 + height: scaledTile, 246 + type: 'ground', 247 + sprite: 'spikes' 248 + }); 249 + } else if (rand < 0.55) { 250 + // Mushroom 251 + obstacles.push({ 252 + x: canvasWidth, 253 + y: groundY - scaledTile, 254 + width: scaledTile, 255 + height: scaledTile, 256 + type: 'ground', 257 + sprite: 'mushroom' 258 + }); 259 + } else { 260 + // Plants 261 + const plantSprites = ['plant1', 'plant2', 'plant3', 'plant4', 'plant5', 'plant6']; 262 + const sprite = plantSprites[Math.floor(Math.random() * plantSprites.length)]; 263 + obstacles.push({ 264 + x: canvasWidth, 265 + y: groundY - scaledTile, 266 + width: scaledTile, 267 + height: scaledTile, 268 + type: 'ground', 269 + sprite 270 + }); 271 + } 272 + } 273 + 274 + function checkCollision( 275 + rect1: { x: number; y: number; width: number; height: number }, 276 + rect2: { x: number; y: number; width: number; height: number } 277 + ) { 278 + const padding = scaledTile * 0.3; 279 + return ( 280 + rect1.x + padding < rect2.x + rect2.width - padding && 281 + rect1.x + rect1.width - padding > rect2.x + padding && 282 + rect1.y + padding < rect2.y + rect2.height - padding && 283 + rect1.y + rect1.height - padding > rect2.y + padding 284 + ); 285 + } 286 + 287 + function drawSprite(spriteKey: string, x: number, y: number, width: number, height: number) { 288 + if (!ctx || !sprites[spriteKey]) return; 289 + ctx.imageSmoothingEnabled = false; 290 + ctx.drawImage(sprites[spriteKey], x, y, width, height); 291 + } 292 + 293 + function gameLoop() { 294 + if (!ctx || !canvas || !spritesLoaded) { 295 + animationId = requestAnimationFrame(gameLoop); 296 + return; 297 + } 298 + 299 + const canvasWidth = canvas.width; 300 + const canvasHeight = canvas.height; 301 + const groundY = canvasHeight - groundHeight; 302 + 303 + // Clear canvas (transparent to show card background) 304 + ctx.clearRect(0, 0, canvasWidth, canvasHeight); 305 + 306 + // Draw ground tiles - continuous floor (slight overlap to prevent gaps) 307 + for (const tile of groundTiles) { 308 + drawSprite('floor', Math.floor(tile.x), groundY, Math.ceil(scaledTile) + 1, scaledTile); 309 + } 310 + 311 + if (gameState === 'playing') { 312 + frameCount++; 313 + 314 + // Update ground tiles - seamless scrolling 315 + for (const tile of groundTiles) { 316 + tile.x -= gameSpeed; 317 + } 318 + 319 + // Find the rightmost tile and reposition tiles that went off-screen 320 + const rightmostX = Math.max(...groundTiles.map((t) => t.x)); 321 + for (const tile of groundTiles) { 322 + if (tile.x < -scaledTile) { 323 + tile.x = rightmostX + scaledTile; 324 + } 325 + } 326 + 327 + // Update player physics 328 + if (player.isJumping) { 329 + player.velocityY += gravity; 330 + player.y += player.velocityY; 331 + 332 + if (player.y >= groundY - player.height) { 333 + player.y = groundY - player.height; 334 + player.isJumping = false; 335 + player.velocityY = 0; 336 + } 337 + } else { 338 + player.y = groundY - player.height; 339 + } 340 + 341 + // Animate player (3 walk frames) 342 + if (frameCount % 8 === 0) { 343 + player.frame = (player.frame + 1) % 3; 344 + } 345 + 346 + // Animate flying obstacles 347 + for (const obs of obstacles) { 348 + if (obs.type === 'air' && frameCount % 12 === 0) { 349 + obs.frame = obs.frame === 1 ? 2 : 1; 350 + obs.sprite = `bat${obs.frame}`; 351 + } 352 + } 353 + 354 + // Spawn obstacles 355 + const baseSpawnRate = 120; 356 + const spawnRate = Math.max(60, baseSpawnRate - Math.floor(score / 100) * 5); 357 + if (frameCount % spawnRate === 0 || (obstacles.length === 0 && frameCount > 60)) { 358 + spawnObstacle(canvasWidth, groundY); 359 + } 360 + 361 + // Update obstacles 362 + obstacles = obstacles.filter((obs) => { 363 + obs.x -= gameSpeed; 364 + return obs.x > -obs.width; 365 + }); 366 + 367 + // Check collisions 368 + for (const obstacle of obstacles) { 369 + let playerHitbox; 370 + 371 + if (player.isDucking) { 372 + // Ducking hitbox - lower and shorter 373 + playerHitbox = { 374 + x: player.x, 375 + y: groundY - player.height * 0.5, 376 + width: player.width, 377 + height: player.height * 0.5 378 + }; 379 + } else if (player.isJumping) { 380 + playerHitbox = { 381 + x: player.x, 382 + y: player.y, 383 + width: player.width, 384 + height: player.height 385 + }; 386 + } else { 387 + playerHitbox = { 388 + x: player.x, 389 + y: groundY - player.height, 390 + width: player.width, 391 + height: player.height 392 + }; 393 + } 394 + 395 + if (checkCollision(playerHitbox, obstacle)) { 396 + gameState = 'gameover'; 397 + if (score > highScore) { 398 + highScore = score; 399 + } 400 + break; 401 + } 402 + } 403 + 404 + // Update score 405 + score = Math.floor(frameCount / 5); 406 + 407 + // Increase speed over time (slower progression) 408 + if (frameCount % 700 === 0) { 409 + gameSpeed = Math.min(gameSpeed + 0.2 * (scale / 2.5), 8 * (scale / 2.5)); 410 + } 411 + } 412 + 413 + // Draw obstacles 414 + for (const obstacle of obstacles) { 415 + drawSprite(obstacle.sprite, obstacle.x, obstacle.y, obstacle.width, obstacle.height); 416 + } 417 + 418 + // Draw player 419 + const playerY = player.isJumping 420 + ? player.y 421 + : player.isDucking 422 + ? groundY - player.height * 0.6 423 + : groundY - player.height; 424 + 425 + let playerSprite: string; 426 + if (player.isDucking) { 427 + playerSprite = 'playerDuck'; 428 + } else if (player.isJumping) { 429 + if (player.velocityY < 0) { 430 + playerSprite = 'playerJump'; 431 + } else { 432 + playerSprite = 'playerFall'; 433 + } 434 + } else { 435 + // 3-frame walk cycle 436 + const walkFrames = ['playerWalk1', 'playerWalk2', 'playerWalk3']; 437 + playerSprite = walkFrames[player.frame]; 438 + } 439 + 440 + // When ducking, draw shorter 441 + const drawHeight = player.isDucking ? player.height * 0.6 : player.height; 442 + drawSprite(playerSprite, player.x, playerY, player.width, drawHeight); 443 + 444 + // Draw score 445 + ctx.fillStyle = '#000000'; 446 + ctx.font = `bold ${Math.max(12, Math.floor(14 * (scale / 2.5)))}px monospace`; 447 + ctx.textAlign = 'right'; 448 + ctx.fillText(String(score).padStart(5, '0'), canvasWidth - 10, 25); 449 + 450 + if (highScore > 0) { 451 + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; 452 + ctx.fillText( 453 + 'HI ' + String(highScore).padStart(5, '0'), 454 + canvasWidth - 70 * (scale / 2.5), 455 + 25 456 + ); 457 + } 458 + 459 + // Draw game over text (no overlay background) 460 + if (gameState === 'gameover') { 461 + ctx.fillStyle = '#000000'; 462 + ctx.font = `bold ${Math.max(14, Math.floor(20 * (scale / 2.5)))}px monospace`; 463 + ctx.textAlign = 'center'; 464 + ctx.fillText('GAME OVER', canvasWidth / 2, canvasHeight / 2 - 40); 465 + } 466 + 467 + animationId = requestAnimationFrame(gameLoop); 468 + } 469 + 470 + function resizeCanvas() { 471 + if (!canvas) return; 472 + const container = canvas.parentElement; 473 + if (!container) return; 474 + canvas.width = container.clientWidth; 475 + canvas.height = container.clientHeight; 476 + calculateScale(); 477 + initGroundTiles(); 478 + } 479 + 480 + let resizeObserver: ResizeObserver | undefined = $state(); 481 + 482 + onMount(async () => { 483 + ctx = canvas.getContext('2d'); 484 + await loadSprites(); 485 + resizeCanvas(); 486 + 487 + resizeObserver = new ResizeObserver(() => { 488 + resizeCanvas(); 489 + }); 490 + resizeObserver.observe(canvas.parentElement!); 491 + 492 + gameLoop(); 493 + }); 494 + 495 + onDestroy(() => { 496 + resizeObserver?.disconnect(); 497 + 498 + if (animationId) { 499 + cancelAnimationFrame(animationId); 500 + } 501 + }); 502 + </script> 503 + 504 + <svelte:window onkeydown={handleKeyDown} onkeyup={handleKeyUp} /> 505 + 506 + <div class="relative h-full w-full overflow-hidden"> 507 + <canvas bind:this={canvas} class="h-full w-full invert dark:invert-0" ontouchstart={handleTouch} 508 + ></canvas> 509 + 510 + {#if gameState === 'idle' || gameState === 'gameover'} 511 + <button 512 + onclick={startGame} 513 + 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" 514 + > 515 + {gameState === 'gameover' ? 'PLAY AGAIN' : 'START'} 516 + </button> 517 + {/if} 518 + </div>
+21
src/lib/cards/GameCards/DinoGameCard/SidebarItemDinoGameCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { Button } from '@foxui/core'; 3 + 4 + let { onclick }: { onclick: () => void } = $props(); 5 + </script> 6 + 7 + <Button {onclick} variant="ghost" class="w-full justify-start"> 8 + <svg 9 + xmlns="http://www.w3.org/2000/svg" 10 + viewBox="0 0 24 24" 11 + fill="currentColor" 12 + class="text-accent-600 dark:text-accent-400" 13 + > 14 + <!-- Dino silhouette --> 15 + <path 16 + d="M18 4h-2v2h-2v2h-2V6h-2V4H8v2H6v2H4v2H2v8h2v2h2v-2h2v-2h2v2h2v-2h2v2h2v-2h2V8h-2V6h-2V4zm-8 8H8v-2h2v2zm4 0h-2v-2h2v2z" 17 + /> 18 + </svg> 19 + 20 + Dino Game</Button 21 + >
+17
src/lib/cards/GameCards/DinoGameCard/index.ts
···
··· 1 + import type { CardDefinition } from '../types'; 2 + import DinoGameCard from './DinoGameCard.svelte'; 3 + import SidebarItemDinoGameCard from './SidebarItemDinoGameCard.svelte'; 4 + 5 + export const DinoGameCardDefinition = { 6 + type: 'dino-game', 7 + contentComponent: DinoGameCard, 8 + sidebarComponent: SidebarItemDinoGameCard, 9 + allowSetColor: true, 10 + createNew: (card) => { 11 + card.w = 4; 12 + card.h = 4; 13 + card.mobileW = 8; 14 + card.mobileH = 6; 15 + card.cardData = {}; 16 + } 17 + } as CardDefinition & { type: 'dino-game' };
+3 -1
src/lib/cards/index.ts
··· 3 import { BigSocialCardDefinition } from './BigSocialCard'; 4 import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 import { EmbedCardDefinition } from './EmbedCard'; 7 import { ImageCardDefinition } from './ImageCard'; 8 import { LinkCardDefinition } from './LinkCard'; ··· 28 MapCardDefinition, 29 ATProtoCollectionsCardDefinition, 30 SectionCardDefinition, 31 - BlueskyMediaCardDefinition 32 ] as const; 33 34 export const CardDefinitionsByType = AllCardDefinitions.reduce(
··· 3 import { BigSocialCardDefinition } from './BigSocialCard'; 4 import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 + import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; 7 import { EmbedCardDefinition } from './EmbedCard'; 8 import { ImageCardDefinition } from './ImageCard'; 9 import { LinkCardDefinition } from './LinkCard'; ··· 29 MapCardDefinition, 30 ATProtoCollectionsCardDefinition, 31 SectionCardDefinition, 32 + BlueskyMediaCardDefinition, 33 + DinoGameCardDefinition 34 ] as const; 35 36 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+6
static/dino/Instructions.url
···
··· 1 + [InternetShortcut] 2 + URL=https://kenney.nl/documentation/game-assets/tilemaps 3 + IDList= 4 + HotKey=0 5 + [{000214A0-0000-0000-C000-000000000046}] 6 + Prop3=19,11
+23
static/dino/License.txt
···
··· 1 + 2 + 3 + 1-Bit Platformer Pack (1.1) 4 + 5 + Created/distributed by Kenney (www.kenney.nl) 6 + Creation date: 02-11-2021 7 + 8 + ------------------------------ 9 + 10 + License: (Creative Commons Zero, CC0) 11 + http://creativecommons.org/publicdomain/zero/1.0/ 12 + 13 + This content is free to use in personal, educational and commercial projects. 14 + 15 + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) 16 + 17 + ------------------------------ 18 + 19 + Donate: http://support.kenney.nl 20 + Patreon: http://patreon.com/kenney/ 21 + 22 + Follow on Twitter for updates: 23 + http://twitter.com/KenneyNL
static/dino/Preview.png

This is a binary file and will not be displayed.

static/dino/Sample.png

This is a binary file and will not be displayed.

static/dino/Tilemap/monochrome_tilemap.png

This is a binary file and will not be displayed.

static/dino/Tilemap/monochrome_tilemap_packed.png

This is a binary file and will not be displayed.

static/dino/Tilemap/monochrome_tilemap_transparent.png

This is a binary file and will not be displayed.

static/dino/Tilemap/monochrome_tilemap_transparent_packed.png

This is a binary file and will not be displayed.

+9
static/dino/Tilesheet.txt
···
··· 1 + Tilesheet information: 2 + 3 + Tile size • 16px × 16px 4 + Space between tiles • 1px × 1px 5 + --- 6 + Total tiles (horizontal) • 20 tiles 7 + Total tiles (vertical) • 20 tiles 8 + --- 9 + Total tiles in sheet • 400 tiles
+7
static/dino/cells.txt
···
··· 1 + (all columns and rows are counted starting with 1, 1 at the top left) 2 + 3 + - floor at row 5, column 6 4 + - player at row 14, column 1-2 for walk cycle, column 4-5 for jump and column 6 for fall 5 + - mushroom (obstacle) at row 3, column 15 6 + - spikes (obstacle) at row 10, column 4 7 + - plants (obstacles) at column 17-19 and row 1-2