更新於 2024/08/12閱讀時間約 18 分鐘

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

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 看喔,不過有包含多人遊戲的部分,我們下篇文章見 ^^。


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.