방을 생성하면 랜덤적인 6자리 방코드를 생성합니다. 해당 방코드를 입력하면 게임에 참여할 수 있습니다. 그리고 방장이 게임시작을 눌렀을때 방코드에 들어와있는 모든 유저들이 게임시작이 될 수 있도록 기능을 구현할 것입니다.
const express = require('express');
const cors = require('cors');
const app = express();
const http = require('http').createServer(app);
const io = require('socket.io')(http, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
// CORS 설정
app.use(cors());
// Sample route
app.get('/', (req, res) => {
res.send('<h1>Socket.io Server is running</h1>');
});
let rooms = {};
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('createRoom', (data) => {
const hostName = data.hostName;
const numOfPlayer = data.numOfPlayer;
let roomCode = generateRoomCode(); // 6자리 방 코드 생성 함수
rooms[roomCode] = { host: socket.id,
players: [socket.id],
playerName: [hostName],
numOfPlayer: numOfPlayer };
socket.join(roomCode);
socket.emit('roomCreated', { roomCode,
playerId: 0,
socketID: socket.id,
playerNames: rooms[roomCode].playerName });
console.log('room Created!:', roomCode);
console.log('All existing room codes:', Object.keys(rooms).join(', '));
});
socket.on('joinRoom', (data) => {
const roomCode = data.roomCode;
const userName = data.userName;
if (rooms[roomCode]) {
rooms[roomCode].players.push(socket.id);
rooms[roomCode].playerName.push(userName);
let playerId = rooms[roomCode].players.length - 1;
socket.join(roomCode);
io.to(data.roomCode)
.emit('roomJoined', { roomCode,
playerId,
socketID: socket.id,
playerNames: rooms[roomCode].playerName });
console.log('room Joined!:', roomCode, 'playerID:',playerId);
} else {
socket.emit('error', { message: 'Room not found!' });
}
});
socket.on('gameStart', (data) => {
const roomCode = data.roomCode;
if (rooms[roomCode]) {
const playersInRoom = rooms[roomCode].players;
const numOfPlayer = rooms[roomCode].numOfPlayer;
const playerNames = rooms[roomCode].playerName;
const payload = {
roomCode: roomCode,
players: playersInRoom,
playerNames: playerNames,
numOfPlayer: numOfPlayer
};
console.log('game Started!:', roomCode);
io.to(roomCode).emit('gameStarted', payload);
} else {
console.log('Room not found:', roomCode);
}
});
socket.on("roomQuit", (data) => {
const roomCode = data.roomCode;
const socketID = data.socketID;
// 플레이어를 방의 플레이어 목록에서 제거
for(let i=0;i<rooms[roomCode].players.length;i++){
console.log(rooms[roomCode].players[i]);
if(rooms[roomCode].players[i] === socketID){
rooms[roomCode].players.splice(i, 1);
}
}
//가장 마지막에 들어온 사람이 나갔다가 들어오는 것은 문제 X
//하지만 이미 들어와있던 사람이 나갔다가 들어오면 playerID가 꼬임
//playerID를 재조정하고 클라이언트에 업데이트 해주는 코드를 추가해줘야함
});
socket.on('disconnect', () => {
console.log('user disconnected');
});
});
// Start the Express server
const PORT = process.env.PORT || 3000;
http.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
function generateRoomCode() {
let roomCode;
do {
roomCode = Math.floor(100000 + Math.random() * 900000).toString();
} while (rooms[roomCode]); // 방코드가 이미 존재하면 다시 생성
return roomCode;
}
socket.on('event', (data) => {...})
코드는 클라이언트로부터 특정 이벤트와 데이터를 수신할 수 있습니다.
socket.emit('event', data)
코드는 클라이언트에게 특정 이벤트와 데이터를 전송할 수 있습니다.
해당 코드는 클라이언트로부터 방 생성, 방 참여, 게임시작 요청을 받으면 요청에 맞게 이벤트를 실행시키고 데이터들을 다시 클라이언트로 보내는 코드입니다.
우선 방코드를 어떻게 관리하는지에 대해서 설명하겠습니다. rooms라는 객체를 초기화하여 사용중인 모든 방의 정보를 저장합니다. 여기서 방 코드는 rooms 객체의 키로 사용되며, 해당 방의 상세 정보 (호스트, 플레이어 목록, 플레이어 이름, 플레이어 수)가 값으로 저장됩니다.
socket.on('createRoom', (data) => {
const hostName = data.hostName;
const numOfPlayer = data.numOfPlayer;
let roomCode = generateRoomCode(); // 6자리 방 코드 생성 함수
rooms[roomCode] = { host: socket.id,
players: [socket.id],
playerName: [hostName],
numOfPlayer: numOfPlayer };
socket.join(roomCode);
socket.emit('roomCreated', { roomCode,
playerId: 0,
socketID: socket.id,
playerNames: rooms[roomCode].playerName });
console.log('room Created!:', roomCode);
console.log('All existing room codes:', Object.keys(rooms).join(', '));
});
클라이언트로부터 방을 생성하라는 요청을 받으면 해당 내용이 실행됩니다. 방장이 설정한 이름, 방 인원수를 데이터를 받고 해당 데이터를 바탕으로 방을 생성합니다. 방코드는 6자리 난수를 생성하는 함수를 이용하여 설정합니다. 그리고 방코드를 키로 사용하여 rooms 객체에 방 정보를 저장합니다. 그렇게 방이 만들어졌다면 클라이언트에 방이 만들어졌다고 데이터와 함께 이벤트를 보냅니다.
socket.emit('event',{ data })
코드를 사용하면 요청을 날린 클라이언트에게 이벤트를 되돌려줄 수 있습니다.
socket.on('joinRoom', (data) => {
const roomCode = data.roomCode;
const userName = data.userName;
if (rooms[roomCode]) {
rooms[roomCode].players.push(socket.id);
rooms[roomCode].playerName.push(userName);
let playerId = rooms[roomCode].players.length - 1;
socket.join(roomCode);
io.to(data.roomCode)
.emit('roomJoined', { roomCode,
playerId,
socketID: socket.id,
playerNames: rooms[roomCode].playerName });
console.log('room Joined!:', roomCode, 'playerID:',playerId);
} else {
socket.emit('error', { message: 'Room not found!' });
}
});
방을 참여할때는 클라이언트로부터 입력한 방코드와 유저 이름을 데이터로 받아옵니다. 받아온 방코드를 rooms의 key로 활용하여 방에 참여합니다. 여기서 playerId
는 게임 로직의 currentPlayerID
와 같이 활용하여 게임 순서나 주사위 로직으로써 활용됩니다.
currentPlayerID
가 0이라면 playerId
가 0인 사람의 차례,
currentPlayerID
가 1이라면 playerId
가 1인 사람의 차례. 이런식으로 말이죠.
그리고 방에 플레이어가 들어오면 게임 레이아웃의 waiting...
부분을 들어온 플레이어의 이름으로 바꾸어야 플레이어가 들어왔는지 안들어왔는지 구분할 수 있기 때문에 방에 들어온 모든 인원에게 방참여 이벤트를 보냅니다.
io.to(data.roomCode).emit('event',{ data })
코드를 사용하면 방코드에 참여한 모든 플레이어들에게 이벤트를 보낼 수 있습니다.
socket.on('gameStart', (data) => {
const roomCode = data.roomCode;
if (rooms[roomCode]) {
const playersInRoom = rooms[roomCode].players;
const numOfPlayer = rooms[roomCode].numOfPlayer;
const playerNames = rooms[roomCode].playerName;
const payload = {
roomCode: roomCode,
players: playersInRoom,
playerNames: playerNames,
numOfPlayer: numOfPlayer
};
console.log('game Started!:', roomCode);
io.to(roomCode).emit('gameStarted', payload);
} else {
console.log('Room not found:', roomCode);
}
});
클라이언트로부터 게임을 시작하라는 요청을 받으면 실행되는 코드입니다. 방코드를 데이터로 받아오고 방코드에 맞게 방에 있는 인원들 모두에게 게임 시작 이벤트를 클라이언트로 보냅니다.
import 'package:socket_io_client/socket_io_client.dart' as IO;
class SocketService {
late IO.Socket socket;
late int myPlayerId;
late String mySocketID;
bool gameStarted= false;
late String nowRoomCode="";
late String myPlayerName;
late int numOfPlayer;
SocketService(bool createJoin, String roomCode, String playerName, int numOfPlayer) {
socket = IO.io('https://localhost:3000',
IO.OptionBuilder()
.setTransports(['websocket'])
.disableAutoConnect()
.build());
socket.connect();
socket.on('connect_error', (error) => print('Connect error: $error'));
}
// 게임시작 요청
void gameStart(){
socket.emit('gameStart', {'roomCode': nowRoomCode});
}
// 방 생성 요청
void createRoom(String hostName, int numOfPlayer) {
socket.emit('createRoom', {'hostName': hostName, 'numOfPlayer': numOfPlayer});
socket.on('roomCreated', (data) {
print('Room created with code: ${data['roomCode']}');
print('Your player ID: ${data['playerId']}');
myPlayerId = data['playerId'];
nowRoomCode = data['roomCode'];
mySocketID = data['socketID'];
});
}
// 방에 참여 요청
void joinRoom(String roomCode, String userName) {
socket.emit('joinRoom', {'roomCode': roomCode, 'userName': userName});
socket.on('roomJoined', (data) {
print('Joined room with code: ${data['roomCode']}');
print('Your player ID: ${data['playerId']}');
myPlayerId = data['playerId'];
nowRoomCode = data['roomCode'];
mySocketID = data['socketID'];
});
}
// 게임 시작 이벤트 수신
void onGameStarted(Function callback) {
socket.on('gameStarted', (data) => callback(data));
}
// 방생성 이벤트 수신
void onRoomCreated(Function callback) {
socket.on('roomCreated', (data) => callback(data));
}
// 방참여 이벤트 수신
void onRoomJoined(Function callback) {
socket.on('roomJoined', (data) => callback(data));
}
}
lib폴더 안에 서버와 통신하기 위한 클래스를 하나 만들어준뒤 해당 코드를 사용해주세요. 해당 내용을 사용하기 위해선 pubspec.yaml
파일의 dependencies
에 socket_io_client
를 추가해줘야 합니다. 버전은 공식문서에서 참고해주세요.
서버측 코드와 비슷하게 이벤트와 데이터를 요청하고 수신하는 코드입니다. 해당 코드를 토대로 main.dart
에서 어떻게 사용해야할지 알려드리겠습니다.
class _MainScreenState extends State<MainScreen> {
...
child: TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: '이름',
),
onChanged: (String) => userName = String,
),
...
child: TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: '방 코드',
),
onChanged: (String) => roomCode = String
),
...
child: ElevatedButton(
child: AutoSizeText(
'방 만들기',
minFontSize: 5,
style: TextStyle(fontSize: 20.0),
maxLines: 1, // 최대 줄 수
),
onPressed: () {
createJoin = true;
Navigator.push(
context, MaterialPageRoute(
builder: (context)
=> GameScreen(
numOfPlayers: numOfPlayers,
createJoin: createJoin,
roomCode: roomCode,
userName: userName),
),
);
},
),
...
child: ElevatedButton(
child: AutoSizeText(
'참가하기',
minFontSize: 5,
style: TextStyle(fontSize: 20.0),
maxLines: 1,
),
onPressed: () {
createJoin = false;
Navigator.push(
context, MaterialPageRoute(
builder: (context)
=> GameScreen(
numOfPlayers: numOfPlayers,
createJoin: createJoin,
roomCode: roomCode,
userName: userName),
),
);
},
),
...
}
class GameScreen extends StatefulWidget {
final int numOfPlayers;
final bool createJoin;
final String roomCode;
final String userName;
GameScreen(
{required this.numOfPlayers,
required this.createJoin,
required this.roomCode,
required this.userName});
_GameScreenState createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
late AnimationController _controller;
late Animation<double> _animation;
// 주사위 이미지를 위한 상태를 추가합니다.
int diceNumber1 = 1;
int diceNumber2 = 1;
int numberOfDiceToRoll = 1;
int diceResult = 0;
int socketDiceResult = 0;
int socketDiceNumber1 = 0;
int socketDiceNumber2 = 0;
int mySocketIdIndex = 0;
// 게임 설정
String? selectedCardPath;
Map<String, int> cardCounts = {};
late MiniVillGame game;
late SocketService socketService;
List<String> playerName = [
'waiting..',
'waiting..',
'waiting..',
'waiting..'
];
//widget.createJoin == true라면 방 만들기
//widget.createJoin == false라면 방 참가
//입력한 방 코드는 widget.roomCode
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
socketService = SocketService(widget.createJoin, widget.roomCode,
widget.userName, widget.numOfPlayers);
if (widget.createJoin) {
socketService.createRoom(widget.userName, widget.numOfPlayers);
} else {
socketService.joinRoom(widget.roomCode, widget.userName);
}
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance!.addPostFrameCallback((_) {
socketService.onGameStarted((data) {
String roomCode = data['roomCode'];
List<dynamic> players = data['players'];
List<dynamic> playerNames = data['playerNames'];
int numOfPlayer = data['numOfPlayer'];
Future.delayed(Duration(milliseconds: 500), () {
if (roomCode == socketService.nowRoomCode) {
for (int i = 0; i < playerNames.length; i++) {
print("유저이름: ${playerNames[i]}");
playerName[i] = playerNames[i];
}
print("방 인원수: $numOfPlayer");
Navigator.pop(context);
}
});
});
socketService.onRoomCreated((data) {
List<dynamic> playerNames = data['playerNames'];
Future.delayed(Duration(milliseconds: 100), () {
_showWaitingDialog(context);
mySocketIdIndex = socketService.myPlayerId;
for (int i = 0; i < playerNames.length; i++) {
playerName[i] = playerNames[i];
}
setState(() {});
});
});
socketService.onRoomJoined((data) {
List<dynamic> playerNames = data['playerNames'];
Future.delayed(Duration(milliseconds: 100), () {
if (socketService.myPlayerId == data['playerId']) {
_showWaitingDialog(context);
}
mySocketIdIndex = socketService.myPlayerId;
for (int i = 0; i < playerNames.length; i++) {
playerName[i] = playerNames[i];
}
setState(() {});
});
});
});
...
game = MiniVillGame(widget.numOfPlayers);
cardCounts = Map.fromIterable(
game.centerCards,
key: (item) => item.imagePath,
value: (item) => item.availableCount,
);
}
...
_showWaitingDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.black45,
builder: (context) {
return WillPopScope(
onWillPop: () async {
if (socketService.myPlayerId == 0) {
socketService.gameFinish();
}
socketService.socket.off('roomCreated');
socketService.socket.off('roomJoined');
Navigator.of(context).popUntil((route) => route.isFirst);
return false;
},
child: AlertDialog(
title: Text('게임 대기 중...'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('방코드: ${socketService.nowRoomCode}'),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () {
// 방장일 경우에만 게임 시작 가능
if (socketService.myPlayerId == 0) {
socketService.gameStart();
}
},
child: Text('게임 시작'),
),
ElevatedButton(
onPressed: () {
if (socketService.myPlayerId == 0) {
socketService.gameFinish();
}
socketService.socket.off('roomCreated');
socketService.socket.off('roomJoined');
Navigator.of(context)
.popUntil((route) => route.isFirst);
},
child: Text('돌아가기'),
),
],
),
],
),
),
);
},
);
}
Widget build(BuildContext context) {
...
return WillPopScope(
onWillPop: () async {
if (socketService.gameStarted) {
return Future.value(false);
} else {
return Future.value(true);
}
},
child: Scaffold(
...
),
);
}
}
우선적으로 메인페이지에서 유저 이름, 인원수, 방 생성 및 참여 여부에 대한 값을 받아옵니다. 그리고 그 값을 이용하여 socketService
생성자를 실행하고 방 생성 버튼을 눌렀다면 서버에 방 생성을 요청하고 참가하기 버튼을 눌렀다면 서버에 방 참여를 요청합니다.
서버로부터 이벤트를 받아오고 나서 그 이벤트를 실행하려면 콜백함수를 등록해야합니다. 그러기 위해선 위젯에 with WidgetsBindingObserver
을 사용하고 WidgetsBinding.instance.addObserver(this);
를 initState()
에서 호출하고 WidgetsBinding.instance!.addPostFrameCallback((_)
를 이용해 콜백함수를 등록합니다.
initState
에서 WidgetsBinding.instance!.addPostFrameCallback((_))
를 사용하는 이유는, 위젯 트리가 처음 구성되면서 초기 상태 설정이 끝난 직후, 즉 첫 프레임이 그려진 직후에 어떤 작업을 실행하기 위함입니다.
이렇게 코드를 구성하면 초기 프레임이 그려진 직후에 각 메서드는 서버로부터의 특정 이벤트를 수신 대기하고, 이벤트가 발생하면 등록된 콜백 함수를 실행합니다.
추가로 _showWaitingDialog
를 수정하여 게임 시작 버튼을 누르면 게임 시작 이벤트를 서버로 전송할 수 있도록 하고 게임 시작 버튼은 방장만 누를 수 있도록 하였습니다.
+) WillPopScope
를 추가하여 뒤로가기를 통해 게임을 종료하지 못하도록 수정하였습니다. 게임이 시작되지 않았을 때는 뒤로가기를 통해 메인 페이지로 돌아갈 수 있어야 하기 때문에 조건문을 추가해주었습니다.
위 포스팅 내용을 모두 정상적으로 적용했다면 위 움짤과 같이 방장이 게임시작 버튼을 눌렀을때 방에 참여한 모든 플레이어의 게임 대기창이 없어져야 합니다.
만약 오류가 고쳐지지 않거나 지적할만한 사항이 있다면 댓글 남겨주세요😎