使用 Deno 构建恐龙跑酷游戏,第3部分

本系列带你使用 Deno 构建一个简单的浏览器恐龙跑酷游戏,依次介绍:基本项目设置;游戏循环、画布与控制;障碍物和碰撞检测;数据库与全球排行榜;玩家资料、自定义和实时调节;可观测性、指标和告警。

障碍物和碰撞检测

第2阶段已经设置基本游戏循环和渲染,但还算不上真正的游戏,因为没有可“玩”的内容。第3阶段将加入恐龙需要跳过的障碍物,实现碰撞检测,判断恐龙是否撞上障碍物并结束游戏。

完成本阶段后,你将学会:

  • 创建和管理游戏中的障碍物。
  • 实现恐龙与障碍物的碰撞检测。
  • 根据碰撞更新游戏状态。

设置游戏状态

先向游戏状态加入管理障碍物和计分的新属性。打开 game.js,找到 DinoGame 类的构造函数,加入初始游戏速度、用于恐龙动画的帧计数和最高分追踪:

this.initialGameSpeed = 3;
this.frameCount = 0;
this.highScore = this.loadHighScore();

搭建游戏方法

游戏已有 startGame() 和 resetGame()。接下来加入处理障碍物生成、更新、碰撞检测和计分的方法。

先在现有方法下面添加空方法,稍后填充:

gameOver() {}

loadHighScore() {}

saveHighScore() {}

updateHighScore() {}

drawGameOver() {}

障碍物系统

从游戏设计角度看,障碍物通常是包含位置、大小和类型等属性的对象,其包围盒可用于碰撞检测。

我们实现一个简单系统:生成障碍物,让它们朝恐龙移动。玩家必须跳过它们以避免碰撞。成功避开障碍物时,得分增加,游戏速度也逐步提高。

在 game.js 构造函数中加入以下属性,可以放在 this.init(); 之前:

// Obstacle properties
this.obstacles = [];
this.obstacleSpawnTimer = 0;
this.obstacleSpawnRate = 120;
this.minObstacleSpawnRate = 60;

这些属性分别建立存放活动障碍物的数组、跟踪下次生成时机的计时器,以及控制出现频率的生成间隔。minObstacleSpawnRate 保证提速后障碍物不会出现过于频繁。

在 startGame() 中将障碍物计时器置零,每轮重新开始:

this.obstacleSpawnTimer = 0;

生成和更新障碍物

障碍物是从右边缘进入、按当前游戏速度向左移动的矩形。在类中加入以下方法,可以放在 jump() 之后:

spawnObstacle() {
    const obstacleTypes = [
        { width: 20, height: 40, type: "cactus-small" },
        { width: 25, height: 50, type: "cactus-medium" },
        { width: 30, height: 35, type: "cactus-wide" },
    ];

    const obstacle =
        obstacleTypes[Math.floor(Math.random() * obstacleTypes.length)];

    this.obstacles.push({
        x: this.canvas.width,
        y: this.groundY - obstacle.height,
        width: obstacle.width,
        height: obstacle.height,
        type: obstacle.type,
    });
}

updateObstacles() {
    if (this.gameState !== "playing") return;

    this.obstacleSpawnTimer++;
    if (this.obstacleSpawnTimer >= this.obstacleSpawnRate) {
        this.spawnObstacle();
        this.obstacleSpawnTimer = 0;
    }

    for (let i = this.obstacles.length - 1; i >= 0; i--) {
        this.obstacles[i].x -= this.gameSpeed;

        if (this.obstacles[i].x + this.obstacles[i].width < 0) {
            this.obstacles.splice(i, 1);
            this.score += 10;
        }
    }
}

这里设置了几种不同尺寸的障碍物类型。spawnObstacle() 随机选择一种,在画布右边缘加入数组。

updateObstacles() 递增计时器,到达生成间隔时创建障碍物;同时让每个障碍物按当前速度向左移动,并移除离开屏幕的障碍物,为每个成功避开的障碍物加分。

碰撞检测和难度提升

碰撞检测用来判断两个对象是否接触。比较恐龙和每个障碍物的矩形包围盒,发生重叠时结束本轮。在 updateObstacles 之后加入:

checkCollisions() {
    if (this.gameState !== "playing") return;

    for (let obstacle of this.obstacles) {
        const isOverlapping =
            this.dino.x < obstacle.x + obstacle.width &&
            this.dino.x + this.dino.width > obstacle.x &&
            this.dino.y < obstacle.y + obstacle.height &&
            this.dino.y + this.dino.height > obstacle.y;

        if (isOverlapping) {
            this.gameOver();
            return;
        }
    }
}

