2026-04-13 00:16:56 +03:00

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>
)
}