从零开始,使用 React + TypeScript + Tailwind CSS + Framer Motion 打造属于你的 Chrome Dino Game
前言:为什么每个人都爱这只小恐龙?
还记得那个没有网络连接的午后,当 Chrome 浏览器告诉你”无法连接到互联网”时,一只可爱的小恐龙出现在屏幕前,按一下空格键,一场冒险就此开始。这个简单的游戏,却陪伴了全球数十亿用户度过了无数个断网时刻。
今天,我们将用最现代化的前端技术栈,从零开始复刻这个经典游戏。这不仅是一次技术练习,更是对游戏机制、动画原理和前端架构的深入探索。
本文亮点:
· ✅ 完整的游戏逻辑实现(碰撞检测、物理引擎、难度曲线)
· ✅ 现代化技术栈(React 18 + TypeScript + Tailwind CSS + Framer Motion)
· ✅ 像素级还原(包括奔跑、跳跃、蹲伏动画)
· ✅ 无任何第三方游戏引擎依赖
· ✅ 完整的代码示例,可直接运行
一、技术栈选型与项目初始化
1.1 核心技术栈
技术 版本 用途
React 18.2+ UI 框架,组件化开发
TypeScript 5.x 类型安全,减少运行时错误
Tailwind CSS 3.x 原子化样式,快速布局
Framer Motion 10.x 流畅动画和手势交互
Vite 5.x 极速构建工具
1.2 项目初始化
# 创建项目
npm create vite@latest dino-game -- --template react-ts
# 进入项目目录
cd dino-game
# 安装依赖
npm install
# 安装 Tailwind CSS 和 Framer Motion
npm install -D tailwindcss postcss autoprefixer
npm install framer-motion
# 初始化 Tailwind CSS
npx tailwindcss init -p
1.3 配置文件
tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'dino-black': '#535353',
'dino-bg': '#f7f7f7',
'cactus': '#4caf50',
},
fontFamily: {
'pixel': ['"Press Start 2P"', 'monospace'],
},
keyframes: {
'walk': {
'0%, 100%': { backgroundPosition: '0 0' },
'50%': { backgroundPosition: '-48px 0' }
},
'ground-move': {
'0%': { backgroundPosition: '0 0' },
'100%': { backgroundPosition: '-48px 0' }
},
'cloud-move': {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(-100%)' }
}
},
animation: {
'walk': 'walk 0.2s steps(2) infinite',
'ground-move': 'ground-move 0.2s linear infinite',
'cloud-move': 'cloud-move 20s linear infinite'
}
},
},
plugins: [],
}
src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
@layer base {
body {
@apply bg-gradient-to-br from-gray-100 to-gray-200 min-h-screen flex items-center justify-center;
font-family: 'Press Start 2P', monospace;
}
}
@layer components {
.game-container {
@apply relative bg-white rounded-2xl shadow-2xl overflow-hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.game-canvas {
@apply block bg-dino-bg cursor-pointer;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
}
二、游戏核心逻辑设计
2.1 游戏状态与类型定义
src/types/game.ts
// 游戏状态枚举
export enum GameState {
IDLE = 'idle', // 待机状态
RUNNING = 'running', // 运行中
GAME_OVER = 'game_over', // 游戏结束
PAUSED = 'paused' // 暂停状态
}
// 恐龙状态枚举
export enum DinoState {
RUNNING = 'running', // 奔跑
JUMPING = 'jumping', // 跳跃
DUCKING = 'ducking' // 蹲伏
}
// 障碍物类型枚举
export enum ObstacleType {
SMALL_CACTUS = 'small', // 小型仙人掌
LARGE_CACTUS = 'large', // 大型仙人掌
BIRD = 'bird' // 飞鸟
}
// 障碍物接口
export interface Obstacle {
id: number;
type: ObstacleType;
x: number; // X轴坐标
width: number; // 宽度
height: number; // 高度
y: number; // Y轴坐标
}
// 游戏配置接口
export interface GameConfig {
gravity: number; // 重力加速度
jumpForce: number; // 跳跃初速度
groundY: number; // 地面Y坐标
gameSpeed: number; // 游戏速度
speedIncrement: number; // 速度增量
maxSpeed: number; // 最大速度
score: number; // 当前分数
highScore: number; // 最高分
}
// 游戏状态接口
export interface GameStateType {
status: GameState;
dino: {
state: DinoState;
y: number;
velocityY: number;
isOnGround: boolean;
};
obstacles: Obstacle[];
config: GameConfig;
frame: number;
}
2.2 游戏主组件实现
src/App.tsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { GameState, DinoState, ObstacleType, GameStateType } from './types/game';
// 游戏常量定义
const GROUND_Y = 280;
const DINO_WIDTH = 48;
const DINO_HEIGHT = 48;
const DINO_DUCK_HEIGHT = 32;
const GRAVITY = 0.8;
const JUMP_FORCE = -12;
const INITIAL_SPEED = 5;
const MAX_SPEED = 15;
const SPEED_INCREMENT = 0.002;
const OBSTACLE_SPAWN_INTERVAL = 2000;
const MIN_OBSTACLE_INTERVAL = 800;
// 恐龙精灵帧(像素风格的8-bit恐龙)
const DINO_SPRITES = {
running: [
'🐱🏍', // 奔跑帧1
'🐱👤' // 奔跑帧2
],
jumping: '🦖',
ducking: '🐱👓',
idle: '🦕'
};
function App() {
// 游戏状态
const [gameState, setGameState] = useState<GameStateType['status']>(GameState.IDLE);
const [dinoState, setDinoState] = useState<DinoState>(DinoState.RUNNING);
const [dinoY, setDinoY] = useState(GROUND_Y - DINO_HEIGHT);
const [velocityY, setVelocityY] = useState(0);
const [isOnGround, setIsOnGround] = useState(true);
const [obstacles, setObstacles] = useState<GameStateType['obstacles']>([]);
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(() => {
const saved = localStorage.getItem('dino_highscore');
return saved ? parseInt(saved, 10) : 0;
});
const [gameSpeed, setGameSpeed] = useState(INITIAL_SPEED);
const [frame, setFrame] = useState(0);
const [isDucking, setIsDucking] = useState(false);
// Refs
const gameLoopRef = useRef<number>();
const lastTimestampRef = useRef<number>(0);
const spawnTimerRef = useRef<NodeJS.Timeout>();
// 重置游戏
const resetGame = useCallback(() => {
setGameState(GameState.RUNNING);
setDinoState(DinoState.RUNNING);
setDinoY(GROUND_Y - DINO_HEIGHT);
setVelocityY(0);
setIsOnGround(true);
setObstacles([]);
setScore(0);
setGameSpeed(INITIAL_SPEED);
setIsDucking(false);
}, []);
// 跳跃逻辑
const jump = useCallback(() => {
if (!isOnGround) return;
if (gameState !== GameState.RUNNING) return;
setVelocityY(JUMP_FORCE);
setIsOnGround(false);
setDinoState(DinoState.JUMPING);
// 播放跳跃音效(使用Web Audio API)
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 880;
gainNode.gain.value = 0.1;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.00001, audioContext.currentTime + 0.3);
oscillator.stop(audioContext.currentTime + 0.3);
}, [isOnGround, gameState]);
// 蹲伏逻辑
const duck = useCallback(() => {
if (!isOnGround) return;
if (gameState !== GameState.RUNNING) return;
setIsDucking(true);
setDinoState(DinoState.DUCKING);
setDinoY(GROUND_Y - DINO_DUCK_HEIGHT);
}, [isOnGround, gameState]);
// 站立逻辑
const stand = useCallback(() => {
if (!isDucking) return;
setIsDucking(false);
setDinoState(DinoState.RUNNING);
setDinoY(GROUND_Y - DINO_HEIGHT);
}, [isDucking]);
// 碰撞检测
const checkCollision = useCallback(() => {
const dinoHitbox = {
x: 80, // 恐龙固定X位置
y: dinoY,
width: DINO_WIDTH,
height: isDucking ? DINO_DUCK_HEIGHT : DINO_HEIGHT
};
for (const obstacle of obstacles) {
const obstacleHitbox = {
x: obstacle.x,
y: obstacle.y,
width: obstacle.width,
height: obstacle.height
};
// AABB碰撞检测
if (dinoHitbox.x < obstacleHitbox.x + obstacleHitbox.width &&
dinoHitbox.x + dinoHitbox.width > obstacleHitbox.x &&
dinoHitbox.y < obstacleHitbox.y + obstacleHitbox.height &&
dinoHitbox.y + dinoHitbox.height > obstacleHitbox.y) {
return true; // 发生碰撞
}
}
return false;
}, [dinoY, isDucking, obstacles]);
// 生成障碍物
const spawnObstacle = useCallback(() => {
if (gameState !== GameState.RUNNING) return;
const random = Math.random();
let type: ObstacleType;
let width: number;
let height: number;
let y: number;
if (random < 0.7) {
// 70%概率生成仙人掌
const isLarge = Math.random() < 0.3;
if (isLarge) {
type = ObstacleType.LARGE_CACTUS;
width = 32;
height = 56;
y = GROUND_Y - height;
} else {
type = ObstacleType.SMALL_CACTUS;
width = 24;
height = 40;
y = GROUND_Y - height;
}
} else {
// 30%概率生成飞鸟
type = ObstacleType.BIRD;
width = 40;
height = 32;
y = GROUND_Y - 80; // 飞鸟在空中
}
setObstacles(prev => [...prev, {
id: Date.now(),
type,
x: window.innerWidth,
width,
height,
y
}]);
}, [gameState]);
// 更新障碍物位置
const updateObstacles = useCallback(() => {
setObstacles(prev => {
const updated = prev
.map(obs => ({
...obs,
x: obs.x - gameSpeed
}))
.filter(obs => obs.x + obs.width > 0); // 移除超出屏幕的障碍物
// 当障碍物通过恐龙时增加分数
const passedCount = prev.filter(obs => obs.x + obs.width < 80 && obs.x > 0).length;
if (passedCount > 0) {
setScore(s => s + passedCount * 10);
}
return updated;
});
}, [gameSpeed]);
// 更新恐龙物理
const updateDinoPhysics = useCallback(() => {
if (!isOnGround) {
const newVelocityY = velocityY + GRAVITY;
const newY = dinoY + newVelocityY;
if (newY >= GROUND_Y - (isDucking ? DINO_DUCK_HEIGHT : DINO_HEIGHT)) {
setDinoY(GROUND_Y - (isDucking ? DINO_DUCK_HEIGHT : DINO_HEIGHT));
setVelocityY(0);
setIsOnGround(true);
setDinoState(isDucking ? DinoState.DUCKING : DinoState.RUNNING);
} else {
setDinoY(newY);
setVelocityY(newVelocityY);
}
}
}, [dinoY, isOnGround, velocityY, isDucking]);
// 更新分数和速度
const updateScoreAndSpeed = useCallback(() => {
setScore(prev => prev + 0.1);
setGameSpeed(prev => {
const newSpeed = prev + SPEED_INCREMENT;
return Math.min(newSpeed, MAX_SPEED);
});
}, []);
// 主游戏循环
const gameLoop = useCallback(() => {
if (gameState !== GameState.RUNNING) return;
updateDinoPhysics();
updateObstacles();
updateScoreAndSpeed();
setFrame(prev => prev + 1);
// 碰撞检测
if (checkCollision()) {
setGameState(GameState.GAME_OVER);
if (Math.floor(score) > highScore) {
setHighScore(Math.floor(score));
localStorage.setItem('dino_highscore', Math.floor(score).toString());
}
}
}, [gameState, updateDinoPhysics, updateObstacles, updateScoreAndSpeed, checkCollision, score, highScore]);
// 启动游戏循环
useEffect(() => {
if (gameState === GameState.RUNNING) {
const interval = setInterval(gameLoop, 1000 / 60); // 60 FPS
return () => clearInterval(interval);
}
}, [gameState, gameLoop]);
// 障碍物生成定时器
useEffect(() => {
if (gameState === GameState.RUNNING) {
const spawnInterval = Math.max(
MIN_OBSTACLE_INTERVAL,
OBSTACLE_SPAWN_INTERVAL - (gameSpeed - INITIAL_SPEED) * 50
);
spawnTimerRef.current = setInterval(() => {
spawnObstacle();
}, spawnInterval);
return () => {
if (spawnTimerRef.current) {
clearInterval(spawnTimerRef.current);
}
};
}
}, [gameState, gameSpeed, spawnObstacle]);
// 键盘事件监听
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' || e.code === 'ArrowUp') {
e.preventDefault();
if (gameState === GameState.IDLE) {
resetGame();
} else if (gameState === GameState.GAME_OVER) {
resetGame();
} else if (gameState === GameState.RUNNING) {
jump();
}
} else if (e.code === 'ArrowDown') {
e.preventDefault();
if (gameState === GameState.RUNNING) {
duck();
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'ArrowDown') {
e.preventDefault();
stand();
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [gameState, jump, duck, stand, resetGame]);
// 触摸事件(移动端支持)
useEffect(() => {
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
const screenHeight = window.innerHeight;
const isBottomHalf = touch.clientY > screenHeight / 2;
if (isBottomHalf) {
duck();
} else {
if (gameState === GameState.IDLE || gameState === GameState.GAME_OVER) {
resetGame();
} else {
jump();
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
e.preventDefault();
stand();
};
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
return () => {
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleTouchEnd);
};
}, [gameState, jump, duck, stand, resetGame]);
// 获取恐龙显示字符
const getDinoChar = () => {
if (gameState === GameState.IDLE) return DINO_SPRITES.idle;
if (dinoState === DinoState.JUMPING) return DINO_SPRITES.jumping;
if (dinoState === DinoState.DUCKING) return DINO_SPRITES.ducking;
return DINO_SPRITES.running[Math.floor(frame / 10) % 2];
};
return (
<div className="relative w-full max-w-4xl">
{/* 游戏容器 */}
<div className="game-container relative">
{/* 游戏画布 */}
<div
className="game-canvas relative w-full h-[400px] overflow-hidden bg-gradient-to-b from-gray-50 to-gray-100"
style={{ cursor: 'pointer' }}
onClick={() => {
if (gameState === GameState.IDLE || gameState === GameState.GAME_OVER) {
resetGame();
} else if (gameState === GameState.RUNNING) {
jump();
}
}}
>
{/* 天空背景 */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-8 left-1/2 text-xs text-gray-400 animate-cloud-move whitespace-nowrap">
☁️ ☁️ ☁️
</div>
</div>
{/* 分数显示 */}
<div className="absolute top-4 right-6 text-right">
<div className="text-sm text-gray-600">
<span className="font-pixel">SCORE</span>
<span className="ml-2 font-pixel text-lg">{Math.floor(score)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
<span className="font-pixel">BEST</span>
<span className="ml-2 font-pixel">{highScore}</span>
</div>
</div>
{/* 速度指示器(难度显示) */}
<div className="absolute top-4 left-6 text-xs text-gray-400 font-pixel">
SPEED: {(gameSpeed / INITIAL_SPEED).toFixed(1)}x
</div>
{/* 恐龙 */}
<motion.div
className="absolute text-6xl select-none"
style={{
left: '80px',
top: `${dinoY}px`,
transform: dinoState === DinoState.JUMPING ? 'scale(1.1)' : 'scale(1)',
filter: dinoState === DinoState.JUMPING ? 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' : 'none'
}}
animate={{
y: dinoState === DinoState.JUMPING ? [0, -5, 0] : 0,
rotate: dinoState === DinoState.JUMPING ? [-5, 5, -5] : 0
}}
transition={{ duration: 0.1, repeat: dinoState === DinoState.JUMPING ? Infinity : 0 }}
>
{getDinoChar()}
</motion.div>
{/* 障碍物 */}
<AnimatePresence>
{obstacles.map(obstacle => (
<motion.div
key={obstacle.id}
className="absolute text-4xl select-none"
style={{
left: `${obstacle.x}px`,
top: `${obstacle.y}px`,
}}
initial={{ x: 100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -100, opacity: 0 }}
transition={{ duration: 0.1 }}
>
{obstacle.type === ObstacleType.SMALL_CACTUS && '🌵'}
{obstacle.type === ObstacleType.LARGE_CACTUS && '🌵🌵'}
{obstacle.type === ObstacleType.BIRD && '🐦'}
</motion.div>
))}
</AnimatePresence>
{/* 地面 */}
<div
className="absolute bottom-0 left-0 right-0 h-4 bg-gradient-to-r from-gray-700 to-gray-600"
style={{ top: `${GROUND_Y}px` }}
>
<div className="absolute inset-0 bg-repeat-x animate-ground-move"
style={{ backgroundImage: 'repeating-linear-gradient(90deg, #4a4a4a 0px, #4a4a4a 12px, #6b6b6b 12px, #6b6b6b 24px)' }} />
</div>
{/* 游戏状态覆盖层 */}
<AnimatePresence>
{gameState === GameState.IDLE && (
<motion.div
className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="text-center bg-white rounded-2xl p-8 shadow-2xl">
<div className="text-7xl mb-4">🦖</div>
<h2 className="text-2xl font-pixel text-gray-800 mb-2">DINO GAME</h2>
<p className="text-sm text-gray-500 mb-4">Press SPACE or Click to Start</p>
<div className="flex gap-4 text-xs text-gray-400">
<span>⬆️ Jump</span>
<span>⬇️ Duck</span>
</div>
</div>
</motion.div>
)}
{gameState === GameState.GAME_OVER && (
<motion.div
className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="text-center bg-white rounded-2xl p-8 shadow-2xl">
<div className="text-7xl mb-4">💀</div>
<h2 className="text-2xl font-pixel text-gray-800 mb-2">GAME OVER</h2>
<p className="text-sm text-gray-600 mb-2">Score: {Math.floor(score)}</p>
<p className="text-sm text-gray-500 mb-4">Best: {highScore}</p>
<button
onClick={resetGame}
className="px-6 py-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-lg font-pixel text-sm hover:shadow-lg transition-all"
>
TRY AGAIN
</button>
</div>
</motion.div>
)}
{gameState === GameState.PAUSED && (
<motion.div
className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="text-center bg-white rounded-2xl p-8 shadow-2xl">
<div className="text-6xl mb-4">⏸️</div>
<h2 className="text-2xl font-pixel text-gray-800">PAUSED</h2>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* 游戏说明 */}
<div className="mt-6 text-center text-sm text-gray-500 font-pixel">
<div className="flex justify-center gap-8 mb-2">
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-gray-800 text-white rounded text-xs">SPACE</kbd>
<span>or</span>
<kbd className="px-2 py-1 bg-gray-800 text-white rounded text-xs">↑</kbd>
<span>Jump</span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-gray-800 text-white rounded text-xs">↓</kbd>
<span>Duck</span>
</div>
</div>
<p className="text-xs">Mobile: Tap top half to jump • Tap bottom half to duck</p>
</div>
</div>
);
}
export default App;
三、高级功能扩展
3.1 添加白天/黑夜模式切换
// 在 App 组件中添加
const [isDarkMode, setIsDarkMode] = useState(false);
// 添加切换函数
const toggleTheme = useCallback(() => {
setIsDarkMode(prev => !prev);
}, []);
// 修改游戏容器背景
<div className={`game-canvas relative w-full h-[400px] overflow-hidden transition-colors duration-300 ${
isDarkMode ? 'bg-gradient-to-b from-gray-800 to-gray-900' : 'bg-gradient-to-b from-gray-50 to-gray-100'
}`}>
{/* 添加主题切换按钮 */}
<button
onClick={toggleTheme}
className="absolute bottom-4 right-4 z-10 p-2 rounded-full bg-white/20 backdrop-blur-sm text-sm"
>
{isDarkMode ? '☀️' : '🌙'}
</button>
</div>
3.2 添加音效系统
// 创建音效管理器
class SoundManager {
private audioContext: AudioContext;
constructor() {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
playJump() {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.frequency.value = 880;
gainNode.gain.value = 0.1;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.00001, this.audioContext.currentTime + 0.3);
oscillator.stop(this.audioContext.currentTime + 0.3);
}
playGameOver() {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.frequency.value = 440;
gainNode.gain.value = 0.15;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.00001, this.audioContext.currentTime + 0.5);
oscillator.stop(this.audioContext.currentTime + 0.5);
}
playScore() {
const oscillator = this.audioContext.createOscillator();
const gainNode = this.audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.frequency.value = 660;
gainNode.gain.value = 0.05;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.00001, this.audioContext.currentTime + 0.1);
oscillator.stop(this.audioContext.currentTime + 0.1);
}
}
// 在组件中使用
const soundManager = useRef(new SoundManager());
// 在跳跃时播放音效
const jump = useCallback(() => {
if (!isOnGround) return;
if (gameState !== GameState.RUNNING) return;
soundManager.current.playJump();
setVelocityY(JUMP_FORCE);
setIsOnGround(false);
setDinoState(DinoState.JUMPING);
}, [isOnGround, gameState]);
// 在游戏结束时播放
if (checkCollision()) {
soundManager.current.playGameOver();
setGameState(GameState.GAME_OVER);
}
3.3 添加粒子效果系统
// 创建粒子组件
const ParticleEffect: React.FC<{ x: number; y: number; onComplete: () => void }> = ({ x, y, onComplete }) => {
const particles = Array.from({ length: 12 }, (_, i) => ({
id: i,
angle: (i / 12) * Math.PI * 2,
distance: Math.random() * 30 + 10,
size: Math.random() * 6 + 2
}));
return (
<AnimatePresence>
{particles.map(particle => (
<motion.div
key={particle.id}
className="absolute w-2 h-2 bg-yellow-500 rounded-full"
style={{ left: x, top: y }}
initial={{ scale: 1, opacity: 1 }}
animate={{
x: Math.cos(particle.angle) * particle.distance,
y: Math.sin(particle.angle) * particle.distance,
scale: 0,
opacity: 0
}}
transition={{ duration: 0.5 }}
onAnimationComplete={onComplete}
/>
))}
</AnimatePresence>
);
};
// 在恐龙跳跃时添加粒子效果
const [showParticles, setShowParticles] = useState(false);
const [particlePos, setParticlePos] = useState({ x: 0, y: 0 });
const jump = useCallback(() => {
if (!isOnGround) return;
if (gameState !== GameState.RUNNING) return;
setParticlePos({ x: 100, y: dinoY + DINO_HEIGHT });
setShowParticles(true);
setTimeout(() => setShowParticles(false), 500);
// ... 原有跳跃逻辑
}, [isOnGround, gameState, dinoY]);
四、性能优化与最佳实践
4.1 使用 useCallback 和 useMemo 优化渲染
// 缓存碰撞检测函数
const checkCollision = useCallback(() => {
// ... 碰撞检测逻辑
}, [dinoY, isDucking, obstacles]);
// 缓存分数显示
const displayScore = useMemo(() => Math.floor(score), [score]);
// 缓存速度显示
const speedMultiplier = useMemo(() => (gameSpeed / INITIAL_SPEED).toFixed(1), [gameSpeed]);
4.2 使用 requestAnimationFrame 替代 setInterval
useEffect(() => {
if (gameState !== GameState.RUNNING) return;
let animationId: number;
let lastTime = performance.now();
const animate = (currentTime: number) => {
const deltaTime = Math.min(100, currentTime - lastTime);
if (deltaTime >= 16) { // 约60 FPS
gameLoop();
lastTime = currentTime;
}
animationId = requestAnimationFrame(animate);
};
animationId = requestAnimationFrame(animate);
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
};
}, [gameState, gameLoop]);
4.3 虚拟化障碍物列表
// 使用 useMemo 优化障碍物渲染
const visibleObstacles = useMemo(() => {
return obstacles.filter(obs => obs.x + obs.width > 0 && obs.x < window.innerWidth);
}, [obstacles]);
五、完整代码清单
5.1 项目结构
dino-game/
├── src/
│ ├── App.tsx # 主游戏组件
│ ├── main.tsx # 入口文件
│ ├── index.css # 全局样式
│ ├── types/
│ │ └── game.ts # TypeScript类型定义
│ ├── hooks/
│ │ ├── useGameLoop.ts # 游戏循环Hook
│ │ └── useKeyboard.ts # 键盘事件Hook
│ └── utils/
│ ├── collision.ts # 碰撞检测工具
│ └── sound.ts # 音效管理
├── public/
│ └── favicon.ico
├── index.html
├── package.json
├── vite.config.ts
├── tailwind.config.js
└── tsconfig.json
5.2 运行项目
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
# 预览生产版本
npm run preview
六、常见问题与解决方案
Q1: 跳跃手感不流畅怎么办?
解决方案: 调整重力常数和跳跃初速度
const GRAVITY = 0.8; // 重力加速度
const JUMP_FORCE = -12; // 跳跃初速度
Q2: 障碍物生成间隔不均匀?
解决方案: 使用动态间隔,根据游戏速度调整
const spawnInterval = Math.max(
MIN_OBSTACLE_INTERVAL,
OBSTACLE_SPAWN_INTERVAL - (gameSpeed - INITIAL_SPEED) * 50
);
Q3: 移动端触摸不灵敏?
解决方案: 使用 touchstart 和 touchend 事件,并区分点击区域
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
const isBottomHalf = touch.clientY > window.innerHeight / 2;
if (isBottomHalf) {
duck();
} else {
jump();
}
};
七、总结
通过本教程,我们使用现代化的前端技术栈(React + TypeScript + Tailwind CSS + Framer Motion)完整复刻了经典的 Chrome Dino Game。项目实现了:
✅ 完整的游戏机制
· 重力物理系统
· 精确的碰撞检测
· 动态难度曲线
· 分数和最高分记录
✅ 流畅的动画效果
· 恐龙奔跑、跳跃、蹲伏动画
· 障碍物移动动画
· 游戏状态切换动画
✅ 良好的用户体验
· 键盘和触摸双支持
· 响应式设计
· 音效反馈(可选)
· 粒子特效(可选)
✅ 优秀的代码质量
· TypeScript 类型安全
· 组件化架构
· 性能优化
· 可扩展性设计
这个项目不仅是一个游戏,更是一个完整的前端架构示例。你可以在此基础上继续扩展,添加更多的障碍物类型、道具系统、关卡系统,甚至将它集成到你的个人网站中。
扩展建议:
· 添加更多恐龙皮肤
· 实现无尽模式排行榜
· 添加成就系统
· 集成 WebSocket 实现多人对战
现在,打开浏览器,按下空格键,和你的小恐龙一起奔跑吧!🦖💨
文章纯原创 请不要侵权 by yuyu的博客 可能有部署语法错误 还请包容。













评论(0)
暂无评论