[遊戲開發] 24點數學遊戲 - 網站製作篇(二)

更新於 發佈於 閱讀時間約 18 分鐘
raw-image


前言

前一篇介紹網站基本架構,這篇主要介紹遊戲最核心的部分,以單人遊戲為出發點,從發牌、抽牌、出牌、檢查算式結果是否正確以及計算分數到遊戲結算,一環扣著一環把遊戲邏輯完成,那就讓我們開始吧!


單人遊玩頁面

這個頁面我把它切成三等份,由上到下分別為,功能按鈕區塊,在多人連線中會多塞對手玩家的畫面,中間區塊顯示組合中的算式以及算式結果,最下面的區塊顯示手牌、玩家資訊、操作按鈕,如下圖。

三大區塊

三大區塊

Websocket

雖然是單人遊玩,但整體架構還是建立在 socket 上,也就是說單人就自己一個遊戲房間,這樣的好處是未來在開發多人遊玩時,就可以共用遊戲事件與邏輯方法,當載入這個頁面的時候,需要 new 一個 socket 變數,開啟與伺服器的連線。

File path: hooks\useSinglePlay.ts

const socket = io();

我使用了 hook 來封裝整個前端事件以及遊戲狀態

const useSinglePlay = () => {
  const [roomInfo, setRoomInfo] = useState<Room>();

  useEffect(() => {
    const roomId = uuidv4();
    socket.emit(SocketEvent.JoinRoom, {
      roomId,
      maxPlayers: 1,
      playerName: 'single',
      mode: GameMode.Single,
    });

    socket.on(SocketEvent.JoinRoomSuccess, () => {
      // 遊戲開始
      socket.emit(SocketEvent.StartGame, { roomId });
    });

    socket.on(SocketEvent.ErrorMessage, message => {
      toast.error(message);
    });

    // 房間更新
    socket.on(
      SocketEvent.RoomUpdate,
      ({
        room,
        extra,
      }: {
        room: Room;
        extra?: { event: SocketEvent; data: any };
      }) => {
        setRoomInfo(room);
        if (extra?.event === SocketEvent.PlayCardResponse) {
          if (extra.data) {
            toast.success('答案正確');
          } else {
            toast.error('答案不等於 24');
          }
          setCheckAnswerCorrect(extra.data as boolean);
        }
      },
    );

    return () => {
      socket.disconnect();
    };
  }, []);

  return {
    roomInfo,
  };
};

export default useSinglePlay;

在 useEffect 中會打一個 JoinRoom 事件到後端,會隨機給一個 roomId,這邊用 uuid 套件產生一個,最大玩家數就設定一個,模式設定單人,這些參數後端會拿來判斷要準備什麼類型的房間,單人有單人的牌庫與設定,接著開啟 socket.on 來監聽後端的事件,事件 JoinRoomSuccess 的作用是後端把玩家成功加入房間後觸發,通知玩家可以開始遊戲了,接著由前端再打 StartGame 事件並打 roomId 過去到後端,觸發 RoomUpdate 事件把房間資訊回傳給前端並渲染畫面。

整個畫面的資料都放在 roomInfo 裡面

const [roomInfo, setRoomInfo] = useState<Room>();

以下是 Room 的型別

export type Room = {
  roomId: string;
  roomName?: string;
  password?: string; // 房間密碼
  maxPlayers: number; // 最大玩家數
  currentOrder: number; // 當前輪到的玩家
  deck: NumberCard[]; // 牌庫
  players: Player[]; // 玩家資訊
  isGameOver: boolean; // 是否遊戲結束
  selectedCards: SelectedCard[]; // 當前所選的牌
  status: GameStatus; // 房間狀態
  settings: RoomSettings; // 房間設定
  countdownTime?: number; // 回合倒數計時
};

最主要的房間資訊是 roomId、maxPlayers、currentOrder、deck、isGameOver、selectedCards。

接下來是後端的部分,由於程式碼太長我舉幾個比較重要的事件來介紹。

const httpServer = createServer(handler);
 const io = new Server(httpServer, {
   pingInterval: 24 * 60 * 60 * 1000,
   pingTimeout: 3 * 24 * 60 * 60 * 1000,
});

首先先 new 一個 socket server。

