TypeScript小游戏开发-吃豆人(附源码,趣味编程)

基于Canvas的吃豆人小游戏(附完整源码,TypeScript) 吃豆人(Pac-Man)是一款经典的街机游戏,自1980年推出以来,一直是游戏界的标志性作品。玩家控制一个黄色的小圆点,在迷宫中移动,吃掉所有的豆子,同时躲避追逐的幽灵。当玩家触碰到幽灵时,游戏结束。这款游戏不仅考验玩家的反应能力,还考验策略和规划能力。在本项目中,我们将使用TypeScriptReact和来实现一个简化版的吃豆人游戏。通过这个项目,你将了解到如何结合现代前端技术来实现经典游戏的复刻,并掌握游戏开发中的一些核心技术。 阅读详情

🎉 经典游戏的魅力,永不褪色!希望你能通过这篇文章,重新体验到吃豆人的乐趣!

      还不快点赞!!!  

目录

背景介绍

技术要点

1. TypeScript 语言特点及优势

2. HTML5 Canvas 2D 绘图 API

3. React 状态管理与组件化

4. 游戏逻辑与碰撞检测

5. 动画与帧循环

完整代码

源码下载

代码分析

1. 程序初始化与显示设置

2. 游戏画布的绘制

3. 吃豆人和幽灵的移动逻辑

4. 碰撞检测与游戏结束条件

5. 爆炸效果的实现

6. 总结

注意事项

写在后面

版权声明

参考资料


背景介绍

吃豆人(Pac-Man)是一款经典的街机游戏,自1980年推出以来,一直是游戏界的标志性作品。玩家控制一个黄色的小圆点,在迷宫中移动,吃掉所有的豆子,同时躲避追逐的幽灵。当玩家触碰到幽灵时,游戏结束。这款游戏不仅考验玩家的反应能力,还考验策略和规划能力。

在本项目中,我们将使用 TypeScript、React 和 HTML5 Canvas 来实现一个简化版的吃豆人游戏。通过这个项目,你将了解到如何结合现代前端技术来实现经典游戏的复刻,并掌握游戏开发中的一些核心技术。


技术要点

实现吃豆人游戏需要掌握以下关键技术,它们共同构成了实现这一经典游戏的基础。

1. TypeScript 语言特点及优势

TypeScript 是 JavaScript 的超集,提供了类型系统和面向对象编程的支持。它在实现吃豆人游戏中的主要优势包括:

  • 类型安全:通过类型注解减少运行时错误,提升代码的可维护性。

  • 面向对象编程:支持类和接口,便于将游戏对象(如吃豆人、幽灵、豆子等)封装为独立的模块。

  • 工具友好:与现代开发工具(如 VS Code)集成良好,提供智能提示和代码检查功能。

2. HTML5 Canvas 2D 绘图 API

HTML5 Canvas 提供了强大的 2D 绘图能力,是实现游戏画面的核心工具。关键特性包括:

  • 绘图上下文:通过 getContext('2d') 获取绘图上下文,支持绘制图形、文本和图像。

  • 图形绘制:使用 arc、fillRect 等方法绘制圆形豆子、矩形障碍物和吃豆人。

  • 透明度和渐变:通过 fillStyle 和 globalAlpha 控制颜色透明度,实现爆炸效果的渐变。

3. React 状态管理与组件化

虽然游戏的核心逻辑主要依赖 Canvas 和 TypeScript,但 React 提供了组件化开发的优势:

  • 状态管理:通过 useState 和 useEffect 管理游戏状态,避免不必要的重新渲染。

  • 事件处理:通过 React 的事件系统(如键盘事件)增强交互性,允许玩家通过键盘控制吃豆人移动。

  • 组件化:将游戏的不同部分(如游戏画布、爆炸效果等)拆分为独立的组件,提升代码的可读性和可维护性。

4. 游戏逻辑与碰撞检测

游戏的核心逻辑包括:

  • 移动逻辑:控制吃豆人和幽灵的移动。

  • 碰撞检测:检测吃豆人是否吃到豆子,以及是否与幽灵碰撞。

  • 游戏结束条件:当吃豆人与幽灵碰撞时,游戏结束。

5. 动画与帧循环

动画效果的实现依赖于高效的帧循环,核心技术包括:

  • requestAnimationFrame:浏览器提供的 API,用于实现平滑的动画效果。它会自动根据屏幕刷新率调整帧率,确保动画的流畅性。

  • 帧循环逻辑:在每一帧中更新游戏状态、清除画布并重新绘制,实现动态效果。


完整代码

以下是实现吃豆人游戏的完整代码:

index.tsx(游戏主逻辑,核心)

import React, { useState, useEffect, useCallback } from 'react';
import { GameCanvas } from './components/GameCanvas';
import { ExplosionEffect } from './components/ExplosionEffect';
import { GameState, Position, Ghost, Dot, Obstacle } from './types';

