import { useRef, useEffect, useCallback, useState } from 'react' import './DinoGame.css' import { CANVAS_W, CANVAS_H, GROUND_Y, GRAVITY, JUMP_VEL, INITIAL_SPEED, SPEED_GROWTH, MAX_SPEED, PLAYER_X, PLAYER_W, PLAYER_H, MIN_GAP, MAX_GAP, OBSTACLE_TEMPLATES, } from './gameConstants.js' // ─── Helpers ────────────────────────────────────────────────────────────────── function randObstacle() { const t = OBSTACLE_TEMPLATES[Math.floor(Math.random() * OBSTACLE_TEMPLATES.length)] return { ...t, x: CANVAS_W + 60, y: GROUND_Y - t.h } } /** AABB collision with per-axis inward padding for forgiveness */ function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh, pad = 7) { return ( ax + pad < bx + bw - pad && ax + aw - pad > bx + pad && ay + pad < by + bh - pad && ay + ah - pad > by + pad ) } // ─── Canvas drawing ─────────────────────────────────────────────────────────── function roundRect(ctx, x, y, w, h, r = 5) { ctx.beginPath() ctx.moveTo(x + r, y) ctx.lineTo(x + w - r, y) ctx.quadraticCurveTo(x + w, y, x + w, y + r) ctx.lineTo(x + w, y + h - r) ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) ctx.lineTo(x + r, y + h) ctx.quadraticCurveTo(x, y + h, x, y + h - r) ctx.lineTo(x, y + r) ctx.quadraticCurveTo(x, y, x + r, y) ctx.closePath() } function drawBackground(ctx, bgOffset) { // Fill ctx.fillStyle = '#080c14' ctx.fillRect(0, 0, CANVAS_W, CANVAS_H) // Scrolling dot grid ctx.fillStyle = 'rgba(0, 180, 255, 0.055)' const spacing = 28 const startX = (-bgOffset % spacing) - spacing for (let x = startX; x < CANVAS_W + spacing; x += spacing) { for (let y = 10; y < GROUND_Y - 4; y += spacing) { ctx.fillRect(x, y, 1.5, 1.5) } } } function drawGround(ctx, bgOffset) { // Glowing ground line ctx.save() ctx.shadowBlur = 8 ctx.shadowColor = '#00ccff' ctx.strokeStyle = '#00ccff' ctx.lineWidth = 1.5 ctx.beginPath() ctx.moveTo(0, GROUND_Y) ctx.lineTo(CANVAS_W, GROUND_Y) ctx.stroke() ctx.restore() // Scrolling dashes below ground line ctx.save() ctx.strokeStyle = 'rgba(0, 140, 200, 0.35)' ctx.lineWidth = 1 ctx.setLineDash([18, 14]) ctx.lineDashOffset = -bgOffset ctx.beginPath() ctx.moveTo(0, GROUND_Y + 7) ctx.lineTo(CANVAS_W, GROUND_Y + 7) ctx.stroke() ctx.setLineDash([]) ctx.restore() } /** Pod player — kubernetes steering-wheel symbol inside a neon green box */ function drawPlayer(ctx, playerY, frameCount, isJumping) { const x = PLAYER_X const y = playerY const w = PLAYER_W const h = PLAYER_H const cx = x + w / 2 const cy = y + h / 2 - 1 ctx.save() // Outer glow ctx.shadowBlur = 14 ctx.shadowColor = '#00ff88' // Body fill roundRect(ctx, x, y, w, h, 5) ctx.fillStyle = '#081410' ctx.fill() // Body border ctx.strokeStyle = '#00ff88' ctx.lineWidth = 2 ctx.stroke() ctx.shadowBlur = 0 // Kubernetes helm wheel (6 spokes + outer ring) ctx.strokeStyle = '#00ff88' ctx.lineWidth = 1 for (let i = 0; i < 6; i++) { const a = (Math.PI / 3) * i - Math.PI / 6 ctx.beginPath() ctx.moveTo(cx + Math.cos(a) * 3, cy + Math.sin(a) * 3) ctx.lineTo(cx + Math.cos(a) * 9, cy + Math.sin(a) * 9) ctx.stroke() } ctx.beginPath() ctx.arc(cx, cy, 10, 0, Math.PI * 2) ctx.stroke() // Centre dot ctx.beginPath() ctx.arc(cx, cy, 2.5, 0, Math.PI * 2) ctx.fillStyle = '#00ff88' ctx.fill() // Running legs (2-frame animation, hidden while airborne) if (!isJumping) { const phase = Math.floor(frameCount / 6) % 2 ctx.fillStyle = '#00ff88' // Left leg const lh = phase === 0 ? 8 : 4 ctx.fillRect(x + 7, y + h, 6, lh) ctx.fillRect(x + 3, y + h + lh, 10, 3) // Right leg (opposite phase) const rh = phase === 0 ? 4 : 8 ctx.fillRect(x + w - 13, y + h, 6, rh) ctx.fillRect(x + w - 17, y + h + rh, 10, 3) } ctx.restore() } function drawObstacle(ctx, obs) { const { x, y, w, h, lines, color } = obs const cx = x + w / 2 const cy = y + h / 2 ctx.save() // Diagonal warning stripes clipped to obstacle rect roundRect(ctx, x, y, w, h, 4) ctx.fillStyle = 'rgba(0,0,0,0.82)' ctx.fill() ctx.save() roundRect(ctx, x, y, w, h, 4) ctx.clip() // Translucent diagonal stripes using 8-digit hex alpha ctx.strokeStyle = `${color}22` ctx.lineWidth = 5 for (let s = -h; s < w + h; s += 10) { ctx.beginPath() ctx.moveTo(x + s, y) ctx.lineTo(x + s - h, y + h) ctx.stroke() } ctx.restore() // Glowing border ctx.shadowBlur = 14 ctx.shadowColor = color roundRect(ctx, x, y, w, h, 4) ctx.strokeStyle = color ctx.lineWidth = 2 ctx.stroke() ctx.shadowBlur = 0 // Label text ctx.fillStyle = color ctx.textAlign = 'center' ctx.textBaseline = 'middle' if (lines.length === 1) { ctx.font = 'bold 10px monospace' ctx.fillText(lines[0], cx, cy) } else { ctx.font = 'bold 9px monospace' ctx.fillText(lines[0], cx, cy - 6) ctx.fillText(lines[1], cx, cy + 6) } ctx.restore() } function drawHUD(ctx, score, highScore) { // Score ctx.save() ctx.fillStyle = '#00ccff' ctx.font = 'bold 13px monospace' ctx.textAlign = 'right' ctx.fillText(`SCORE ${String(score).padStart(5, '0')}`, CANVAS_W - 14, 20) // High score ctx.fillStyle = 'rgba(0,200,255,0.42)' ctx.font = '11px monospace' ctx.fillText(`HI ${String(highScore).padStart(5, '0')}`, CANVAS_W - 14, 36) ctx.restore() } function drawIdleOverlay(ctx) { ctx.save() ctx.fillStyle = 'rgba(8, 12, 20, 0.72)' ctx.fillRect(0, 0, CANVAS_W, CANVAS_H) ctx.fillStyle = '#00ffcc' ctx.font = 'bold 15px monospace' ctx.textAlign = 'center' ctx.fillText('[ SPACE / ↑ / TAP TO LAUNCH ]', CANVAS_W / 2, CANVAS_H / 2 - 8) ctx.fillStyle = 'rgba(0,220,255,0.5)' ctx.font = '11px monospace' ctx.fillText('dodge the cluster faults', CANVAS_W / 2, CANVAS_H / 2 + 12) ctx.restore() } function drawGameOverOverlay(ctx, score, highScore) { ctx.save() ctx.fillStyle = 'rgba(8, 12, 20, 0.72)' ctx.fillRect(0, 0, CANVAS_W, CANVAS_H) ctx.fillStyle = '#ff4455' ctx.font = 'bold 17px monospace' ctx.textAlign = 'center' ctx.fillText('── POD TERMINATED ──', CANVAS_W / 2, CANVAS_H / 2 - 26) ctx.fillStyle = '#e0e8f0' ctx.font = '13px monospace' ctx.fillText(`score: ${score}`, CANVAS_W / 2, CANVAS_H / 2 - 2) if (score > 0 && score >= highScore) { ctx.fillStyle = '#ffee22' ctx.font = 'bold 11px monospace' ctx.fillText('✦ new high score ✦', CANVAS_W / 2, CANVAS_H / 2 + 16) } ctx.fillStyle = '#00ffcc' ctx.font = '12px monospace' ctx.fillText('[ SPACE / ↑ / TAP to respawn ]', CANVAS_W / 2, CANVAS_H / 2 + 38) ctx.restore() } // ─── Component ──────────────────────────────────────────────────────────────── /** * DinoGame — canvas-based infinite runner with a Kubernetes pod player * and DevOps-themed obstacles. * * Controls: Space / ArrowUp / click / tap to jump (or start / restart). */ export default function DinoGame() { const canvasRef = useRef(null) // All mutable game state lives in a ref so the rAF loop never re-renders const s = useRef({ status: 'idle', // 'idle' | 'playing' | 'gameover' playerY: GROUND_Y - PLAYER_H, velocityY: 0, isJumping: false, obstacles: [], bgOffset: 0, frameCount: 0, // frames while playing (used for animation & score) distTravelled: 0, // pixels travelled (used for obstacle spawning) nextSpawnAt: 400, // distTravelled value at which to spawn next obstacle speed: INITIAL_SPEED, score: 0, highScore: parseInt(localStorage.getItem('lab404_hi') ?? '0', 10), }) // Minimal React state — only for values shown in JSX buttons outside canvas const [gameStatus, setGameStatus] = useState('idle') // ── Input handler ───────────────────────────────────────────────────────── const handleAction = useCallback(() => { const g = s.current if (g.status === 'idle') { g.status = 'playing' setGameStatus('playing') } else if (g.status === 'playing' && !g.isJumping) { g.velocityY = JUMP_VEL g.isJumping = true } else if (g.status === 'gameover') { // Full reset g.status = 'playing' g.playerY = GROUND_Y - PLAYER_H g.velocityY = 0 g.isJumping = false g.obstacles = [] g.bgOffset = 0 g.frameCount = 0 g.distTravelled = 0 g.nextSpawnAt = 400 g.speed = INITIAL_SPEED g.score = 0 setGameStatus('playing') } }, []) // ── Keyboard listeners ──────────────────────────────────────────────────── useEffect(() => { const onKey = (e) => { if (e.code === 'Space' || e.code === 'ArrowUp') { e.preventDefault() handleAction() } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [handleAction]) // ── Game loop ───────────────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current const ctx = canvas.getContext('2d') let rafId const loop = () => { const g = s.current // ── Update ────────────────────────────────────────────────────────── if (g.status === 'playing') { g.frameCount++ // Speed ramp g.score = Math.floor(g.frameCount / 7) g.speed = Math.min(INITIAL_SPEED + g.score * SPEED_GROWTH, MAX_SPEED) // Background scroll g.bgOffset = (g.bgOffset + g.speed * 0.35) % CANVAS_W g.distTravelled += g.speed // Player physics g.velocityY += GRAVITY g.playerY += g.velocityY if (g.playerY >= GROUND_Y - PLAYER_H) { g.playerY = GROUND_Y - PLAYER_H g.velocityY = 0 g.isJumping = false } // Spawn obstacle when distance threshold is reached if (g.distTravelled >= g.nextSpawnAt) { g.obstacles.push(randObstacle()) const gap = MIN_GAP + Math.random() * (MAX_GAP - MIN_GAP) g.nextSpawnAt = g.distTravelled + gap } // Move & cull obstacles for (const o of g.obstacles) o.x -= g.speed g.obstacles = g.obstacles.filter(o => o.x + o.w > -10) // Collision detection const hit = g.obstacles.some(o => rectsOverlap(PLAYER_X, g.playerY, PLAYER_W, PLAYER_H, o.x, o.y, o.w, o.h) ) if (hit) { if (g.score > g.highScore) { g.highScore = g.score localStorage.setItem('lab404_hi', String(g.score)) } g.status = 'gameover' setGameStatus('gameover') } } else { // Even while idle/gameover keep background subtly drifting g.bgOffset = (g.bgOffset + 0.4) % CANVAS_W } // ── Draw ──────────────────────────────────────────────────────────── drawBackground(ctx, g.bgOffset) drawGround(ctx, g.bgOffset) g.obstacles.forEach(o => drawObstacle(ctx, o)) drawPlayer(ctx, g.playerY, g.frameCount, g.isJumping) drawHUD(ctx, g.score, g.highScore) if (g.status === 'idle') drawIdleOverlay(ctx) if (g.status === 'gameover') drawGameOverOverlay(ctx, g.score, g.highScore) rafId = requestAnimationFrame(loop) } rafId = requestAnimationFrame(loop) return () => cancelAnimationFrame(rafId) }, []) // runs once — game loop owns its own state via ref return (
{ e.preventDefault(); handleAction() }} >
) }