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

在 Day 5,我們的目標是讓 Day 4 設計的人物、階梯等可以在畫面上呈現,並且做到操控人物左右移動,讓整個遊戲動起來。Day 6,則讓我們一起修復 Bug(這可是軟體開發的日常喔!),設法使遊戲照著我們預期的樣子運行。那就開始吧,Let’s get our hands dirty!

回顧 Day 4,我們已經完成了階梯、人物的實體,接下來,就要進到更上層的 Business logic 的設計階段了。我們需要一套縝密的邏輯,讓這些實體照著我們想要的方式來互動。今天的內容,我們將會用到一點設計模式(Design Pattern)——工廠模式,有興趣了解的朋友可以參考這篇文章 深入淺出設計模式

程式分工

打造一個遊戲程式往往涉及複雜的邏輯設計,即使是一個規模很小的遊戲,都萬萬不可小覷。因為遊戲中牽涉到各種操作、數據運算,以及畫面更新等等作業。為了讓每一部分彼此協調運作,遊戲開發本身就是一個不小的挑戰。如果考量後續維護與擴展,那麼我們就更必須留意每一個程式模組負責的分工內容,想辦法讓後續接手的人(或者幾個月後的自己)不必太痛苦的就看懂程式。畢竟,就算是天縱英才,也難以看懂一坨義大利麵般的混亂程式碼的。

在這個小朋友下樓梯遊戲中,我們暫且先這樣切分每一個模組功能(這也許不是最好的,未來仍有調整空間,但是至少我們已經跨出了第一步):

  • StairGame.jsx: 整個遊戲的最外層,負責裝載遊戲模組、初始化,以及遊戲數據的最終渲染。由於這個模組牽涉到了最終的畫面渲染,所以我們使用 jsx 檔名來處理。
  • Game.js: Game 整個模組的管理中樞,遊戲當中所有的實體與數據,都會在這裡被初始化,並且隨著遊戲進度或者使用者的操作而被更新。
  • EntityFactory.js: EntityFactory 顧名思義,負責管理所有的實體,他負責創建人物、階梯等工作。
  • GameStateManager.js: GameStateManager 在整個程式當中,負責處理遊戲的狀態,包含:開始、暫停,終止;同時,這個模組當中還有一個 stats 的資料結構,這個 stats 當中儲存了當前分數、歷史高分、遊戲進行時間,以及遊戲速度等相關的數據資料。
  • GameRenderer.js: GameRenderer 負責所有渲染相關的作業,我們在 Day 4 的內容中提到在每一個實體中,實作 updaterender是很常見的作法。在這裡,我們則再使用一個 GameRenderer 負責畫面背景渲染、分數渲染、UI 渲染等工作,同時 GameRenderer 也會調用實體當中的 render 方法,這樣我們就可以讓所有的渲染工作,都交由 GameRenderer 來負責了!

程式碼

說完程式分工,就讓我們把程式碼給放上來吧!如果要細講每一個細節的話,可能得用上三天三夜,這裡就得請讀者自己搭配上述的分工介紹來參透了 =D。程式碼的部分有點繁瑣,不妨在 Github 上面直接 fork 下來。

StairGame.jsx

"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { GAME_CONFIG } from "./config";
import { GameState } from "./GameStateManager";
import Game from "./Game";