io.on('connection', socket => {
const playerId = socket.id;

// 以下省略...
});

開啟連線並監聽從 client 來的事件,使用 socket.id 來做為玩家 id。

socket.on(
  SocketEvent.JoinRoom,
  ({ roomId, maxPlayers, playerName, roomName, password, mode }) => {
    const canJoin = checkCanJoinRoom(roomId, playerId, mode);
    if (canJoin) {
      socket.join(roomId);
      const { room, msg, needPassword } = joinRoom(
        { roomId, maxPlayers, roomName, password },
        playerId,
        playerName,
        mode,
      );

      if (room) {
        io.sockets.to(roomId).emit(SocketEvent.JoinRoomSuccess, room);
        socket.emit(SocketEvent.GetPlayerId, playerId);
      } else if (needPassword) {
        socket.emit(SocketEvent.NeedRoomPassword);
      } else {
        socket.emit(SocketEvent.ErrorMessage, msg);
      }
    } else {
      socket.emit(SocketEvent.ErrorMessage, '房間人數已滿或不存在');
    }
  },
);

JoinRoom 為玩家加入房間時觸發,會先檢查是否可以加入房間 checkCanJoinRoom 這個方法。

export function checkCanJoinRoom(
  roomId: string,
  playerId: string,
  mode: GameMode,
) {
  if (mode === GameMode.Single) {
    return true;
  }

  return false;
}

單人比較簡單因為只有一個人一律回傳 true,接下來就呼叫 joinRoom 方法把玩家加入房間。

let _rooms: Room[] = [];

所有的房間都存在內存中,在專案的初始階段這個方法簡單又快速,但是當玩家人數多的時候可能會有記憶體不足的問題,或是重新上版時重啟伺服器會把所有已存在的房間清除,所以只能挑合適的時間上版。

// 加入房間
export function joinRoom(
  payload: Pick<Room, 'roomId' | 'maxPlayers' | 'roomName' | 'password'>,
  playerId: string,
  playerName: string,
  mode: GameMode,
): Response & { needPassword?: boolean } {
  try {
    // 創建新房間
    const newRoom: Room = {
      roomId: payload.roomId,
      maxPlayers: payload.maxPlayers,
      deck: [],
    currentOrder: -1,
      isGameOver: false,
      selectedCards: [],
      roomName: payload.roomName,
     password: payload.password,
      status: GameStatus.Idle,
      settings: {
        deckType: DeckType.Standard,
        remainSeconds: 60,
      },
      players: [
        {
          id: playerId,
          isMaster: true,
          name: playerName,
          handCard: [],
          score: 0,
          isLastRoundPlayer: false,
          isReady: true,
        },
      ],
    };

    _rooms.push(newRoom);

    return {
      room: newRoom,
    };
  }
} catch (e) {
  return { msg: '發生錯誤,請稍後再試 (join room)' };
}
}

初始化房間資訊,建立一個新的房間並回傳當前的房間資訊到前端。

io.sockets.to(roomId).emit(SocketEvent.JoinRoomSuccess, room);

還記得前端會在收到 JoinRoomSuccess 事件後打 StartGame 事件嗎,當後端收到 StartGame 後就會準備寫入遊戲設置到房間資訊。

socket.on(SocketEvent.StartGame, ({ roomId }) => {
  const { room, msg } = startGame(roomId);
  if (room) {
    io.sockets.to(roomId).emit(SocketEvent.RoomUpdate, { room });
  } else {
    socket.emit(SocketEvent.ErrorMessage, msg);
  }
});

接下來呼叫 startGame 方法。