const CELL_SIZE = 40;
const GAME_SPEED = 150;
const GHOST_COLORS = ['#FFB6C1', '#87CEEB', '#98FB98', '#DDA0DD'];
const GRID_COLS = 10;
const GRID_ROWS = 10;

// 初始化豆子
const initializeDots = (): Dot[] => {
  const dots: Dot[] = [];
  for (let x = 0; x < GRID_COLS; x++) {
    for (let y = 0; y < GRID_ROWS; y++) {
      dots.push({
        position: { x: x * CELL_SIZE, y: y * CELL_SIZE },
        eaten: false
      });
    }
  }
  return dots;
};

// 初始化障碍物
const initializeObstacles = (): Obstacle[] => {
  const obstacles: Obstacle[] = [];
  const numObstacles = 8; // 障碍物数量
  
  for (let i = 0; i < numObstacles; i++) {
    let position;
    do {
      position = {
        x: Math.floor(Math.random() * GRID_COLS) * CELL_SIZE,
        y: Math.floor(Math.random() * GRID_ROWS) * CELL_SIZE
      };
    } while (
      // 确保障碍物不会出现在起始位置和其他障碍物位置
      (position.x === 0 && position.y === 0) ||
      obstacles.some(obs => 
        obs.position.x === position.x && 
        obs.position.y === position.y
      )
    );
    
    obstacles.push({ position });
  }
  return obstacles;
};

// 检查是否与障碍物碰撞
const checkObstacleCollision = (position: Position, obstacles: Obstacle[]): boolean => {
  return obstacles.some(obstacle => 
    obstacle.position.x === position.x && 
    obstacle.position.y === position.y
  );
};

export const PacmanGame: React.FC = () => {
  const [gameState, setGameState] = useState<GameState>({
    pacman: {
      position: { x: 0, y: 0 },
      direction: 'right',
      mouthOpen: true
    },
    ghosts: GHOST_COLORS.map((color, index) => {
      // 将怪物放在地图右下角区域
      const row = Math.floor(GRID_ROWS * 0.7) + index % 2; // 从70%的位置开始
      const col = Math.floor(GRID_COLS * 0.7) + Math.floor(index / 2); // 从70%的位置开始
      return {
        position: { 
          x: Math.min(col, GRID_COLS - 1) * CELL_SIZE,
          y: Math.min(row, GRID_ROWS - 1) * CELL_SIZE
        },
        color,
        direction: 'right'
      };
    }),
    dots: initializeDots(),
    obstacles: initializeObstacles(),
    gameOver: false,
    score: 0
  });

  const [showExplosion, setShowExplosion] = useState(false);
  const [explosionPosition, setExplosionPosition] = useState<Position>({ x: 0, y: 0 });

  const moveGhosts = useCallback(() => {
    setGameState(prev => ({
      ...prev,
      ghosts: prev.ghosts.map(ghost => {
        const newPosition = { ...ghost.position };
        const step = CELL_SIZE;

        let attempts = 0;
        let validMove = false;
        
        while (!validMove && attempts < 4) {
          const directions: ('right' | 'left' | 'up' | 'down')[] = ['right', 'left', 'up', 'down'];
          ghost.direction = directions[Math.floor(Math.random() * 4)];
          
          const testPosition = { ...ghost.position };
          switch (ghost.direction) {
            case 'right':
              testPosition.x += step;
              break;
            case 'left':
              testPosition.x -= step;
              break;
            case 'down':
              testPosition.y += step;
              break;
            case 'up':
              testPosition.y -= step;
              break;
          }

          if (
            testPosition.x >= 0 && 
            testPosition.x < GRID_COLS * CELL_SIZE &&
            testPosition.y >= 0 && 
            testPosition.y < GRID_ROWS * CELL_SIZE &&
            !checkObstacleCollision(testPosition, prev.obstacles)
          ) {
            validMove = true;
            newPosition.x = testPosition.x;
            newPosition.y = testPosition.y;
          }
          
          attempts++;
        }

        return { ...ghost, position: newPosition };
      })
    }));
  }, []);

  const checkCollision = useCallback(() => {
    const pacman = gameState.pacman;
    gameState.ghosts.forEach(ghost => {
      const dx = ghost.position.x - pacman.position.x;
      const dy = ghost.position.y - pacman.position.y;
      const distance = Math.sqrt(dx * dx + dy * dy);
      
      if (distance < 20) {
        setExplosionPosition(pacman.position);
        setShowExplosion(true);
        setTimeout(() => setShowExplosion(false), 500);
        setGameState(prev => ({ ...prev, gameOver: true }));
      }
    });
  }, [gameState]);

  useEffect(() => {
    const handleKeyPress = (e: KeyboardEvent) => {
      if (gameState.gameOver) return;

      setGameState(prev => {
        const newPosition = { ...prev.pacman.position };
        let newDirection = prev.pacman.direction;
        const step = CELL_SIZE;

        // 计算新位置但不立即应用
        let testPosition = { ...newPosition };
        switch (e.key) {
          case 'ArrowRight':
            testPosition.x = Math.min(newPosition.x + step, (GRID_COLS - 1) * CELL_SIZE);
            newDirection = 'right';
            break;
          case 'ArrowLeft':
            testPosition.x = Math.max(newPosition.x - step, 0);
            newDirection = 'left';
            break;
          case 'ArrowUp':
            testPosition.y = Math.max(newPosition.y - step, 0);
            newDirection = 'up';
            break;
          case 'ArrowDown':
            testPosition.y = Math.min(newPosition.y + step, (GRID_ROWS - 1) * CELL_SIZE);
            newDirection = 'down';
            break;
        }

        // 检查新位置是否与障碍物碰撞
        if (checkObstacleCollision(testPosition, prev.obstacles)) {
          testPosition = newPosition;
        }

        // 更新位置后立即检查豆子收集情况
        let updatedDots = [...prev.dots];
        let scoreIncrement = 0;

        updatedDots = updatedDots.map(dot => {
          if (!dot.eaten && 
              dot.position.x === testPosition.x &&
              dot.position.y === testPosition.y) {
            scoreIncrement += 10;
            return { ...dot, eaten: true };
          }
          return dot;
        });

        return {
          ...prev,
          pacman: {
            ...prev.pacman,
            position: testPosition,
            direction: newDirection,
            mouthOpen: !prev.pacman.mouthOpen
          },
          dots: updatedDots,
          score: prev.score + scoreIncrement
        };
      });
    };

    window.addEventListener('keydown', handleKeyPress);
    return () => window.removeEventListener('keydown', handleKeyPress);
  }, [gameState.gameOver]);

  useEffect(() => {
    if (gameState.gameOver) return;

    const gameLoop = setInterval(() => {
      moveGhosts();
      checkCollision();
    }, GAME_SPEED);

    return () => clearInterval(gameLoop);
  }, [moveGhosts, checkCollision, gameState.gameOver]);

  return (
    <div className="relative">
      <div className="absolute top-0 left-0 text-white text-xl font-bold m-2 flex items-center gap-4">
        <span>Score: {gameState.score}</span>
        {gameState.gameOver && (
          <span className="text-red-400">
            Game Over! Final Score: {gameState.score}
          </span>
        )}
      </div>
      <GameCanvas gameState={gameState} />
      {showExplosion && <ExplosionEffect position={explosionPosition} />}
    </div>
  );
};

