[Flutter] Socket.io를 이용한 통신 보드게임 어플 만들기 (게임 서버 구현-2)

강민석·2023년 10월 21일
0

✨방코드 및 게임시작 구현하기


방을 생성하면 랜덤적인 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);
    }
});

클라이언트로부터 게임을 시작하라는 요청을 받으면 실행되는 코드입니다. 방코드를 데이터로 받아오고 방코드에 맞게 방에 있는 인원들 모두에게 게임 시작 이벤트를 클라이언트로 보냅니다.

💻클라이언트측 코드(socketService.dart)

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파일의 dependenciessocket_io_client를 추가해줘야 합니다. 버전은 공식문서에서 참고해주세요.

서버측 코드와 비슷하게 이벤트와 데이터를 요청하고 수신하는 코드입니다. 해당 코드를 토대로 main.dart에서 어떻게 사용해야할지 알려드리겠습니다.

💻클라이언트측 코드(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를 추가하여 뒤로가기를 통해 게임을 종료하지 못하도록 수정하였습니다. 게임이 시작되지 않았을 때는 뒤로가기를 통해 메인 페이지로 돌아갈 수 있어야 하기 때문에 조건문을 추가해주었습니다.



위 포스팅 내용을 모두 정상적으로 적용했다면 위 움짤과 같이 방장이 게임시작 버튼을 눌렀을때 방에 참여한 모든 플레이어의 게임 대기창이 없어져야 합니다.



만약 오류가 고쳐지지 않거나 지적할만한 사항이 있다면 댓글 남겨주세요😎

profile
백석대학교 소프트웨어학과 4학년 재학중

0개의 댓글

관련 채용 정보