// 開始遊戲
export function startGame(roomId: string): Response {
const roomIndex = _getCurrentRoomIndex(roomId);
  if (roomIndex === -1) return { msg: '房間不存在' };
  try {
    let tempDeck: number[] = [];
    switch (room.players.length) {
      case 1:
        tempDeck = createDeckByRandomMode(40, 10);
        break;
      default:
        return {
          msg: '開始遊戲失敗',
        };
    }

    // 洗牌
    const shuffledDeck: NumberCard[] = shuffleArray(tempDeck).map(d => ({
      id: uuidv4(),
      value: d,
    }));

   _rooms[roomIndex].players[0].playerOrder = order;
    _rooms[roomIndex].players[0].score = 0;
   _rooms[roomIndex].players[0].isLastRoundPlayer = false;
   if (shuffledDeck.length) {
    // 抽牌並改變牌庫牌數
    _rooms[roomIndex].players[0].handCard = draw(
      shuffledDeck,
      HAND_CARD_COUNT,
      );
    }

    // 寫入牌庫
    _rooms[roomIndex].deck = shuffledDeck;

    // 從玩家1開始
    _rooms[roomIndex].currentOrder = 1;

    // 初始化
    _rooms[roomIndex].status = GameStatus.Playing;
    _rooms[roomIndex].isGameOver = false;
    _rooms[roomIndex].selectedCards = [];

    return {
      room: _rooms[roomIndex],
    };
  } catch (e) {
    return { msg: '發生錯誤,請稍後再試 (start game)' };
  }
}

我們先呼叫 _getCurrentRoomIndex 方法找到當前房間,沒找到則回傳錯誤訊息。

const roomIndex = _rooms.findIndex(room => room.roomId === roomId);

在第 9 行,我們呼叫 createDeckByRandomMode 方法產生隨機的牌庫。

/** 產生牌庫 n 為共幾張,maxValue 為最大值 */
export function createDeckByRandomMode(n: number, maxValue: number) {
  const array = [];

  for (let i = 0; i < n; i++) {
    array.push(Math.floor(Math.random() * maxValue) + 1);
  }

  return array;
}

接著呼叫 shuffleArray 洗牌。