export default PacmanGame;

App.tsx(应用入口)

import React from 'react';
import { PacmanGame } from './index';

const App: React.FC = () => {
  return (
    <div className="flex items-center justify-center min-h-screen bg-gradient-to-r from-blue-500 to-purple-600">
      <PacmanGame />
    </div>
  );
};

export default App;

GameCanvas.tsx(游戏画布背景组件,基于 Canvas)

import React, { useRef, useEffect } from 'react';
import { Position, GameState } from '../types';

const CELL_SIZE = 40;
const PACMAN_SIZE = 30;
const GHOST_SIZE = 30;
const GRID_COLS = 10;
const GRID_ROWS = 10;

const drawDot = (ctx: CanvasRenderingContext2D, position: Position) => {
  ctx.beginPath();
  ctx.arc(
    position.x + CELL_SIZE / 2,
    position.y + CELL_SIZE / 2,
    4,
    0,
    Math.PI * 2
  );
  ctx.fillStyle = '#FFFFFF';
  ctx.fill();
  ctx.closePath();
};

const drawObstacle = (ctx: CanvasRenderingContext2D, position: Position) => {
  ctx.fillStyle = '#4A5568';
  ctx.fillRect(position.x, position.y, CELL_SIZE, CELL_SIZE);
  
  // 添加立体感
  ctx.strokeStyle = '#2D3748';
  ctx.lineWidth = 2;
  ctx.strokeRect(position.x, position.y, CELL_SIZE, CELL_SIZE);
};

const drawPacman = (ctx: CanvasRenderingContext2D, position: Position, direction: string, mouthOpen: boolean) => {
  ctx.save();
  ctx.translate(position.x + CELL_SIZE / 2, position.y + CELL_SIZE / 2);
    
  let rotation = 0;
  switch(direction) {
    case 'right': rotation = 0; break;
    case 'down': rotation = Math.PI / 2; break;
    case 'left': rotation = Math.PI; break;
    case 'up': rotation = -Math.PI / 2; break;
  }
    
  ctx.rotate(rotation);
    
  ctx.beginPath(); // 添加这行,开始一个新的路径
  const mouthAngle = mouthOpen ? Math.PI / 4 : 0;
  ctx.arc(0, 0, PACMAN_SIZE / 2, mouthAngle, 2 * Math.PI - mouthAngle);
  ctx.lineTo(0, 0);
  ctx.closePath();
  ctx.fillStyle = '#FFFF00';
  ctx.fill();
    
  ctx.restore();
};

