Y.K.'s Sanctuary
  • 主頁
  • 部落格
  • 永續
  • 鄉土
  • 小遊戲
  • 主頁
  • 部落格
  • 永續
  • 鄉土
  • 小遊戲

30 天架起一個前後端分離網站 - Day 4 - 前端遊戲開發

連載區索引

30 天架起一個前後端分離網站

第四~六天的目標是把遊戲的主要相關物件給刻出來,在這裡,我們將採用物件導向設計來開發這個遊戲。關於物件導向的詳細內容,建議參考這篇 Medium 文章:物件導向 vs 程序式導向-平庸與高級工程師開發速度差異的秘密。一言以蔽之物件導向開發模式,可以這麼說:想像一下樂高積木,樂高的每一小塊積木形狀、顏色,以及所要組建的部位都不同。這些小積木透過層層相疊,最後組成了成品。而這就是物件導向設計所追求的,透過將程式碼依照功能、屬性不停分割,得出一個又一個模組,就像樂高積木。再透過這些小小的模組,組成系統。這麼做的好處是每一個模組的功能明確,避免了程式碼雜亂,對未來的維護與擴展很有幫助。至於物件導向設計中的繼承、介面實作,與多型等面試最愛考的概念則暫且擱置,留待用別的技術文章細談,在本系列的後續實作中,我們只會簡單帶過。

暫時把整個遊戲的開發進程切分成基礎功能階段,與進階功能的階段。在基礎功能階段中(也就是這幾天的內容),需要先確保遊戲中包含:一個可以控制左右移動的實體、一個持續向下捲動的遊戲畫面、隨機分佈的階梯、人物血條,以及遊戲終止的方式。進階階段中,則會在原本的基礎上,再加入不穩定的階梯,以及有尖刺的階梯,這部分則等到我們將前後端接起來,完成了 MVP(minimum viable product)後,再回頭補上。

Day 4 ,我們將完成下列的模組:

  1. Configuration
  2. 父類別 Entity
  3. Player 實體
  4. Stair 實體
  5. 計時器

Configuration

frontend/down-game/src/components/down-stairs-game/config.js

export const GAME_CONFIG = {
  canvasWidth: 400,
  canvasHeight: 600,
  stairWidth: 100,
  stairHeight: 20,
  playerWidth: 30,
  playerHeight: 40,
  initialSpeed: 1,
  maxSpeed: 8,
  speedIncrement: 0.1,
  speedIncrementScore: 500,
  playerMoveStep: 10,
  gravity: 0.2,
  stairSpacing: 100, // 階梯間距
  initialHealth: 3, // 初始血量
  invincibleTime: 60, // 無敵時間(幀數)
  healthBarWidth: 100,
  healthBarHeight: 15,
  healthBarPadding: 5,
  minStairCount: 8, // 畫面上的最小階梯數量
  stairDensity: 1.5, // 階梯密度係數(值越大,階梯越密集)
};

export const ScoreLevel = {
  LOW: 1000,
  MEDIUM: 2000,
  HIGH: 4000,
};

首先,讓我們在 down-stairs-game 資料夾底下創立一個 config.js 檔案,並且使用一個 GAME_CONFIG 物件來集中管理所需的遊戲參數。

Entity

frontend/down-game/src/components/down-stairs-game/entities/Entity.js

export default class Entity {
 x;
 y;
 width;
 height;

 constructor(x, y, width, height) {
   this.x = x;
   this.y = y;
   this.width = width;
   this.height = height;
 }

 setPosition(x, y) {
   this.x = x;
   this.y = y;
 }

 // 畫面刷新處理
 update(ctx) {}

 // 渲染處理
 render(...args) {}
}

這個 Entity 類別將是所有實體的父類別,接下來的 Player 與 Stair 都會繼承 Entity。繼承的子類別會擁有父類別中的所有公開屬性與方法,藉由類別繼承的設計,我們確立了物件與物件之間的垂直關係,同時也集中管理擁有特定功能的程式碼,讓它得以有效地被重複使用。在 Entity 中,我們定義了 update 與 render 兩個方法,這兩個方法幾乎在各種遊戲引擎的實作中都可以看見。update 負責更新每一幀要處理的內容,而 render 則負責將要視覺化的內容渲染出來。在整個遊戲模組中,每一個實體都會包含這兩個方法。

Player

frontend/down-game/src/components/down-stairs-game/entities/Player.js

import { GAME_CONFIG } from "../config";
import Timer from "../utils/timer";
import Entity from "./Entity";

let FLIPPING_PARAM = 5;

export default class Player extends Entity {
  velocity = 0;

  health;
  maxHealth;

  invincible = false;
  isSad = false;

  invincibleTimer;
  blinkTimer;
  sadTimer;

  isVisible = true;

