이번 포스팅에선 게임 로직과 관련된 서버 통신 코드를 모두 구현해보겠습니다. 게임 로직은 서버에서 구현하지 않고 각각의 클라이언트 어플 내에서 로직이 돌아갑니다. 서버의 역할은 주사위 결과 값을 다른 모든 플레이어에게 전달, 어떤 건물을 구매했는지 전달, 턴을 넘겼는지 전달 하는 역할을 합니다. 즉 게임 로직과 관련된 결과값들만 성공적으로 전달한다면 각각의 기기에서 게임을 즐길 수 있습니다.
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('createRoom', (data) => {
...
});
socket.on('joinRoom', (data) => {
...
});
// 주사위 굴리기 이벤트 수신
socket.on('rollDice', (data) => {
const roomCode = data.roomCode;
const numberOfDiceToRoll = data.numberOfDiceToRoll;
const dice1Result = data.dice1Result;
const dice2Result = data.dice2Result;
// 주사위를 굴린 플레이어를 제외한 모든 플레이어에게 결과를 전송합니다.
socket.to(roomCode).emit('diceRolled', {
roomCode,
dice1Result,
dice2Result,
numberOfDiceToRoll});
});
// 주사위 한번 더 굴리기 이벤트 수신
socket.on('extraRollDice', (data) => {
const roomCode = data.roomCode;
const shouldRollAgain = data.shouldRollAgain;
const numberOfDiceToRoll = data.numberOfDiceToRoll;
const dice1Result = data.dice1Result;
const dice2Result = data.dice2Result;
// 주사위를 굴린 플레이어를 제외한 모든 플레이어에게 결정 알림
socket.to(roomCode).emit('extraDiceRolled', {
roomCode,
shouldRollAgain,
numberOfDiceToRoll,
dice1Result,
dice2Result});
});
// 턴 넘김 이벤트 수신
socket.on('nextTurn', (data) =>{
const roomCode = data.roomCode;
socket.to(roomCode).emit('doNextTurn',{roomCode});
});
// 중앙 건물 구매 이벤트 수신
socket.on('centerCardPurchase', (data)=>{
const roomCode = data.roomCode;
const purchasePlayerId = data.purchasePlayerId;
const buildingIndex = data.buildingIndex;
const buildingCost = data.buildingCost;
socket.to(roomCode).emit('centerCardPurchased',{
roomCode,
purchasePlayerId,
buildingIndex,
buildingCost});
});
// 주요 건물 구매 이벤트 수신
socket.on('majorCardPurchase', (data)=>{
const roomCode = data.roomCode;
const purchasePlayerId = data.purchasePlayerId;
const buildingIndex = data.buildingIndex;
const buildingCost = data.buildingCost;
socket.to(roomCode).emit('majorCardPurchased',{
roomCode,
purchasePlayerId,
buildingIndex,
buildingCost,
playerNames: rooms[roomCode].playerName});
});
socket.on('gameStart', (data) => {
...
});
socket.on("roomQuit", (data) => {
...
});
socket.on('disconnect', () => {
...
});
// 게임 승리 이벤트 수신
socket.on('gameWon', (data) => {
const roomCode = data.roomCode;
if (rooms[roomCode]) {
delete rooms[roomCode];
console.log(`Room ${roomCode} has been deleted due to game completion.`);
} else {
console.log('Room not found:', roomCode);
}
});
});
// Start the Express server
const PORT = process.env.PORT || 3000;
http.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
function generateRoomCode() {
...
}
socket.on('rollDice', (data) => { ... }
첫번째 주사위 결과, 두번째 주사위 결과, 주사위 갯수를 방코드에 맞게 전달합니다. 이 때 주사위를 굴린 플레이어를 제외한 모든 플레이어들에게 데이터가 전달됩니다.
클라이언트에서 주사위 굴리기 버튼을 눌렀을 때 바로 결과가 확정되고 해당 결과가 서버로 전송되도록 구현할 것입니다. 주사위 애니매이션이 끝나고 나서야 결과가 나오기 때문에 미리 결과가 확정되어도 유저는 알 수 없습니다.
socket.on('extraRollDice', (data) => { ... }
주사위 확정 여부, 첫번째 주사위 결과, 두번째 주사위 결과, 주사위 갯수를 방코드에 맞게 전달합니다.
주사위를 확정한다면 shouldRollAgain값이 true로 전달되어 바로 효과가 적용될 것이고 만약 확정하지 않는다면 돈이 오르지 않고 주사위를 두번 굴린 것이 보일겁니다.
socket.on('nextTurn', (data) =>{ ... }
방코드에 맞게 플레이어 턴을 넘기라고 요청을 보냅니다.
socket.on('centerCardPurchase', (data)=>{ ... }
방코드에 맞게 구매한 플레이어 id(인덱스)값, 구매한 건물의 인덱스 값, 구매한 건물의 가격을 전달합니다. 이 때 건물을 구매한 플레이어를 제외한 모든 플레이어들에게 데이터가 전달됩니다.
socket.on('majorCardPurchase', (data)=>{ ... }
방코드에 맞게 구매한 플레이어 id(인덱스)값, 구매한 건물의 인덱스 값, 구매한 건물의 가격, 구매한 플레이어의 이름을 전달합니다. 이 때 건물을 구매한 플레이어를 제외한 모든 플레이어들에게 데이터가 전달됩니다.
플레이어의 이름을 전달하는 이유는 게임이 끝났을 때 승리 플레이어의 닉네임을 대화상자에 표시하기 위함입니다. 주요 건물을 모두 구매하는 것이 승리 조건임으로 해당 코드에 이 내용을 추가하였습니다.
socket.on('gameWon', (data) => { ... }
누군가 승리하여 게임이 끝났을 때 해당 방코드를 삭제하기 위한 코드입니다.
void rollDice(String roomCode, int numberOfDiceToRoll, int dice1Result, int dice2Result) {
socket.emit('rollDice', {
'roomCode': roomCode,
'numberOfDiceToRoll': numberOfDiceToRoll,
'dice1Result': dice1Result,
'dice2Result': dice2Result});
}
void onDiceRolled(Function callback) {
socket.on('diceRolled', (diceResult) => callback(diceResult));
}
void extraRollDice(bool shouldRollAgain, int numberOfDiceToRoll, int dice1Result, int dice2Result){
socket.emit('extraRollDice', {
'roomCode': nowRoomCode,
'shouldRollAgain': shouldRollAgain,
'numberOfDiceToRoll': numberOfDiceToRoll,
'dice1Result': dice1Result,
'dice2Result':dice1Result});
}
void onExtraDiceRolled(Function callback) {
socket.on('extraDiceRolled', (data) => callback(data));
}
void nextTurn(){
socket.emit('nextTurn', {'roomCode': nowRoomCode});
}
void onNextTurned(Function callback) {
socket.on('doNextTurn', (data) => callback(data));
}
void centerCardPurchase(int purchasePlayerId, int buildingIndex, int buildingCost){
// 방코드, 구매한 플레이어 id, 건물의 종류, 건물의 가격 날리기
socket.emit('centerCardPurchase',{
'roomCode': nowRoomCode,
'purchasePlayerId' : purchasePlayerId,
'buildingIndex' :buildingIndex,
'buildingCost' : buildingCost
});
}
void onCenterCardPurchased(Function callback) {
socket.on('centerCardPurchased', (data) => callback(data));
}
void majorCardPurchase(int purchasePlayerId, int buildingIndex, int buildingCost){
// 방코드, 구매한 플레이어 id, 건물의 종류, 건물의 가격 날리기
socket.emit('majorCardPurchase',{
'roomCode': nowRoomCode,
'purchasePlayerId' : purchasePlayerId,
'buildingIndex' :buildingIndex,
'buildingCost' : buildingCost
});
}
void onMajorCardPurchased(Function callback) {
socket.on('majorCardPurchased', (data) => callback(data));
}
해당 코드를 통해 서버와 통신을 주고 받습니다. 앞 포스팅에서도 설명했듯이 emit이 전송, on이 수신입니다. 앞서 말했듯이 게임 로직에서 서버의 역할은 적절한 데이터만 잘 주고 받으면 되기 때문에 복잡한 코드는 필요 없습니다.
WidgetsBinding.instance!.addPostFrameCallback((_) {
socketService.onGameStarted((data) {
...
});
socketService.onRoomCreated((data) {
...
});
socketService.onRoomJoined((data) {
...
});
socketService.onDiceRolled((data) {
String roomCode = data['roomCode'];
socketDiceNumber1 = data['dice1Result'];
socketDiceNumber2 = data['dice2Result'];
numberOfDiceToRoll = data['numberOfDiceToRoll'];
Future.delayed(Duration(milliseconds: 100), () {
game.extraTurn = false;
if (roomCode == socketService.nowRoomCode) {
socketDiceResult = socketDiceNumber1 +
(numberOfDiceToRoll == 2 ? socketDiceNumber2 : 0);
if (game.players[game.currentPlayerIndex].majorBuildings[2]
.isActive &&
socketDiceNumber1 == socketDiceNumber2 &&
numberOfDiceToRoll == 2) {
game.extraTurn = true;
}
// 주사위 애니메이션 실행
_controller.forward();
}
});
});
socketService.onNextTurned((data) {
String roomCode = data['roomCode'];
Future.delayed(Duration(milliseconds: 100), () {
if (roomCode == socketService.nowRoomCode) {
game.socketNextTurn();
setState(() {});
}
});
});
socketService.onCenterCardPurchased((data) {
String roomCode = data['roomCode'];
int purchasePlayerId = data['purchasePlayerId'];
int buildingIndex = data['buildingIndex'];
int buildingCost = data['buildingCost'];
Future.delayed(Duration(milliseconds: 100), () {
if (roomCode == socketService.nowRoomCode) {
String socketSelectedCard;
game.players[purchasePlayerId].centerBuildings
.add(game.centerCards[buildingIndex]);
game.players[purchasePlayerId].money -= buildingCost;
game.centerCards[buildingIndex].availableCount -= 1;
socketSelectedCard = game.centerCards[buildingIndex].imagePath;
cardCounts[socketSelectedCard!] =
game.centerCards[buildingIndex].availableCount;
setState(() {});
}
});
});
socketService.onMajorCardPurchased((data) {
String roomCode = data['roomCode'];
int purchasePlayerId = data['purchasePlayerId'];
int buildingIndex = data['buildingIndex'];
int buildingCost = data['buildingCost'];
List<dynamic> playerNames = data['playerNames'];
Future.delayed(Duration(milliseconds: 100), () {
if (roomCode == socketService.nowRoomCode) {
game.players[purchasePlayerId].majorBuildings[buildingIndex]
.isActive = true;
game.players[purchasePlayerId].money -= buildingCost;
int victoryCount = 0;
for (int i = 0;
i < game.players[purchasePlayerId].majorBuildings.length;
i++) {
if (game.players[purchasePlayerId].majorBuildings[i].isActive) {
victoryCount += 1;
}
}
if (victoryCount >= 4) {
_showVictoryDialog(
context, "${playerNames[purchasePlayerId]}이(가) 승리했어요!");
socketService.socket.off('roomCreated');
socketService.socket.off('roomJoined');
socketService.socket.disconnect();
}
}
});
});
socketService.onExtraDiceRolled((data) {
String roomCode = data['roomCode'];
bool shouldRollAgain = data['shouldRollAgain'];
int extraNumberOfDiceToRoll = data['numberOfDiceToRoll'];
int extraDice1Result = data['dice1Result'];
int extraDice2Result = data['dice2Result'];
int extraDiceResult;
print("더굴릴지 말지 정보 받음");
Future.delayed(Duration(milliseconds: 100), () {
if (roomCode == socketService.nowRoomCode) {
if (shouldRollAgain) {
extraDiceResult = extraDice1Result +
(extraNumberOfDiceToRoll == 2 ? extraDice2Result : 0);
// 주사위 확정시 주사위 효과 적용
print("정보 받음 + 주사위 확정");
game.rollDice(extraDiceResult);
game.rollDiceStatus = true;
game.extraDice = true;
if (game.players[game.currentPlayerIndex].majorBuildings[2]
.isActive &&
extraDice1Result == extraDice2Result &&
extraNumberOfDiceToRoll == 2) {
game.extraTurn = true;
_showCustomDialog(context, "한턴을 추가로 진행할 수 있어요!");
}
} else {
print("정보 받음 + 주사위 확정안함");
}
}
setState(() {});
});
});
});
해당 코드는 서버로부터 데이터를 받았을 때 실행되는 코드입니다. 서버가 보낸 데이터에 맞게 게임 로직을 수행하는 역할을 합니다. 게임 로직은 기존의 게임 로직과 같은 로직이기에 설명은 생략하겠습니다.
_controller.addStatusListener((status) async {
if (status == AnimationStatus.completed) {
_controller.reverse();
} else if (status == AnimationStatus.dismissed) {
setState(() {
diceNumber1 = socketDiceNumber1;
diceNumber2 = socketDiceNumber2;
diceResult = diceNumber1 + (numberOfDiceToRoll == 2 ? diceNumber2 : 0);
}); // UI 갱신
if (!game.players[game.currentPlayerIndex].majorBuildings[3].isActive) {
// 라디오 방송국 비활성화 시 바로 주사위 이벤트 진행
game.rollDice(socketDiceResult);
game.rollDiceStatus = true;
extraTurnEvent();
} else if (game.players[game.currentPlayerIndex].majorBuildings[3].isActive &&
game.extraDice) {
// 주사위 다시 굴릴것 인지 물어보는 코드
game.extraDice = false;
if (game.currentPlayerIndex == socketService.myPlayerId) {
bool shouldRollAgain = await _showDiceRollConfirmationDialog(context);
if (shouldRollAgain) {
// 주사위 확정시 주사위 효과 적용
socketService.extraRollDice(shouldRollAgain, numberOfDiceToRoll,
diceNumber1, diceNumber2);
print("주사위 확정");
game.rollDice(socketDiceResult);
game.rollDiceStatus = true;
game.extraDice = true;
extraTurnEvent();
}
}
} else {
// 주사위 확정 안했을 시 추가로 진행한 주사위 이벤트 적용
print("주사위 두번 굴리고 보상 얻음");
game.rollDice(socketDiceResult);
game.rollDiceStatus = true;
game.extraDice = true;
extraTurnEvent();
}
setState(() {});
}
});
주사위 결과는 미리 확정하되 주사위 애니매이션이 끝난 뒤에야 결과를 표시하도록 코드를 수정했습니다. 주사위 결과를 미리 확정시킨 뒤에 서버와 통신을 해야 매끄럽게 통신이 진행됩니다. 해당 코드는 initState()
코드 안에 위치하고 있습니다.
Expanded(
flex: 1,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[mySocketIdIndex].id, 0),
),
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[mySocketIdIndex].id, 1),
),
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[mySocketIdIndex].id, 2),
),
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[mySocketIdIndex].id, 3),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
flex: 9,
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Expanded(
flex: 9,
child: FittedBox(
child: Icon(Icons.attach_money),
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
Expanded(
flex: 9,
child: AutoSizeText(
'${game.players[mySocketIdIndex].money}원',
minFontSize: 5,
style: TextStyle(fontSize: 20.0),
// 시작할 폰트 크기
maxLines: 1, // 최대 줄 수
),
),
],
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
Expanded(
flex: 9,
child: ElevatedButton(
onPressed: () {
_showPlayerBuildingStatusDialog(context,
game.players[mySocketIdIndex].id);
},
child: AutoSizeText(
'건물 현황',
minFontSize: 5,
style: TextStyle(fontSize: 15.0),
// 시작할 폰트 크기
maxLines: 1, // 최대 줄 수
),
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
Expanded(
flex: 9,
child: ElevatedButton(
onPressed: () {
// 건물 구매 안하고 턴넘기기
setState(() {
// 내 차레라는 뜻
if (game.currentPlayerIndex ==
socketService.myPlayerId) {
if (game.rollDiceStatus) {
socketService.nextTurn();
game.nextTurn();
} else {
_showCustomDialog(
context, "주사위를 먼저 굴리십시오.");
}
} else {
_showCustomDialog(
context, "내 차례가 아니에요!");
}
});
},
child: AutoSizeText(
'턴 넘기기',
minFontSize: 5,
style: TextStyle(fontSize: 15.0),
// 시작할 폰트 크기
maxLines: 1, // 최대 줄 수
),
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
],
),
),
],
),
),
기존에는 현재 플레이어의 정보를 보여주었지만 이제는 각각의 플레이어의 정보를 보여주도록 수정했습니다. 이제 각각의 화면이 플레이 상황에 따라 다르게 표시될 것입니다.
void diceEvent(int numberOfDice) {
if (game.currentPlayerIndex == socketService.myPlayerId) {
if (!game.rollDiceStatus) {
if (!game.players[game.currentPlayerIndex].majorBuildings[0].isActive &&
numberOfDice == 2) {
_showCustomDialog(context, "기차역을 먼저 구매해야 합니다.");
} else {
setState(() {
diceResult = 0;
socketDiceResult = 0;
numberOfDiceToRoll = numberOfDice;
});
_controller.forward();
socketDiceNumber1 = Random().nextInt(6) + 1;
socketDiceNumber2 = Random().nextInt(6) + 1;
socketDiceResult = socketDiceNumber1 +
(numberOfDiceToRoll == 2 ? socketDiceNumber2 : 0);
socketService.rollDice(socketService.nowRoomCode, numberOfDiceToRoll,
socketDiceNumber1, socketDiceNumber2);
}
} else {
_showCustomDialog(context, "주사위를 이미 굴리셨습니다.");
}
} else {
_showCustomDialog(context, "내 차례가 아니에요!");
}
}
주사위를 굴릴때마다 주사위 정보를 서버로 보내도록 주사위 로직을 수정했습니다. 서버가 주사위 정보를 받았다면 해당 정보를 주사위를 굴린 플레이어를 제외한 모든 플레이어게 전달하여 각각의 기기에서 주사위 로직을 실행할 수 있도록 할 것입니다. 추가로 나의 턴이 아니라면 주사위를 굴리지 못하도록 기능을 구현하였습니다.
onPurchase: () {
setState(() {
// 내 차레라는 뜻
if (game.currentPlayerIndex ==
socketService.myPlayerId) {
if (game.rollDiceStatus) {
if (selectedCardPath != null &&
selectedCardPath!.startsWith('assets/center_card')) {
CenterBuildingCard selectedCard = game.centerCards.firstWhere((card) =>
card.imagePath == selectedCardPath);
int selectedIndex = game.centerCards.indexOf(selectedCard);
if (selectedCard.availableCount > 0) {
if (selectedCard.cost <=
game.players[game.currentPlayerIndex]
.money) {
selectedCard.availableCount -= 1;
cardCounts[selectedCardPath!] =
selectedCard.availableCount;
game.players[game.currentPlayerIndex]
.centerBuildings
.add(game.centerCards.firstWhere(
(card) =>
card.imagePath ==
selectedCardPath));
game.players[game.currentPlayerIndex]
.money -= selectedCard.cost;
socketService.centerCardPurchase(
socketService.myPlayerId,
selectedIndex,
selectedCard.cost);
socketService.nextTurn();
game.nextTurn();
} else if (selectedCard.cost >
game.players[game.currentPlayerIndex]
.money) {
_showCustomDialog(context, "돈이 모자라요!");
}
} else if (selectedCard.availableCount <= 0) {
_showCustomDialog(context, "카드가 이미 다 팔렸어요!");
}
} else if (selectedCardPath != null &&
selectedCardPath!
.startsWith('assets/major_card')) {
int selectedIndex = game.majorCards.indexWhere(
(card) =>
card.backImagePath ==
selectedCardPath ||
card.frontImagePath ==
selectedCardPath);
if (!game.players[game.currentPlayerIndex]
.majorBuildings[selectedIndex].isActive) {
if (game.majorCards[selectedIndex].cost <=
game.players[game.currentPlayerIndex]
.money) {
game.players[game.currentPlayerIndex]
.money -=
game.majorCards[selectedIndex].cost;
game
.players[game.currentPlayerIndex]
.majorBuildings[selectedIndex]
.isActive = true;
socketService.majorCardPurchase(
socketService.myPlayerId,
selectedIndex,
game.majorCards[selectedIndex].cost);
int victoryCount = 0;
for (int i = 0;
i <
game
.players[
game.currentPlayerIndex]
.majorBuildings
.length;
i++) {
if (game.players[game.currentPlayerIndex]
.majorBuildings[i].isActive) {
victoryCount += 1;
}
}
if (victoryCount >= 4) {
_showVictoryDialog(context,
"${playerName[game.currentPlayerIndex]}이(가) 승리했어요!");
socketService.gameFinish();
socketService.socket.off('roomCreated');
socketService.socket.off('roomJoined');
}
socketService.nextTurn();
game.nextTurn();
} else if (game
.majorCards[selectedIndex].cost >
game.players[game.currentPlayerIndex]
.money) {
// 돈이 모자라서 못산다는 메세지 띄우기
_showCustomDialog(context, "돈이 모자라요!");
}
} else if (game.players[game.currentPlayerIndex]
.majorBuildings[selectedIndex].isActive) {
// 이미 구매한 건물이라는 메세지 띄우기
_showCustomDialog(
context, "이미 구매한 주요 건물이에요!");
}
}
} else {
_showCustomDialog(context, "주사위를 먼저 굴리십시오.");
}
} else {
_showCustomDialog(context, "내 차례가 아니에요!");
}
});
},
건물 구매 이벤트를 서버로 전송하도록 코드를 수정했습니다. 건물 구매 또한 나의 턴이 아닐때는 건물 구매를 하지 못하도록 기능을 구현했습니다.
ElevatedButton(
onPressed: () {
// 건물 구매 안하고 턴넘기기
setState(() {
// 내 차레라는 뜻
if (game.currentPlayerIndex ==
socketService.myPlayerId) {
if (game.rollDiceStatus) {
socketService.nextTurn();
game.nextTurn();
} else {
_showCustomDialog(
context, "주사위를 먼저 굴리십시오.");
}
} else {
_showCustomDialog(
context, "내 차례가 아니에요!");
}
});
},
child: AutoSizeText(
'턴 넘기기',
minFontSize: 5,
style: TextStyle(fontSize: 15.0),
// 시작할 폰트 크기
maxLines: 1, // 최대 줄 수
),
),
턴 넘기기 버튼 또한 내 차례가 아니라면 누를 수 없도록 수정해야 합니다.
이제 플러터 미니빌 게임 구현이 끝났습니다!! 다음 포스팅은 마지막 포스팅으로 서버 배포하기, 안드로이드 및 IOS에서 실행하는 방법에 대해서 설명드리겠습니다. 궁금한 점은 댓글로 남겨주세요😎