const drawGhost = (ctx: CanvasRenderingContext2D, ghost: Position, color: string) => {
  // 绘制身体
  ctx.beginPath();
  ctx.fillStyle = color;
  
  // 身体上半部分(圆形)
  ctx.arc(
    ghost.x + CELL_SIZE / 2,
    ghost.y + CELL_SIZE / 2 - 2,
    GHOST_SIZE / 2,
    Math.PI,
    0,
    false
  );
  
  // 身体下半部分(波浪形)
  ctx.lineTo(ghost.x + CELL_SIZE, ghost.y + CELL_SIZE);
  for (let i = 0; i < 3; i++) {
    const curve = 5;
    const spacing = CELL_SIZE / 3;
    ctx.quadraticCurveTo(
      ghost.x + CELL_SIZE - spacing * i - spacing / 2,
      ghost.y + CELL_SIZE + curve,
      ghost.x + CELL_SIZE - spacing * (i + 1),
      ghost.y + CELL_SIZE
    );
  }
  ctx.closePath();
  ctx.fill();

  // 绘制眼睛
  const eyeOffset = 8;
  const pupilOffset = 2;
  
  // 白色眼球
  ctx.fillStyle = 'white';
  ctx.beginPath();
  ctx.arc(ghost.x + CELL_SIZE / 2 - eyeOffset, ghost.y + CELL_SIZE / 2 - 2, 5, 0, Math.PI * 2);
  ctx.arc(ghost.x + CELL_SIZE / 2 + eyeOffset, ghost.y + CELL_SIZE / 2 - 2, 5, 0, Math.PI * 2);
  ctx.fill();
  
  // 黑色瞳孔
  ctx.fillStyle = 'black';
  ctx.beginPath();
  ctx.arc(ghost.x + CELL_SIZE / 2 - eyeOffset + pupilOffset, ghost.y + CELL_SIZE / 2 - 2, 2, 0, Math.PI * 2);
  ctx.arc(ghost.x + CELL_SIZE / 2 + eyeOffset + pupilOffset, ghost.y + CELL_SIZE / 2 - 2, 2, 0, Math.PI * 2);
  ctx.fill();
};

export const GameCanvas: React.FC<{ gameState: GameState }> = ({ gameState }) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const drawGrid = (ctx: CanvasRenderingContext2D) => {
    ctx.strokeStyle = '#1a365d';
    ctx.lineWidth = 1;

    for (let x = 0; x <= GRID_COLS; x++) {
      ctx.beginPath();
      ctx.moveTo(x * CELL_SIZE, 0);
      ctx.lineTo(x * CELL_SIZE, GRID_ROWS * CELL_SIZE);
      ctx.stroke();
    }

    for (let y = 0; y <= GRID_ROWS; y++) {
      ctx.beginPath();
      ctx.moveTo(0, y * CELL_SIZE);
      ctx.lineTo(GRID_COLS * CELL_SIZE, y * CELL_SIZE);
      ctx.stroke();
    }
  };

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    drawGrid(ctx);

    // 绘制障碍物
    gameState.obstacles?.forEach(obstacle => {
      drawObstacle(ctx, obstacle.position);
    });

    gameState.dots.forEach(dot => {
      if (!dot.eaten) {
        drawDot(ctx, dot.position);
      }
    });

    drawPacman(
      ctx,
      gameState.pacman.position,
      gameState.pacman.direction,
      gameState.pacman.mouthOpen
    );

    gameState.ghosts.forEach(ghost => {
      drawGhost(ctx, ghost.position, ghost.color);
    });
  }, [gameState]);

  return (
    <canvas
      ref={canvasRef}
      width={CELL_SIZE * GRID_COLS}
      height={CELL_SIZE * GRID_ROWS}
      className="border-4 border-blue-300 rounded-lg shadow-lg bg-black"
    />
  );
};

ExplosionEffect.tsx(爆炸效果组件)

import React from 'react';
import { motion } from 'framer-motion';

interface ExplosionEffectProps {
  position: { x: number; y: number };
}

export const ExplosionEffect: React.FC<ExplosionEffectProps> = ({ position }) => {
  return (
    <motion.div
      className="absolute pointer-events-none"
      style={
   {
    left: position.x, top: position.y }}
      initial={
   {
    scale: 0, opacity: 1 }}
      animate={
   {
   
        scale: [1, 2],
        opacity: [1, 0],
      }}
      transition={
   {
    duration: 0.5 }}
    >
      <div className="w-8 h-8 bg-yellow-400 rounded-full" />
    </motion.div>
  );
};

types.ts(接口定义文件)

export interface Position {
  x: number;
  y: number;
}

export interface Ghost {
  position: Position;
  color: string;
  direction: 'right' | 'left' | 'up' | 'down';
}

export interface Dot {
  position: Position;
  eaten: boolean;
}

export interface Obstacle {
  position: Position;
}

export interface GameState {
  pacman: {
    position: Position;
    direction: 'right' | 'left' | 'up' | 'down';
    mouthOpen: boolean;
  };
  ghosts: Ghost[];
  dots: Dot[];
  obstacles: Obstacle[];
  gameOver: boolean;
  score: number;
}