  constructor(x, y) {
    super(x, y, GAME_CONFIG.playerWidth, GAME_CONFIG.playerHeight);
    this.health = GAME_CONFIG.initialHealth;
    this.maxHealth = GAME_CONFIG.initialHealth;

    this.invincibleTimer = new Timer(GAME_CONFIG.invincibleTime, () => {
      this.invincible = false;
    });

    this.blinkTimer = new Timer(FLIPPING_PARAM, () => {
      this.invincible = !this.invincible;
      this.blinkTimer.reset();
    });

    this.sadTimer = new Timer(GAME_CONFIG.invincibleTime, () => {
      this.isSad = false;
    });
  }

  updateTimers() {
    // 更新無敵計時器
    if (this.invincible) {
      this.invincibleTimer.update();
      this.blinkTimer.update();
    }

    if (this.isSad) {
      this.sadTimer.update();
    }
  }

  handleMovement(keysPressed) {
    if (keysPressed.ArrowLeft) {
      this.x = Math.max(0, this.x - GAME_CONFIG.playerMoveStep);
    }

    if (keysPressed.ArrowRight) {
      this.x = Math.min(
        GAME_CONFIG.canvasWidth - this.width,
        this.x + GAME_CONFIG.playerMoveStep
      );
    }
  }

  checkIfOnStair(stairs) {
    for (const stair of stairs) {
      if (!stair.isSolid) {
        continue;
      }

      if (this.isStandingOnStair(stair)) {
        this.velocity = 0;
        this.y = stair.y - this.height;
        return true;
      }
    }

    return false;
  }

  isStandingOnStair(stair) {
    return (
      this.y + this.height >= stair.y - 2 &&
      this.y + this.height <= stair.y + stair.height + 2 &&
      this.x + this.width > stair.x &&
      this.x < stair.x + stair.width
    );
  }

  applyGravity(stairs) {
    const prevY = this.y;
    this.velocity -= GAME_CONFIG.gravity;
    this.y -= this.velocity;

    if (this.velocity < 0) {
      this.checkForMidairCollisions(stairs, prevY);
    }
  }

  checkForMidairCollisions(stairs, prevY) {
    for (const stair of stairs) {
      if (
        prevY + this.height <= stair.y && // 之前在階梯上方
        this.y + this.height >= stair.y && // 現在穿過階梯
        this.isStandingOnStair(stair)
      ) {
        this.y = stair.y - this.height;
        this.velocity = 0;
        break;
      }
    }
  }

  isStandingOnStair(stair) {
    return (
      this.y + this.height >= stair.y - 2 &&
      this.y + this.height <= stair.y + stair.height + 2 &&
      this.x + this.width > stair.x &&
      this.x < stair.x + stair.width
    );
  }

  takeDamage() {
    if (this.health <= 0 || this.invincible) return;

    this.health--;
    this.invincible = true;
    this.invincibleTimer.reset();
    this.blinkTimer.reset();
    this.isSad = true;
    this.sadTimer.reset();
  }

  // 設置初始無敵狀態
  setInitialInvincibility() {
    this.invincible = true;
    this.invincibleTimer.reset();
    this.blinkTimer.reset();
  }

  renderHealthBar(ctx) {
    const barWidth = GAME_CONFIG.healthBarWidth;
    const barHeight = GAME_CONFIG.healthBarHeight;
    const padding = GAME_CONFIG.healthBarPadding;

    // 外框
    ctx.strokeStyle = "#000000";
    ctx.lineWidth = 2;
    ctx.strokeRect(10, 40, barWidth, barHeight);

    // 背景
    ctx.fillStyle = "#DDDDDD";
    ctx.fillRect(10, 40, barWidth, barHeight);

    // 血量
    const healthWidth =
      (this.health / this.maxHealth) * (barWidth - padding * 2);
    ctx.fillStyle = "#FF0000";
    ctx.fillRect(
      10 + padding,
      40 + padding,
      healthWidth,
      barHeight - padding * 2
    );

    // 心形圖示
    ctx.fillStyle = "#FF0000";
    ctx.font = "16px Arial";
    ctx.fillText("♥ " + this.health, barWidth + 20, 40 + barHeight - 2);
  }

  renderHealthBar(ctx) {
    const barWidth = GAME_CONFIG.healthBarWidth;
    const barHeight = GAME_CONFIG.healthBarHeight;
    const padding = GAME_CONFIG.healthBarPadding;

    // 外框
    ctx.strokeStyle = "#000000";
    ctx.lineWidth = 2;
    ctx.strokeRect(10, 40, barWidth, barHeight);

    // 背景
    ctx.fillStyle = "#DDDDDD";
    ctx.fillRect(10, 40, barWidth, barHeight);

    // 血量
    const healthWidth =
      (this.health / this.maxHealth) * (barWidth - padding * 2);
    ctx.fillStyle = "#FF0000";
    ctx.fillRect(
      10 + padding,
      40 + padding,
      healthWidth,
      barHeight - padding * 2
    );

    // 心形圖示
    ctx.fillStyle = "#FF0000";
    ctx.font = "16px Arial";
    ctx.fillText("♥ " + this.health, barWidth + 20, 40 + barHeight - 2);
  }