const StairGame = () => {
  const canvasRef = useRef(null);

  const gameRef = useRef(null);

  const [gameStatus, setGameStatus] = useState({
    score: 0,
    highestScore: localStorage.getItem("highScore"),
    health: GAME_CONFIG.initialHealth,
    maxHealth: GAME_CONFIG.initialHealth,
    gameState: GameState.NOT_STARTED,
    isInvincible: false,
    timeElapsed: 0,
  });

  const animationFrameIdRef = useRef(null);
  const keysPressed = useRef({});

  const handleKeyDown = (event) => {
    if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
      event.preventDefault();
      keysPressed.current[event.key] = true;
    } else if (event.key === " " || event.key === "Enter") {
      const game = gameRef.current;
      if (game && game.stateManager === GameState.NOT_STARTED) {
        startGame();
      } else if (game && game.stateManager.state === GameState.GAME_OVER) {
        startGame();
      }
    }
  };

  const handleKeyUp = (event) => {
    if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
      event.preventDefault();
      keysPressed.current[event.key] = false;
    }
  };

  useLayoutEffect(() => {
    if (window !== "undefined") {
      console.log("初始化畫布和事件處理器");
    }

    initGame();
    window.addEventListener("keydown", handleKeyDown);
    window.addEventListener("keyup", handleKeyUp);

    const game = gameRef.current;
    if (game) {
      game.stateManager.onStateChange(() => {
        updateGameStatus();
      });
    }

    return () => {
      window.removeEventListener("keydown", handleKeyDown);
      window.removeEventListener("keyup", handleKeyUp);

      if (animationFrameIdRef.current) {
        cancelAnimationFrame(animationFrameIdRef.current);
        animationFrameIdRef.current = null;
      }
    };
  }, []);

  useEffect(() => {
    console.log("遊戲狀態更新:", gameStatus);
  }, [gameStatus]);

  const getGameStateText = () => {
    switch (gameStatus.gameState) {
      case GameState.NOT_STARTED:
        return "準備開始";
      case GameState.PLAYING:
        return "遊戲中";
      case GameState.GAME_OVER:
        return "遊戲結束";
      default:
        return "";
    }
  };

  const formatTime = (seconds) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins}:${secs.toString().padStart(2, "0")}`;
  };

  const initGame = () => {
    console.log("遊戲初始化");

    if (canvasRef.current && !gameRef.current) {
      gameRef.current = new Game(canvasRef.current);
    }

    const game = gameRef.current;
    if (!game) return;

    game.init();

    if (animationFrameIdRef.current) {
      cancelAnimationFrame(animationFrameIdRef.current);
    }
    animationFrameIdRef.current = requestAnimationFrame(gameLoop);
  };

  const startGame = () => {
    const game = gameRef.current;
    if (!game) return;

    game.stateManager.startNewGame();
    updateGameStatus();
  };

  const updateGameStatus = () => {
    const game = gameRef.current;
    if (!game) return;

    setGameStatus({
      score: game.stateManager.stats.score,
      highestScore: game.stateManager.stats.highScore,
      health: game.player.health,
      maxHealth: game.player.maxHealth,
      gameState: game.stateManager.state,
      isInvincible: game.player.invincible,
      timeElapsed: game.stateManager.stats.timeElapsed,
    });
  };

  const gameLoop = () => {
    const game = gameRef.current;
    if (!game) return;

    game.update(keysPressed.current);
    game.render();

    if (game.stateManager.state === GameState.PLAYING) {
      if (game.stateManager.score % 50 === 0) {
        updateGameStatus();
      }
    }

    animationFrameIdRef.current = requestAnimationFrame(gameLoop);
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-screen p-5 bg-gray-100">
      <h1 className="text-2xl font-bold mb-4 text-black">小朋友下樓梯</h1>

      <div className="relative">
        <canvas
          ref={canvasRef}
          width={GAME_CONFIG.canvasWidth}
          height={GAME_CONFIG.canvasHeight}
          className="border-2 border-gray-800 rounded-lg shadow-lg"
        />
      </div>

      <div className="mt-4 space-y-2 w-full max-w-md">
        <div className="flex justify-between text-black items-center">
          <span className="font-bold">遊戲狀態:</span>
          <span className="text-lg">{getGameStateText()}</span>
        </div>

        <div className="flex justify-between text-black items-center">
          <span className="font-bold">分數:</span>
          <span className="text-lg">{gameStatus.score}</span>
        </div>

        <div className="flex justify-between text-black items-center">
          <span className="font-bold">最高分:</span>
          <span className="text-lg">{gameStatus.highestScore}</span>
        </div>

        <div className="flex justify-between text-black items-center">
          <span className="font-bold">時間:</span>
          <span className="text-lg">{formatTime(gameStatus.timeElapsed)}</span>
        </div>

        <div className="flex justify-between text-black items-center">
          <span className="font-bold">生命值:</span>
          <div className="flex">
            {[...Array(gameStatus.maxHealth)].map((_, i) => (
              <span
                key={i}
                className={`text-2xl ${
                  i < gameStatus.health ? "text-red-500" : "text-gray-300"
                } ${gameStatus.isInvincible ? "animate-pulse" : ""}`}
              >
                ♥
              </span>
            ))}
          </div>
        </div>

        <div className="flex space-x-2">
          {gameStatus.gameState === GameState.NOT_STARTED && (
            <button
              className="flex-1 px-6 py-2 bg-green-500 text-white rounded-md font-medium hover:bg-green-600 transition duration-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
              onClick={startGame}
            >
              開始遊戲
            </button>
          )}

          {gameStatus.gameState === GameState.GAME_OVER && (
            <button
              className="flex-1 px-6 py-2 bg-blue-500 text-white rounded-md font-medium hover:bg-blue-600 transition duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
              onClick={startGame}
            >
              重新開始
            </button>
          )}
        </div>
      </div>

      <div className="mt-4 bg-white text-black p-4 rounded-lg shadow w-full max-w-md">
        <h3 className="font-bold text-lg mb-2">遊戲說明</h3>
        <ul className="list-disc pl-5 space-y-1">
          <li>使用鍵盤左右箭頭鍵控制角色移動</li>
          <li>避免碰到頂部邊界,否則會扣血並被彈回</li>
          <li>避免掉落到底部,否則遊戲結束</li>
          <li>小心尖刺樓梯(紅色)會扣血</li>
          <li>不穩定樓梯(黃色)會在站立一段時間後消失</li>
          <li>每個角色有 {GAME_CONFIG.initialHealth} 條命</li>
        </ul>
      </div>
    </div>
  );
};

export default StairGame;

Game.js

import { GAME_CONFIG } from "./config";
import Player from "./entities/Player";
import EntityFactory from "./EntityFactory";
import GameRenderer from "./GameRender";
import GameStateManager, { GameState } from "./GameStateManager";

export default class Game {
  player;
  stairs;

  stateManager;
  entityFactory;
  renderer;

  canvas;
  context;

  constructor(canvas) {
    this.canvas = canvas;
    this.context = canvas.getContext("2d");

    // Init the services
    this.stateManager = new GameStateManager();
    this.entityFactory = new EntityFactory();
    this.renderer = new GameRenderer();

    // Init the entities
    this.player = new Player(0, 0);
    this.stairs = [];

    this.bindEvents();
    this.init();
  }

  init() {
    this.stateManager.setState(GameState.NOT_STARTED);

    this.entityFactory.resetIdCounter();

    this.stairs = this.entityFactory.createInitialStairs();

    this.player = this.entityFactory.createPlayerOnStair(this.stairs);

    this.ensureEnoughStairs();
  }

  bindEvents() {
    this.canvas.addEventListener("click", () => {
      this.handleCanvasClick();
    });
  }

  handleCanvasClick() {
    switch (this.stateManager.state) {
      case GameState.NOT_STARTED:
        this.init();
        this.stateManager.startNewGame();
        break;
      case GameState.GAME_OVER:
        this.init();
        this.stateManager.startNewGame();
        break;
      default:
        break;
    }
  }

  resetGame() {
    this.stateManager.resetScore();
    this.entityFactory.resetIdCounter();
    this.stairs = this.entityFactory.createInitialStairs();
    this.player = this.entityFactory.createPlayerOnStair(this.stairs);

    this.ensureEnoughStairs();
  }

  update(keysPressed) {
    this.stateManager.updateScore();
    this.stateManager.updateTime();
    this.updateEntities(keysPressed);
    this.checkGameConditions();
  }

  updateEntities(keysPressed) {
    const gameSpeed = this.stateManager.stats.gameSpeed;
    this.updateStairs(gameSpeed);

    this.player.update(keysPressed, this.stairs);
  }

  ensureEnoughStairs() {
    if (this.stairs.length === 0) return;

    const lowestStair = [...this.stairs].sort((a, b) => b.y - a.y)[0];

    if (lowestStair.y < GAME_CONFIG.canvasHeight + 5) {
      const stairsNeeded =
        Math.ceil(
          (GAME_CONFIG.canvasHeight + 5 - lowestStair.y) /
            GAME_CONFIG.stairSpacing
        ) + 2;
      for (let i = 0; i < stairsNeeded; i++) {
        this.addNewStair();
      }
    }
  }

  addNewStair() {
    if (this.stairs.length === 0) {
      this.stairs.push(
        this.entityFactory.createStair(Math.random() * GAME_CONFIG.canvasWidth)
      );
      return;
    }

    const lastStair = this.stairs[this.stairs.length - 1];
    const stairY = lastStair.y + GAME_CONFIG.stairSpacing;
    const stairX =
      Math.random() * (GAME_CONFIG.canvasHeight - GAME_CONFIG.stairWidth);
    const newStair = this.entityFactory.createStair(stairX, stairY);
    this.stairs.push(newStair);
  }

  updateStairs(gameSpeed) {
    this.stairs.forEach((stair) => stair.update(gameSpeed));
  }

  manageStairs() {
    this.stairs = this.stairs.filter((stair) => stair.y + stair.height > 0);
    this.ensureEnoughStairs();
  }

  checkGameConditions() {
    if (this.player.y <= 0) {
      this.handleTopCollision();
    }

    if (this.player.y >= GAME_CONFIG.canvasHeight + this.player.height) {
      this.stateManager.endGame();
      return;
    }

    if (this.player.health <= 0) {
      this.stateManager.endGame();
    }
  }

  handleTopCollision() {
    if (this.stateManager.stats.score > 50 && this.player.takeDamage()) {
      this.player.y = 5;
      this.player.velocity = -5;
    } else {
      this.player.y = 5;
      this.player.velocity = -3;
    }
  }

  render() {
    this.renderer.render(
      this.context,
      this.stateManager.state,
      this.player,
      this.stairs,
      this.stateManager.stats
    );
  }
}

EntityFactory.js

import { GAME_CONFIG } from "./config";
import Player from "./entities/Player";
import Stair from "./entities/Stair";

export default class EntityFactory {
  // To manage the entities
  nextEntityId = 0;

  constructor() {
    this.reset;
  }

  resetIdCounter() {
    this.nextEntityId = 0;
  }

  createPlayerOnStair(stairs) {
    const topStairs = stairs.filter((stair) => stair.y < 200 && stair.y > 50);
    if (topStairs.length > 0) {
      const randomStair =
        topStairs[Math.floor(Math.random() * topStairs.length)];

      const player = new Player(
        randomStair.x + randomStair.width / 2 - GAME_CONFIG.playerWidth / 2,
        randomStair.y - GAME_CONFIG.playerHeight
      );

      player.y = Math.max(player.y, 20);
      player.setInitialInvincibility();
      return player;
    }

    const player = new Player(
      GAME_CONFIG.canvasWidth / 2 - GAME_CONFIG.playerWidth / 2,
      100
    );

    player.setInitialInvincibility();
    return player;
  }

  createPlayer() {}

  createStair(x, y, width = undefined) {
    return new Stair(this.nextEntityId++, x, y, width);
  }

  createInitialStairs() {
    let stairs = [];
    let stairsNeeded =
      Math.ceil(GAME_CONFIG.canvasHeight / GAME_CONFIG.stairSpacing) + 3;

    for (let i = 1; i < stairsNeeded; i++) {
      stairs.push(
        this.createStair(
          Math.random() * (GAME_CONFIG.canvasWidth - GAME_CONFIG.stairWidth),
          GAME_CONFIG.canvasHeight - i * GAME_CONFIG.stairSpacing - 50
        )
      );
    }
    return stairs;
  }
}

GameStateManager.js

import { GAME_CONFIG, ScoreLevel } from "./config";

export const GameState = {
  NOT_STARTED: "NOT_STARTED",
  PLAYING: "PLAYING",
  GAME_OVER: "GAME_OVER",
};

export default class GameStateManager {
  state = GameState.NOT_STARTED;

  stats = {
    score: 0,
    highScore: 0,
    gameSpeed: GAME_CONFIG.initialSpeed,
    timeElapsed: 0,
  };

  lastUpdateTime = 0;
  onStateChangeCallbacks = [];

  constructor() {
    let savedHighScore = localStorage.getItem("highScore");
    if (savedHighScore) {
      this.stats.highScore = parseInt(savedHighScore);
    }

    this.lastUpdateTime = Date.now();
  }

  setState(newState) {
    if (this.state !== newState) {
      this.state = newState;
      if (newState === GameState.GAME_OVER) {
        this.checkAndUpdateHighScore();
      }

      this.notifyStateChange(newState);
    }
  }

  onStateChange(callback) {
    this.onStateChangeCallbacks.push(callback);
  }

  notifyStateChange(newState) {
    for (const callback of this.onStateChangeCallbacks) {
      callback(newState);
    }
  }

  updateScore(incrementAmount = 1) {
    if (this.state !== GameState.PLAYING) {
      return;
    }

    this.stats.score += incrementAmount;

    this.updateGameSpeed();
  }

  resetScore() {
    this.stats.score = 0;
    this.stats.gameSpeed = GAME_CONFIG.initialSpeed;
    this.stats.timeElapsed = 0;
    this.lastUpdateTime = Date.now();
  }

  checkAndUpdateHighScore() {
    if (this.stats.score > this.stats.highScore) {
      this.stats.highScore = this.stats.score;
      localStorage.setItem("highScore", this.stats.highScore.toString());
    }
  }

  updateGameSpeed() {
    if (
      this.stats.score > 0 &&
      this.stats.score % GAME_CONFIG.speedIncrementScore === 0
    ) {
      this.stats.gameSpeed = Math.min(
        this.stats.gameSpeed + GAME_CONFIG.speedIncrement,
        GAME_CONFIG.maxSpeed
      );
    }
  }

  updateTime() {
    if (this.state !== GameState.PLAYING) return;

    const currentTime = Date.now();
    const deltaTime = currentTime - this.lastUpdateTime;
    this.lastUpdateTime = currentTime;

    this.stats.timeElapsed += deltaTime;
  }

  getTimeElapsed() {
    return Math.floor(this.stats.timeElapsed / 1000);
  }

  startNewGame() {
    this.resetScore();
    this.setState(GameState.PLAYING);
  }

  endGame() {
    if (this.state === GameState.PLAYING) {
      this.setState(GameState.GAME_OVER);
    }
  }
}

GameRenderer.js

import { GAME_CONFIG, ScoreLevel } from "./config";
import { GameState } from "./GameStateManager";

export default class GameRenderer {
  config;

  constructor(config = undefined) {
    this.config = {
      width: GAME_CONFIG.canvasWidth,
      height: GAME_CONFIG.canvasHeight,
      backgroundColor: "#87CEEB",
      textColor: "#FFFFFF",
      overlayColor: "rgba(0,0,0,0.7)",
      ...config,
    };
  }

  render(ctx, gameState, player, stairs, stats) {
    this.clearCanvas(ctx);
    // this.renderBackground(ctx);

    switch (gameState) {
      case GameState.NOT_STARTED:
        this.renderEntities(ctx, player, stairs);
        this.renderStartScreen(ctx);
        break;

      case GameState.PLAYING:
        this.renderEntities(ctx, player, stairs);
        this.renderUI(ctx, player, stats);
        break;

      case GameState.GAME_OVER:
        this.renderEntities(ctx, player, stairs);
        this.renderGameOverScreen(ctx, stats);
        break;

      default:
        break;
    }
  }

  clearCanvas(ctx) {
    ctx.clearRect(0, 0, this.config.width, this.config.height);
  }

  renderBackground(ctx) {
    gradient = ctx.createLinearGradient(0, 0, 0, this.config.height);
    gradient.addColorStop(0, "#87CEEB"); // 天空藍
    gradient.addColorStop(1, "#4682B4"); // 鋼藍
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, this.config.width, this.config.height);

    ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
    this.drawCloud(ctx, 50, 80, 70, 30);
    this.drawCloud(ctx, 250, 40, 60, 25);
    this.drawCloud(ctx, 150, 120, 80, 35);
  }

  renderEntities(ctx, player, stairs) {
    for (const stair of stairs) {
      stair.render(ctx);
    }

    player.render(ctx);
  }

  renderUI(ctx, player, stats) {
    this.renderScore(ctx, stats.score);
    player.renderHealthBar(ctx);
    this.renderHighestScore(ctx, stats.highestScore);
    this.renderGameTime(ctx, stats.time);
  }

  renderScore(ctx, score) {
    ctx.fillStyle = "#000000";
    ctx.font = "20px Arial";
    ctx.textAlign = "left";
    ctx.fillText(`分數: ${score}`, 10, 30);
  }

  renderHighestScore(ctx, highestScore) {
    ctx.fillStyle = "#000000";
    ctx.font = "20px Arial";
    ctx.textAlign = "right";
    ctx.fillText(`最高分數: ${highestScore}`, this.config.width - 10, 30);
  }

  renderGameTime(ctx, seconds) {
    let minutes = Math.floor(seconds / 60);
    let remaingSeconds = seconds % 60;

    ctx.fillStyle = "#000000";
    ctx.font = "16px Arial";
    ctx.textAlign = "right";
    ctx.fillText(
      `時間: ${minutes}:${remaingSeconds.toString().padStart(2, "0")}`,
      this.config.width - 10,
      60
    );
  }

  drawCloud(ctx, x, y, width, height) {
    ctx.beginPath();
    ctx.moveTo(x, y + height / 2);
    ctx.bezierCurveTo(
      x,
      y,
      x + width / 2,
      y - height / 2,
      x + width,
      y + height / 2
    );
    ctx.bezierCurveTo(
      x + width * 1.2,
      y + height,
      x + width * 0.8,
      y + height * 1.5,
      x + width / 2,
      y + height
    );
    ctx.bezierCurveTo(
      x + width / 5,
      y + height * 1.2,
      x,
      y + height,
      x,
      y + height / 2
    );
    ctx.closePath();
    ctx.fill();
  }

  renderStartScreen(ctx) {
    ctx.fillStyle = this.config.overlayColor;
    ctx.fillRect(0, 0, this.config.width, this.config.height);

    ctx.fillStyle = this.config.textColor;
    ctx.font = "30px Arial";
    ctx.textAligh = "center";
    ctx.fillText(
      "小朋友下樓梯",
      this.config.width / 2,
      this.config.height / 2 - 50
    );

    ctx.font = "20px Arial";
    ctx.fillText(
      "← → 鍵控制移動",
      this.config.width / 2,
      this.config.height / 2
    );

    ctx.fillText(
      "避免撞到頂部或掉下去",
      this.config.width / 2,
      this.config.height / 2 + 40
    );
    ctx.fillText(
      "點擊開始遊戲",
      this.config.width / 2,
      this.config.height / 2 + 80
    );
  }

  renderGameOverScreen(ctx, stats) {
    ctx.fillStyle = this.config.overlayColor;
    ctx.fillRect(0, 0, this.config.width, this.config.height);

    ctx.fillStyle = this.config.textColor;
    ctx.font = "30px Arial";
    ctx.textAligh = "center";

    ctx.fillText(
      "遊戲結束",
      this.config.width / 2,
      this.config.height / 2 - 60
    );

    ctx.fillText(
      `最終分數: ${stats.score}`,
      this.config.width / 2,
      this.config.height / 2 - 10
    );

    ctx.font = "20px Arial";
    let comment = this.getScoreComment(stats.score);
    ctx.fillText(comment, this.config.width / 2, this.config.height / 2 + 30);

    ctx.fillText(
      "點擊重新開始",
      this.config.width / 2,
      this.config.height / 2 + 70
    );
  }

  getScoreComment(score) {
    if (score < ScoreLevel.LOW) {
      return "再多練習一下吧!";
    } else if (score < ScoreLevel.MEDIUM) {
      return "還不錯的表現!";
    } else if (score < ScoreLevel.HIGH) {
      return "真是太棒了!";
    } else {
      return "你是下樓梯大師!";
    }
  }
}

總結

完成後,讓我們進入到前端的專案之中(30-days-build-up-api-driven-architecture/frontend/down-game),輸入 npm run dev 試試看!

看起來畫面的確順利出現了,人物也可以移動,但是比例還怪怪的,並且新的階梯不會自動生成,不過這些就留待 Day 6 解決吧!404

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