源码下载

您可以通过以下链接访问和下载完整的代码: 源码压缩包链接

代码分析

以下是对代码实现的详细解析,帮助理解吃豆人游戏的核心逻辑和实现方式。

1. 程序初始化与显示设置

在 React 组件中,我们使用 useEffect 钩子来初始化程序,并设置游戏的初始状态。同时,我们通过 initializeDots 和 initializeObstacles 函数初始化游戏中的豆子和障碍物。

const initializeDots = (): Dot[] => {
  const dots: Dot[] = [];
  for (let x = 0; x < GRID_COLS; x++) {
    for (let y = 0; y < GRID_ROWS; y++) {
      dots.push({
        position: { x: x * CELL_SIZE, y: y * CELL_SIZE },
        eaten: false
      });
    }
  }
  return dots;
};

const initializeObstacles = (): Obstacle[] => {
  const obstacles: Obstacle[] = [];
  const numObstacles = 8; // 障碍物数量
  
  for (let i = 0; i < numObstacles; i++) {
    let position;
    do {
      position = {
        x: Math.floor(Math.random() * GRID_COLS) * CELL_SIZE,
        y: Math.floor(Math.random() * GRID_ROWS) * CELL_SIZE
      };
    } while (
      // 确保障碍物不会出现在起始位置和其他障碍物位置
      (position.x === 0 && position.y === 0) ||
      obstacles.some(obs => 
        obs.position.x === position.x && 
        obs.position.y === position.y
      )
    );
    
    obstacles.push({ position });
  }
  return obstacles;
};

2. 游戏画布的绘制

游戏画布的绘制逻辑在 GameCanvas 组件中实现。通过 Canvas 的 2D 绘图 API,我们绘制了游戏的网格、豆子、障碍物、吃豆人和幽灵。

const drawDot = (ctx: CanvasRenderingContext2D, position: Position) => {
  ctx.beginPath();
  ctx.arc(
    position.x + CELL_SIZE / 2,
    position.y + CELL_SIZE / 2,
    4,
    0,
    Math.PI * 2
  );
  ctx.fillStyle = '#FFFFFF';
  ctx.fill();
  ctx.closePath();
};

3. 吃豆人和幽灵的移动逻辑

吃豆人的移动逻辑通过键盘事件处理函数实现。玩家按下方向键时,吃豆人的位置会根据方向更新。同时,我们还检查了吃豆人是否与障碍物碰撞。

const handleKeyPress = (e: KeyboardEvent) => {
  if (gameState.gameOver) return;

  setGameState(prev => {
    const newPosition = { ...prev.pacman.position };
    let newDirection = prev.pacman.direction;
    const step = CELL_SIZE;

    // 计算新位置但不立即应用
    let testPosition = { ...newPosition };
    switch (e.key) {
      case 'ArrowRight':
        testPosition.x = Math.min(newPosition.x + step, (GRID_COLS - 1) * CELL_SIZE);
        newDirection = 'right';
        break;
      case 'ArrowLeft':
        testPosition.x = Math.max(newPosition.x - step, 0);
        newDirection = 'left';
        break;
      case 'ArrowUp':
        testPosition.y = Math.max(newPosition.y - step, 0);
        newDirection = 'up';
        break;
      case 'ArrowDown':
        testPosition.y = Math.min(newPosition.y + step, (GRID_ROWS - 1) * CELL_SIZE);
        newDirection = 'down';
        break;
    }

    // 检查新位置是否与障碍物碰撞
    if (checkObstacleCollision(testPosition, prev.obstacles)) {
      testPosition = newPosition;
    }

    // 更新位置后立即检查豆子收集情况
    let updatedDots = [...prev.dots];
    let scoreIncrement = 0;

    updatedDots = updatedDots.map(dot => {
      if (!dot.eaten && 
          dot.position.x === testPosition.x &&
          dot.position.y === testPosition.y) {
        scoreIncrement += 10;
        return { ...dot, eaten: true };
      }
      return dot;
    });

    return {
      ...prev,
      pacman: {
        ...prev.pacman,
        position: testPosition,
        direction: newDirection,
        mouthOpen: !prev.pacman.mouthOpen
      },
      dots: updatedDots,
      score: prev.score + scoreIncrement
    };
  });
};

幽灵的移动逻辑通过随机选择方向实现。每次移动时,幽灵会尝试向一个随机方向移动,如果该方向可行,则更新位置。