/* shuffle */
export function shuffleArray<T>(array: T[]) {
  // 複製原始陣列以避免修改
  let shuffledArray = array.slice();

  // Fisher-Yates 洗牌算法
  for (let i = shuffledArray.length - 1; i > 0; i--) {
    // 生成 0 到 i 之間的隨機整數
    const j = Math.floor(Math.random() * (i + 1));
    // 交換元素 shuffledArray[i] 和 shuffledArray[j]
    [shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
  }

  return shuffledArray;
}

我們透過強大的 ChatGPT 幫我們寫洗牌邏輯,洗完牌後把牌寫進牌庫中,並切換遊玩狀態到遊玩中,這樣我們就完成單人遊戲的初始化了。

結語

感謝大家看到這邊,寫到加入房間與開始遊戲,就已經這麼落落長了,事件的部分我想把它分成幾篇文章來介紹,完整的程式碼可以到我的 github 看喔,不過有包含多人遊戲的部分,我們下篇文章見 ^^。


留言
avatar-img
留言分享你的想法!
avatar-img
Johnson Huang的沙龍
7會員
10內容數
Johnson Huang的沙龍的其他內容
2025/01/09
這是一款休閒的記憶配對翻牌遊戲,遊戲總共超過 1000 個關卡等你來挑戰,玩家可以選擇各種不同的主題,例如可愛的動物、繽紛的顏色、超鬧的古人、路邊的樹木與草叢,不只是挑戰你的記憶力還要多一點觀察力才能闖關成功...
Thumbnail
2025/01/09
這是一款休閒的記憶配對翻牌遊戲,遊戲總共超過 1000 個關卡等你來挑戰,玩家可以選擇各種不同的主題,例如可愛的動物、繽紛的顏色、超鬧的古人、路邊的樹木與草叢,不只是挑戰你的記憶力還要多一點觀察力才能闖關成功...
Thumbnail
2024/08/28
大家好,要來介紹一款新開發的翻牌遊戲,可以訓練玩家的反應與記憶力。簡單易懂的遊戲規則以及有趣的題組設計。目前遊戲已經上線,歡迎玩家一起來挑戰!遊戲包含無限接關和排行榜機制,讓玩家能與朋友一較高下。下篇文章將探討遊戲開發技術及購買網域的流程。
Thumbnail
2024/08/28
大家好,要來介紹一款新開發的翻牌遊戲,可以訓練玩家的反應與記憶力。簡單易懂的遊戲規則以及有趣的題組設計。目前遊戲已經上線,歡迎玩家一起來挑戰!遊戲包含無限接關和排行榜機制,讓玩家能與朋友一較高下。下篇文章將探討遊戲開發技術及購買網域的流程。
Thumbnail
2024/08/18
文章介紹了遊戲的核心事件,包括選牌、出牌和計算分數的邏輯與實作。首先介紹遊戲開始時的選牌事件,接著解析出牌事件及算式計算的過程,最後詳述分數計算的邏輯。
Thumbnail
2024/08/18
文章介紹了遊戲的核心事件,包括選牌、出牌和計算分數的邏輯與實作。首先介紹遊戲開始時的選牌事件,接著解析出牌事件及算式計算的過程,最後詳述分數計算的邏輯。
Thumbnail
看更多
你可能也想看
Thumbnail
每年4月、5月都是最多稅要繳的月份,當然大部份的人都是有機會繳到「綜合所得稅」,只是相當相當多人還不知道,原來繳給政府的稅!可以透過一些有活動的銀行信用卡或電子支付來繳,從繳費中賺一點點小確幸!就是賺個1%~2%大家也是很開心的,因為你們把沒回饋變成有回饋,就是用卡的最高境界 所得稅線上申報
Thumbnail
每年4月、5月都是最多稅要繳的月份,當然大部份的人都是有機會繳到「綜合所得稅」,只是相當相當多人還不知道,原來繳給政府的稅!可以透過一些有活動的銀行信用卡或電子支付來繳,從繳費中賺一點點小確幸!就是賺個1%~2%大家也是很開心的,因為你們把沒回饋變成有回饋,就是用卡的最高境界 所得稅線上申報
Thumbnail
全球科技產業的焦點,AKA 全村的希望 NVIDIA,於五月底正式發布了他們在今年 2025 第一季的財報 (輝達內部財務年度為 2026 Q1,實際日曆期間為今年二到四月),交出了打敗了市場預期的成績單。然而,在銷售持續高速成長的同時,川普政府加大對於中國的晶片管制......
Thumbnail
全球科技產業的焦點,AKA 全村的希望 NVIDIA,於五月底正式發布了他們在今年 2025 第一季的財報 (輝達內部財務年度為 2026 Q1,實際日曆期間為今年二到四月),交出了打敗了市場預期的成績單。然而,在銷售持續高速成長的同時,川普政府加大對於中國的晶片管制......
Thumbnail
重點摘要: 6 月繼續維持基準利率不變,強調維持高利率主因為關稅 點陣圖表現略為鷹派,收斂 2026、2027 年降息預期 SEP 連續 2 季下修 GDP、上修通膨預測值 --- 1.繼續維持利率不變,強調需要維持高利率是因為關稅: 聯準會 (Fed) 召開 6 月利率會議
Thumbnail
重點摘要: 6 月繼續維持基準利率不變,強調維持高利率主因為關稅 點陣圖表現略為鷹派,收斂 2026、2027 年降息預期 SEP 連續 2 季下修 GDP、上修通膨預測值 --- 1.繼續維持利率不變,強調需要維持高利率是因為關稅: 聯準會 (Fed) 召開 6 月利率會議
Thumbnail
本篇介紹單人遊戲的核心架構與邏輯,涵蓋發牌、抽牌、出牌及遊戲結算等重要步驟。文章也詳細介紹了使用 socket.io 建立連線的過程,並說明如何利用 React Hooks 管理遊戲狀態,提及後端伺服器如何處理玩家加入房間的事件,並簡要介紹了房間資訊的管理,此文將分為多篇進一步介紹遊戲事件部分。
Thumbnail
本篇介紹單人遊戲的核心架構與邏輯,涵蓋發牌、抽牌、出牌及遊戲結算等重要步驟。文章也詳細介紹了使用 socket.io 建立連線的過程,並說明如何利用 React Hooks 管理遊戲狀態,提及後端伺服器如何處理玩家加入房間的事件,並簡要介紹了房間資訊的管理,此文將分為多篇進一步介紹遊戲事件部分。
Thumbnail
24點數學遊戲是一款適合小朋友與想動動腦的朋友們的小遊戲,遊戲規則簡單易懂,可訓練邏輯能力。遊戲分為單人與多人模式,可以讓玩家自行挑戰高分或是與其他玩家競爭。算式中不同的數學符號會對應不同的加分機制。遊戲網站連結與專案 repo 也都提供在文章中。
Thumbnail
24點數學遊戲是一款適合小朋友與想動動腦的朋友們的小遊戲,遊戲規則簡單易懂,可訓練邏輯能力。遊戲分為單人與多人模式,可以讓玩家自行挑戰高分或是與其他玩家競爭。算式中不同的數學符號會對應不同的加分機制。遊戲網站連結與專案 repo 也都提供在文章中。
Thumbnail
原版的官方規則導入記分機制,但因為計算過於繁複,所以一般遊玩時較少採用。本變體規則旨在還原原規則的策略性,並保留平常的遊玩樂趣。 1. 配件準備 4枚不同顏色的棋子(紅、藍、黃、綠),以及一張標記0~15的場地。 2. 記分方式 一開始所有棋子都在0的位置。每一局結束時,贏家以外的所有人拿出
Thumbnail
原版的官方規則導入記分機制,但因為計算過於繁複,所以一般遊玩時較少採用。本變體規則旨在還原原規則的策略性,並保留平常的遊玩樂趣。 1. 配件準備 4枚不同顏色的棋子(紅、藍、黃、綠),以及一張標記0~15的場地。 2. 記分方式 一開始所有棋子都在0的位置。每一局結束時,贏家以外的所有人拿出
Thumbnail
Ae 小技巧:卡牌旋轉 動態後記系列會記錄一些我在製作中的記錄,可能是分解動畫、小技巧、發想、腳本......等等。 每篇都是小短篇,就是補充用的小筆記,沒有前後順序,可跳著閱讀。
Thumbnail
Ae 小技巧:卡牌旋轉 動態後記系列會記錄一些我在製作中的記錄,可能是分解動畫、小技巧、發想、腳本......等等。 每篇都是小短篇,就是補充用的小筆記,沒有前後順序,可跳著閱讀。
Thumbnail
從博弈觀點切入並結合數值盤點,觀察大富翁GO的運營模式。
Thumbnail
從博弈觀點切入並結合數值盤點,觀察大富翁GO的運營模式。
Thumbnail
曼哈頓 規則https://punchboardgame.pixnet.net/blog/post/463394453 這是一款老桌遊,規則和計分方式相對簡單,但整體的遊玩體驗是很好的。 我所遊玩的是新版的擴充版本,增加了額外的計分規則,也會讓每一次的策略都有所變化,最後呈現出完全不同的結果。
Thumbnail
曼哈頓 規則https://punchboardgame.pixnet.net/blog/post/463394453 這是一款老桌遊,規則和計分方式相對簡單,但整體的遊玩體驗是很好的。 我所遊玩的是新版的擴充版本,增加了額外的計分規則,也會讓每一次的策略都有所變化,最後呈現出完全不同的結果。
Thumbnail
W5 3/18 《曼哈頓》 雖然遊戲規則感覺很簡單,但很重策略(對我來說),要一直思考如何分佈建築才能得高分,還有目標針對誰之類的,但我覺得設計的不錯,很有趣。 《機密代碼》 這款遊戲學校有,我也玩過,但是老師有改一個規則分攤隊長的壓力,我覺得很不錯,是很有效的規則,我們玩了兩局,都是紅隊贏
Thumbnail
W5 3/18 《曼哈頓》 雖然遊戲規則感覺很簡單,但很重策略(對我來說),要一直思考如何分佈建築才能得高分,還有目標針對誰之類的,但我覺得設計的不錯,很有趣。 《機密代碼》 這款遊戲學校有,我也玩過,但是老師有改一個規則分攤隊長的壓力,我覺得很不錯,是很有效的規則,我們玩了兩局,都是紅隊贏
Thumbnail
建立幾個變數如下,最上面兩個變數值為清單值 接下來分別設定球1位置到左上角落、設定球2位置到右上角落、設定球3位置到左下角落、設定球4位置到右下角落 當螢幕初始化的時候,設定玩家球的X、Y座標和大小,並將玩家球的初始顏色,設定成(變數_顏色清單)中.....
Thumbnail
建立幾個變數如下,最上面兩個變數值為清單值 接下來分別設定球1位置到左上角落、設定球2位置到右上角落、設定球3位置到左下角落、設定球4位置到右下角落 當螢幕初始化的時候,設定玩家球的X、Y座標和大小,並將玩家球的初始顏色,設定成(變數_顏色清單)中.....
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News