前一篇介紹網站基本架構,這篇主要介紹遊戲最核心的部分,以單人遊戲為出發點,從發牌、抽牌、出牌、檢查算式結果是否正確以及計算分數到遊戲結算,一環扣著一環把遊戲邏輯完成,那就讓我們開始吧!
這個頁面我把它切成三等份,由上到下分別為,功能按鈕區塊,在多人連線中會多塞對手玩家的畫面,中間區塊顯示組合中的算式以及算式結果,最下面的區塊顯示手牌、玩家資訊、操作按鈕,如下圖。
雖然是單人遊玩,但整體架構還是建立在 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 看喔,不過有包含多人遊戲的部分,我們下篇文章見 ^^。