417 lines
13 KiB
JavaScript
417 lines
13 KiB
JavaScript
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 (
|
|
<div
|
|
className="dino-game-wrapper"
|
|
onClick={handleAction}
|
|
onTouchStart={(e) => { e.preventDefault(); handleAction() }}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={CANVAS_W}
|
|
height={CANVAS_H}
|
|
className="dino-game-canvas"
|
|
role="img"
|
|
aria-label="Kubernetes pod runner mini-game. Press Space or tap to jump."
|
|
/>
|
|
</div>
|
|
)
|
|
}
|