const moveGhosts = useCallback(() => {
  setGameState(prev => ({
    ...prev,
    ghosts: prev.ghosts.map(ghost => {
      const newPosition = { ...ghost.position };
      const step = CELL_SIZE;

      let attempts = 0;
      let validMove = false;
      
      while (!validMove && attempts < 4) {
        const directions: ('right' | 'left' | 'up' | 'down')[] = ['right', 'left', 'up', 'down'];
        ghost.direction = directions[Math.floor(Math.random() * 4)];
        
        const testPosition = { ...ghost.position };
        switch (ghost.direction) {
          case 'right':
            testPosition.x += step;
            break;
          case 'left':
            testPosition.x -= step;
            break;
          case 'down':
            testPosition.y += step;
            break;
          case 'up':
            testPosition.y -= step;
            break;
        }

        if (
          testPosition.x >= 0 && 
          testPosition.x < GRID_COLS * CELL_SIZE &&
          testPosition.y >= 0 && 
          testPosition.y < GRID_ROWS * CELL_SIZE &&
          !checkObstacleCollision(testPosition, prev.obstacles)
        ) {
          validMove = true;
          newPosition.x = testPosition.x;
          newPosition.y = testPosition.y;
        }
        
        attempts++;
      }

      return { ...ghost, position: newPosition };
    })
  }));
}, []);

4. 碰撞检测与游戏结束条件

游戏的核心逻辑之一是碰撞检测。我们需要检测吃豆人是否与幽灵碰撞,以及是否吃到豆子。如果吃豆人与幽灵碰撞,游戏结束。

const checkCollision = useCallback(() => {
  const pacman = gameState.pacman;
  gameState.ghosts.forEach(ghost => {
    const dx = ghost.position.x - pacman.position.x;
    const dy = ghost.position.y - pacman.position.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    
    if (distance < 20) {
      setExplosionPosition(pacman.position);
      setShowExplosion(true);
      setTimeout(() => setShowExplosion(false), 500);
      setGameState(prev => ({ ...prev, gameOver: true }));
    }
  });
}, [gameState]);

5. 爆炸效果的实现

爆炸效果通过 ExplosionEffect 组件实现。当吃豆人与幽灵碰撞时,会在碰撞位置显示一个爆炸动画。

export const ExplosionEffect: React.FC<ExplosionEffectProps> = ({ position }) => {
  return (
    <motion.div
      className="absolute pointer-events-none"
      style={
   {
    left: position.x, top: position.y }}
      initial={
   {
    scale: 0, opacity: 1 }}
      animate={
   {
   
        scale: [1, 2],
        opacity: [1, 0],
      }}
      transition={
   {
    duration: 0.5 }}
    >
      <div className="w-8 h-8 bg-yellow-400 rounded-full" />
    </motion.div>
  );
};

6. 总结

通过 React 和 TypeScript,我们实现了一个经典的吃豆人游戏。核心逻辑包括:

  1. 使用 Canvas 绘制游戏界面。

  2. 通过键盘事件控制吃豆人移动。

  3. 实现幽灵的随机移动逻辑。

  4. 检测碰撞并处理游戏结束条件。

  5. 添加爆炸效果增强视觉体验。

这种实现方式不仅展示了 TypeScript 的类型安全性和代码结构化优势,还通过 React 的组件化开发提升了代码的可维护性。


注意事项

  1. TypeScript版本兼容性问题:使用npm版本管理工具,。

  2. 浏览器兼容性问题:HTML5 Canvas 在现代浏览器中支持良好,但仍需注意兼容性问题。


写在后面

感谢您的阅读!如果你在实现过程中有任何问题或建议,欢迎随时反馈。开发这个吃豆人游戏的过程充满了乐趣,希望你也能享受其中。如果你喜欢这个项目,不妨分享到你的项目中!


版权声明

本文遵循 Creative Commons Attribution-ShareAlike 4.0 International License 协议。你可以自由使用和修改代码,但需注明原作者和出处。


参考资料

  • HTML5 Canvas 文档

  • TypeScript 官方文档

  • React 官方文档

一人工作室微信小游戏实战:Cocos Creator 3.8 + TypeScript 快速闭环开发 微信小游戏作为轻量级互动应用载体,其核心在于快速验证、低门槛发布与高效广告变现。理解小游戏引擎选型(如Cocos Creator与Unity的输入延迟、资源热更、跨端兼容差异)、TypeScript工程化实践(职责分离、接口驱动、类型即文档)及微信开发者工具真实避坑逻辑(真机路径、SourceMap体积、iOS/Android模拟器渲染分歧),是个人开发者突破‘能做’迈向‘稳赚’的关键。结合广告ARPU优化(场景化激励设计、设备指纹频控、时段ECPM动态调度)与7日冷启动作战地图,技术决策直指用户留存与现金 阅读详情

相关推荐

一人工作室如何用Cocos Creator+TS高效开发微信小游戏

微信小游戏是一种轻量级、高集成度的WebGL游戏交付形态,依托微信宿主环境实现免安装、即点即玩。其核心原理在于将渲染、内存管理、API适配等底层能力封装为wx对象,大幅降低开发复杂度。技术价值体现在极简交付单元(主包≤4MB)、内置分包机制与真机调试支持,使单人开发者可闭环完成逻辑编码、资源优化与上线发布。典型应用场景包括休闲弹球、益智解谜、轻量RPG等对性能敏感、迭代快速的小型游戏项目。本文聚焦Cocos Creator 3.x与TypeScript协同工作流,覆盖game.json配置规范、真机调试技巧