updateGameDifficulty() {
    if (this.gameState !== "playing") return;

    const difficultyLevel = Math.floor(this.score / 200);
    this.gameSpeed = this.initialGameSpeed + difficultyLevel * 0.5;
    this.obstacleSpawnRate = Math.max(
        this.minObstacleSpawnRate,
        120 - difficultyLevel * 10,
    );
}

difficultyLevel 大约每200分提高一级,同时增加速度和生成频率,让玩家存活越久,难度越高。

绘制障碍物

为了避免仙人掌过于单调,每种类型具有略有区别的轮廓。在类中加入以下方法,可以放在 drawDino() 之后:

drawObstacles() {
    this.ctx.fillStyle = "olive";

    for (let obstacle of this.obstacles) {
        this.ctx.fillRect(
            obstacle.x,
            obstacle.y,
            obstacle.width,
            obstacle.height,
        );
        this.ctx.fillStyle = "darkolivegreen";
        if (obstacle.type === "cactus-small") {
            this.ctx.fillRect(obstacle.x - 3, obstacle.y + 10, 6, 4);
            this.ctx.fillRect(obstacle.x + obstacle.width - 3, obstacle.y + 20, 6, 4);
        } else if (obstacle.type === "cactus-medium") {
            this.ctx.fillRect(obstacle.x - 4, obstacle.y + 8, 8, 6);
            this.ctx.fillRect(obstacle.x + obstacle.width - 4, obstacle.y + 15, 8, 6);
            this.ctx.fillRect(obstacle.x + obstacle.width / 2 - 2, obstacle.y + 25, 4, 8);
        } else if (obstacle.type === "cactus-wide") {
            this.ctx.fillRect(obstacle.x - 5, obstacle.y + 5, 10, 8);
            this.ctx.fillRect(obstacle.x + obstacle.width - 5, obstacle.y + 10, 10, 8);
            this.ctx.fillRect(obstacle.x + obstacle.width / 2 - 3, obstacle.y + 20, 6, 6);
        }

        this.ctx.fillStyle = "olive";
    }
}

为恐龙添加动画

通过上下移动两条小腿,可以得到简单的奔跑动画。将 drawDino() 更新为:

drawDino() {
    const legOffset = this.gameState === "playing" && !this.dino.isJumping
        ? (Math.floor(this.frameCount / 10) % 2) * 2
        : 0;

    this.ctx.fillStyle = "green";
    this.ctx.fillRect(
        this.dino.x,
        this.dino.y,
        this.dino.width,
        this.dino.height,
    );

    this.ctx.fillStyle = "darkgreen";
    this.ctx.fillRect(this.dino.x + 25, this.dino.y + 8, 4, 4);
    this.ctx.fillRect(this.dino.x + 30, this.dino.y + 20, 8, 2);

    if (!this.dino.isJumping) {
        this.ctx.fillStyle = "green";
        this.ctx.fillRect(
            this.dino.x + 10,
            this.dino.y + 40 + legOffset,
            6,
            8 - legOffset,
        );
        this.ctx.fillRect(
            this.dino.x + 24,
            this.dino.y + 40 - legOffset,
            6,
            8 + legOffset,
        );
    }
}

渲染游戏组件

更新 render():先清空画布,再调用 drawObstacles() 和 drawDino(),并在适当状态叠加说明界面或游戏结束界面。

  render() {
    // Clear canvas
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // Draw obstacles
    this.drawObstacles();

    // Draw dino
    this.drawDino();

    // Draw instructions if waiting
    if (this.gameState === "waiting") {
      this.drawInstructions();
    }

    // Draw game over screen
    if (this.gameState === "gameOver") {
      this.drawGameOver();
    }
  }

接入物理更新、开始和重置

更新 startGame() 和 resetGame(),确保开始或重置时,障碍物系统正确初始化并清理。

游戏开始时重置障碍物数组,将速度和帧计数恢复为初始值:

startGame() {
  this.gameState = "playing";
  this.score = 0;
  this.gameSpeed = this.initialGameSpeed;
  this.obstacles = [];
  this.obstacleSpawnTimer = 0;
  this.frameCount = 0;
  this.updateScore();
  this.updateStatus("");
}

然后更新 resetGame(),清理残留障碍物,让重新开始即时生效:

resetGame() {
  this.gameState = "waiting";
  this.score = 0;
  this.gameSpeed = this.initialGameSpeed;
  this.obstacles = [];
  this.obstacleSpawnTimer = 0;
  this.frameCount = 0;
  this.dino.y = this.dino.groundY;
  this.dino.velocityY = 0;
  this.dino.isJumping = false;
  this.updateScore();
  this.updateStatus("Click to Start!");
  console.log("Game reset!");
}

最后在 updatePhysics() 中加入障碍物更新和碰撞检查:

updatePhysics() {
    if (this.gameState !== "playing") return;

    this.frameCount++;

    // Apply gravity
    this.dino.velocityY += this.gravity;
    this.dino.y += this.dino.velocityY;

    // Ground collision
    if (this.dino.y >= this.dino.groundY) {
      this.dino.y = this.dino.groundY;
      this.dino.velocityY = 0;
      this.dino.isJumping = false;
    }

    // Update score (continuous scoring)
    this.score += 0.1;
    this.updateScore();

    // Update obstacles
    this.updateObstacles();

    // Check collisions
    this.checkCollisions();

    // Update difficulty
    this.updateGameDifficulty();
  }

游戏结束

目前已有跳跃的恐龙和移动的障碍物,但碰撞后尚无处理。现在补齐。

检测到碰撞时,需要结束游戏并显示结束界面。更新空的 gameOver(),设置状态、保存最高分并记录消息:

gameOver() {
   this.gameState = "gameOver";
   this.saveHighScore();
   this.updateHighScore();
   this.updateStatus("Game Over! Click to restart");
   console.log(`Game Over! Final Score: ${Math.floor(this.score)}`);
 }

接着更新 drawGameOver(),在画布上叠加简单的结束提示:

drawGameOver() {
    // Semi-transparent overlay
    this.ctx.fillStyle = "rgba(0, 0, 0, 0.8)";
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    // Game Over text
    this.ctx.fillStyle = "white";
    this.ctx.font = "36px Arial";
    this.ctx.textAlign = "center";
    this.ctx.fillText(
      "GAME OVER",
      this.canvas.width / 2,
      this.canvas.height / 2 - 40,
    );

    // Final score
    this.ctx.font = "20px Arial";
    this.ctx.fillText(
      `Final Score: ${Math.floor(this.score)}`,
      this.canvas.width / 2,
      this.canvas.height / 2 - 5,
    );

    // High score
    if (Math.floor(this.score) === this.highScore && this.highScore > 0) {
      this.ctx.fillStyle = "gold";
      this.ctx.fillText(
        "🏆 NEW HIGH SCORE! 🏆",
        this.canvas.width / 2,
        this.canvas.height / 2 + 25,
      );
    } else if (this.highScore > 0) {
      this.ctx.fillStyle = "#CCCCCC";
      this.ctx.fillText(
        `High Score: ${this.highScore}`,
        this.canvas.width / 2,
        this.canvas.height / 2 + 25,
      );
    }

    // Restart instruction
    this.ctx.fillStyle = "#FFFFFF";
    this.ctx.font = "16px Arial";
    this.ctx.fillText(
      "Click or press SPACE to restart",
      this.canvas.width / 2,
      this.canvas.height / 2 + 55,
    );
  }

最高分

为了让玩家感受到最佳表现的奖励,用 localStorage 实现简单的最高分系统。创建加载、保存和更新最高分的方法,并将最高分追踪接入结束和重置逻辑。更新 DinoGame 中的空方法:

loadHighScore() {
    return parseInt(localStorage.getItem("dinoHighScore")) || 0;
}

saveHighScore() {
    if (Math.floor(this.score) > this.highScore) {
        this.highScore = Math.floor(this.score);
        localStorage.setItem("dinoHighScore", this.highScore);
        console.log(`New High Score: ${this.highScore}!`);
    }
}

updateHighScore() {
    if (this.highScoreElement) {
        this.highScoreElement.textContent = this.highScore;
    }
}

在 init() 中加入以下调用,在游戏初始化时更新最高分显示:

this.updateHighScore();

完成第3阶段

在类外保留第2阶段的健康检查,页面加载时确认 API 路由可用:

async function checkHealth() {
  try {
    const response = await fetch("/api/health");
    const data = await response.json();
    console.log("Server health check:", data);
  } catch (error) {
    console.error("Health check failed:", error);
  }
}

window.addEventListener("load", () => {
  checkHealth();
  new DinoGame();
  console.log(
    "Stage 3 complete: Full game with obstacles and collision detection!",
  );
});

运行后,游戏便有了障碍物和碰撞检测。原文此处展示运行效果;本译稿尚未复现该演示。

部署更新后的游戏

已有基本循环、恐龙跳跃和分数追踪,现在可以将更新部署到网页。在终端运行以下命令,更新第2阶段已部署的项目:

deno deploy

部署后,会得到一个恐龙可以跳过障碍物、碰撞时结束的完整游戏,也会获得可分享给其他玩家的 URL。

第4阶段将连接后端,在服务器保存分数,并为多人排行榜铺路。


原文:Build a dinosaur runner game with Deno, pt. 3
作者:Jo Franchetti,2025年12月22日。本文为中文译稿,依赖第2阶段已有代码;代码、命令及字符串保留原文。没有实际执行 deno deploy。Copyright © Deno Land Inc. 原文及示例权利归原权利人所有,转载依据用户明确授权;示例仓库许可证待发布前核验。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容