  renderFace(ctx) {
    // 眼睛
    ctx.fillStyle = "#000000";
    ctx.fillRect(
      this.x + this.width * 0.2,
      this.y + this.height * 0.2,
      this.width * 0.15,
      this.height * 0.15
    );
    ctx.fillRect(
      this.x + this.width * 0.65,
      this.y + this.height * 0.2,
      this.width * 0.15,
      this.height * 0.15
    );

    // 嘴巴
    ctx.beginPath();
    if (this.isSad) {
      // 哭臉
      ctx.arc(
        this.x + this.width / 2,
        this.y + this.height * 0.7,
        this.width * 0.2,
        0,
        Math.PI,
        true // 嘴角向下
      );

      // 眼淚
      ctx.moveTo(this.x + this.width * 0.25, this.y + this.height * 0.35);
      ctx.lineTo(this.x + this.width * 0.25, this.y + this.height * 0.5);
      ctx.moveTo(this.x + this.width * 0.75, this.y + this.height * 0.35);
      ctx.lineTo(this.x + this.width * 0.75, this.y + this.height * 0.5);
    } else {
      // 笑臉
      ctx.arc(
        this.x + this.width / 2,
        this.y + this.height * 0.6,
        this.width * 0.2,
        0,
        Math.PI,
        false // 嘴角向上
      );
    }
    ctx.stroke();
  }

  update(keysPressed, stairs) {
    this.updateTimers();
    this.handleMovement(keysPressed);
    const onStair = this.checkIfOnStair(stairs);
    if (!onStair) {
      this.applyGravity(stairs);
    }
  }

  render(ctx) {
    if (this.invincible && !this.isVisible) {
      return;
    }

    ctx.fillStyle = "#FF6347"; // 紅色
    ctx.fillRect(this.x, this.y, this.width, this.height);

    this.renderFace(ctx);
  }
}

Player 模組比較複雜,因為我們要在這個地方考量到整個遊戲會涵蓋到的各種行為。我們處理了人物移動、重力下墜、受傷,以及是否站在階梯上等問題。最後,一樣透過 update 和 render,在每一幀當中做動態更新。

Stair

frontend/down-game/src/components/down-stairs-game/entities/Stair.js

import { GAME_CONFIG } from "../config";
import Entity from "./Entity";

export default class Stair extends Entity {
  id;

  constructor(id, x, y, width) {
    super(x, y, width || GAME_CONFIG.stairWidth, GAME_CONFIG.stairHeight);
    this.id = id;
  }

  update(gameSpeed) {
    this.y -= gameSpeed;
  }

  render(ctx) {
    ctx.fillStyle = "#8B4513"; // 棕色
    ctx.fillRect(this.x, this.y, this.width, this.height);
  }
}

Stair 模組單純很多,因為我們只需要定義階梯怎麼被初始化和渲染,並且留意在這個遊戲當中,為了要呈現畫面往上捲動的效果,階梯在每一幀 update 中都會往上升一點。

Timer

frontend/down-game/src/components/down-stairs-game/utils/Timer.js

export default class Timer {
  active = false;
  counter = 0;
  maxCount;
  callback = undefined;

  constructor(maxCount, callback = undefined) {
    this.maxCount = maxCount;
    if (callback) this.callback = callback;
  }

  reset() {
    this.counter = 0;
    this.active = true;
  }

  stop() {
    this.active = false;
  }

  update() {
    if (!this.active) {
      return false;
    }

    this.counter++;
    if (this.counter >= this.maxCount) {
      this.active = false;
      if (this.callback) {
        this.callback;
      }
      return true;
    }
    return false;
  }
}

再來,我們新增一個 utils 資料夾在 down-stairs-game 底下,並且創立一個 Timer.js 類別。這個物件應該早於 Player 之前被創建,因為我是先寫完程式再寫文章的,才會有這樣問題。Timer 被設計用來集中管理 Player 當中很多的狀態改變,如果我們讓計時器散落在 Player 當中的各個方法,很容易導致程式碼變成一坨義大利麵,有違物件導向設計的精神。將 Timer 獨立出來的好處顯而易見,我們可以很簡單的在 Player 當中使用 new Timer() 方法,並且定義是否需要利用 callback 做後續處理,就可以輕鬆設計出各種有時間限制的狀態改變功能了。

收尾

Day 4 內容就到這邊吧!Day 5 我們將再接再厲,把更上層的應用模組一一建立起來!

Thank you for reading. If you enjoyed this post, consider sharing it with others.