weixin_33843409的博客 308

Typescript小练习之贪吃蛇

用tyscript做一个贪吃蛇的小游戏

m0_70968573的博客 852

基于Egret框架的H5小游戏源码完整解析

H5小游戏是以 HTML5 技术为核心,运行在浏览器中的轻量级游戏形式,凭借其跨平台、无需下载安装、即点即玩的特性,迅速在移动端和社交平台中占据一席之地。随着移动互联网的发展,H5小游戏已成为小游戏生态中不可或缺的一部分。从应用场景来看,H5小游戏广泛应用于社交分享、广告推广、节日活动、品牌营销等多个领域。其开发周期短、部署灵活、传播便捷,使得中小团队和个人开发者也能快速进入市场。

weixin_35995661的博客 598

TypeScript学习问题及解决(附贪吃蛇源码)

typescript学习总结,之后应该边学边记TypeScript是JavaScript的超集。它对JS进行了扩展,向JS中引入了类型的概念,并添加了许多新的特性。TS代码需要通过编译器编译为JS,然后再交由JS解析器执行。TS完全兼容JS,换言之,任何的JS代码都可以直接当成JS使用。相较于JS而言,TS拥有了静态类型,更加严格的语法,更强大的功能;TS可以在- 代码执行前就完成代码的检查,减小了运行时异常的出现的几率;

yolo_______95的博客 600

微信小游戏宝可梦图鉴完整源码解析与实战

微信小游戏自诞生以来,凭借其无需下载安装、即点即玩的便捷性,以及微信平台的巨大用户群体,迅速成为开发者和投资者关注的焦点。本章节旨在为读者提供微信小游戏框架应用的入门级知识和背景信息,帮助读者理解微信小游戏的基本概念、技术特性和开发环境。微信小游戏是基于微信平台,运行在微信内部的网页游戏。它利用HTML5技术,结合微信的社交属性,让玩家能够在微信聊天窗口中直接玩游戏,分享游戏成绩到朋友圈或微信群。小游戏的开发框架主要是基于JavaScript语言,使用的是微信官方提供的小游戏开发文档和API。

weixin_28713083的博客 1108

Typescript写2048小游戏

Typescript写的2048小游戏,基于layaAir box 开发平台

来写个贪吃蛇小游戏吧(TypeScript版本)

这里的核心逻辑就是监控,看是上下左右中的哪一个,然后对应的改变蛇蛇的方向。其次,当蛇蛇碰到食物的时候,这个食物的位置会改变,可以设置一个。,所以我们需要定义出非常多的类来对这个贪吃蛇小游戏进行分析。首先,我们需要获取到蛇头的横纵坐标,还要能够给横纵坐标赋值。我们先来看看这个贪吃蛇小游戏有几个主要的部分。首先,我们需要获取其中的横纵坐标。当然还需要增加检测机制,舌头不能与身子重叠。其次,我们需要有方法增加蛇的身子。同时还需要增加身子移动的方式。其中蛇的移动需要不断的调用。这个函数,所以我们使用。

weixin_60789461的博客 1873

用TypeScript开发的一个游戏源码-Greg The Robot

展示一个使用TypeScript的HTML5风格的游戏,以及一Phaser游戏开发库。这个项目是对80年代街机游戏的致敬,更具体地说,是Konami在1986年制作的令人敬畏的游戏“骑士”。希望能吸引那些寻找一个基本游戏结构来构建的人。相信TypeScript能提高开发效率,开发过程不仅限于游戏项目,也适用于JavaScript开发。

使用TypeScript面向对象方法实现扑克牌小游戏

扑克牌小游戏

m0_67212141的博客 406

TypeScript - 坦克游戏源码

使用 TypeScript 编写的坦克大战游戏源码。

EndGate, TypeScript游戏框架.zip

EndGate, TypeScript游戏框架 EndGate[EndGate] ( http://endgate.net ) 是一个专门为使HTML5游戏开发可以维护和易于使用而创建的[TypeScript] ( http://www.typescriptlang.org/ )

webpack + typescript 开发微信小游戏实践

源码地址 微信小游戏版本技术选型使用typescript开发 但是微信小游戏原生不支持 typescript 开发,于是探索一下使用ts开发微信小游戏 1. 创建小游戏 使用测试号,创建一个使用官方示例的小游戏 会生成一个可以直接运行的打飞机小游戏 2. 准备工作 2.1 安装依赖 首先 npm init一下,生成package.json 在 package.json 写入如下 devDeoendencies, npm install一下 "devDependencies": { "@b

yanxiaomu的博客 5143

基于TypeScript的HTML5游戏开发.pdf

基于TypeScript的HTML5游戏开发.pdf。学习用,如有侵权请联系我删除

刚学会 TypeScript, 顺手做个贪吃蛇小游戏

最近在学习中,再次遇到了贪吃蛇的案例,之前刚学 JavaScript 的时候就有遇到过,趁着这段时间有一点点时间,就跟着做了一下,这篇文章将手把手带你实现一个贪吃蛇的小游戏,难度不会很大,嘻嘻 可以从这个案例中学到以下几点: 面向对象编程、this 指向问题、webpack 简单的配置、 一、实现效果预览 需要实现的功能有以下: 页面布局

小丞同学 2246

基于python的超市历年数据可视化分析

包含:Order_Date Sales Quantity Profit year month。所有这些物品都在美国4个地区的49个州销售,来着793位客户的5009个订单。9994行,21列数据。

m0_74872863的博客 537

解决nfs挂载根文件系统报错:VFS: Unable to mount root fs via NFS, trying floppy.

解决nfs挂载根文件系统报错的问题:VFS: Unable to mount root fs via NFS, trying floppy.

汉尼拔勇闯天涯 4576

Cocos微信小游戏开发教程

Cocos Creator是一个功能强大的游戏开发工具,特别适合用于开发微信小游戏。本文将从下载安装Cocos Creator、创建项目、开发调试到最终发布的全过程进行详细讲解,帮助开发者快速上手开发微信小游戏。

hawkol的专栏 3411

从理论到实践:用CST微波工作室完成单站雷达RCS仿真的完整指南

本文提供了一份利用CST微波工作室进行单站雷达RCS仿真的完整实践指南。文章深入剖析了从求解器选择(重点推荐积分方程法)、模型与材料设置、平面波激励定义到后处理分析的完整逻辑链条,旨在帮助工程师不仅完成仿真,更能理解其背后的物理原理与工程考量,从而高效、精准地评估目标的电磁散射特性。

weixin_29256771的博客 181

CocosCreator像素鸟小游戏实现(有源码)超详细教程 TS实现小游戏 零基础开发

CocosCreator像素鸟小游戏实现(有源码)超详细教程 TS实现小游戏 前言 老规矩先看效果 源码的获取方式在最下面 对于本游戏来说canvas这样设置最佳哦 游戏实现思路:小鸟的位置是全程不动的,只有管子和地面在动。需要用到物理引擎和碰撞系统,物理引擎来让小鸟下落,并在点击屏幕的时候让小鸟飞起来,碰撞引擎用来判断小鸟和障碍物碰撞,还需要存储和读取历史最高分等等 本文章的目录 前言 一、搭建场景 二、写代码 一、搭建场景 搭建基本场景 新建一个场景取名为Game并且打开 设置好canvas 把背

bcswkl_的博客 6828

在Ansys Mechanical中对磨损进行建模

在有限元分析中,磨损是通过移动接触表面上的节点来复制材料侵蚀来建模的。仿真迭代更新几何,并在每次更新后重新计算平衡,使过程成为非线性的。Ansys使用将压力、相对运动和材料阻力与表面损失联系起来的经验定律来处理连续介质尺度的磨损。要在Ansys Mechanical中激活磨损建模,必须使用TB,WEAR命令定义专用材料模型。该命令与 TBDATA 相结合,允许用户输入与磨损相关的材料常数,以控制磨损的计算方式以及在接触表面上的应用方式。

jkh920184196的博客 2631

ESP32-CAM实战:从零构建高精度QR二维码识别系统

本文详细介绍了如何使用ESP32-CAM构建高精度QR二维码识别系统,涵盖硬件配置、开发环境搭建、二维码识别库选择与优化、完整系统实现及实战问题排查。通过优化识别算法和电源管理,系统可实现快速、稳定的二维码识别,适用于物联网设备绑定等场景。

weixin_42524703的博客 433

cocos2d c++ 小游戏源码_微信抖音小游戏《黄金矿工》案例详解

序言微信小游戏,抖音小游戏,非常适合个人开发者创业,不用版号,门槛低,同时抖音小游戏的系统算法推荐,能让好的游戏脱颖而出, 你要做的就是把游戏做好就可以了。本篇系列的文章,配套了视频教程讲解与课程资源,课程源码。可以关注留言获取哦!下面开始讲解黄金矿工的具体制作流程。1:开发工具的基本使用教程的开发工具选用cocos creator, 开发语言选用TypeScript。首先我们创建项目,导入素材,...

weixin_39610488的博客 1778

Android音频学习(十四)——加载音频设备

loadHwModule 是 Android 音频系统的核心初始化函数,负责加载音频硬件抽象层(HAL)的模块实现。它建立 AudioPolicyService 与底层硬件的桥梁,将配置文件(audio_policy_config.xml)中的标签<module>定义转化为可操作的硬件对象,加载的是音频硬件模块(HAL实现),每个模块对应一种音频设备类型,以动态库的形式存在,负责与底层音频硬件交互。

miemie2014的博客 1732
上一篇: 基于SFML3.0的C++17新年烟花代码(附源码,蛇年大吉)
下一篇: C++新年烟花代码(附源码,基于SFML3.0、C++17实现)
Whoisbug
博客等级 码龄9年 894粉丝 57原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值