본문 바로가기
Development/C#

멋쟁이사자처럼부트캠프 Unity 게임 개발 3기 68일차

by Mobics 2025. 3. 6.

 

목차


    Tic Tac Toe 서버 만들기

    25.03.06

    지난 시간에 놓친 활동하기

    : Tic Tac Toe 자동 로그인 만들기

    • 점수 불러오기 기능과 로그인 기능을 이용해 자동 로그인 기능을 만들어 보세요.

    --> 한번 로그인을 하면 자동으로 로그인이 되지만, 아직 안한 상태라면 로그인 창이 뜨도록

     

    활동

    : TicTacToe 스코어 등록 및 리더보드 만들기

    • 싱글 플레이에서 게임에 승리하면 유저에게 10점 부여하세요 --> 현재 싱글 플레이에는 Minimax 알고리즘이 적용되어 절대 승리할 수 없기 때문에 2인 플레이에서 승리하면 유저에게 10점 부여하는 식으로 확인 가능
    • 전체 유저를 대상으로 랭킹을 보여주는 리더보드를 만드세요
    • 리더보드는 Scroll View를 이용해 만드세요
    • Scroll View에는 유저 닉네임 + 점수를 표시하세요
    • 서버에서 유저 랭킹을 보여주는 기능을 만드세요
    • 리더보드를 실행했을 때 자신의 점수를 바로 볼 수 있게 만들어주세요

    ※ Hint : 처음 로그인 했을 때 Cookie가 전달되고 Session 파일이 존재하면 Cookie가 전달되지 않을 것이기 때문에 로그인 했을 때 받은 Cookie 값을 저장해놓고 점수 저장과 불러오기를 구현하면 된다.

    ※ UnityWebRequest에서도 Cookie가 사용된다. 정확히는 게임 세션 동안 Cookie를 캐싱한다.

    --> UnityWebRequest.ClearCookieCache(); 를 사용하면 캐싱된 쿠키를 제거할 수 있다.

     

    리더보드 만들기

    >> 코드 작성

    • NetworkManager.cs
    • SigninPanelController.cs
    • app.js
    • leaderboard.js

     

    채팅 서버 만들기

    >> www 파일에 코드 추가

    var game = require('../game');  // 추가
    
    
    /**
     * 게임 서버 실행
     */
    game(server); // 추가

     

    >> game.js 생성 및 코드 작성

     

    >> 'Main Panel'의 자식으로 'Chatting Panel' 생성

    : Color는 임의로 정한 색

    --> 이름에 t 하나 빼먹음..

     

    >> Chating Panel의 자식으로 Scroll View 생성

    --> Anchor는 Alt + Shift

     

    >> Content

     

    >> Content의 자식으로 Text 추가

     

    >> Chating Panel의 자식으로 Input Field 생성

    --> Anchor는 Alt + Shift

     

    >> Placeholder와 Text의 Alignment를 Middle로 설정

     

    최종 코드

    >> NetworkManager.cs

    using System;
    using System.Collections;
    using UnityEngine;
    using UnityEngine.SceneManagement;
    using UnityEngine.Networking;
    
    public class NetworkManager : Singleton<NetworkManager>
    {
        protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
        {
            
        }
        
        public IEnumerator Signup(SignupData signupData, Action success, Action failure)
        {
            string jsonString = JsonUtility.ToJson(signupData);
            byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonString);
    
            // () 안의 객체가 {}를 벗어나면 Disposing되는 문법
            using (UnityWebRequest www =
                   new UnityWebRequest(Constants.ServerURL + "/users/signup", UnityWebRequest.kHttpVerbPOST))
            {
                www.uploadHandler = new UploadHandlerRaw(bodyRaw);
                www.downloadHandler = new DownloadHandlerBuffer();
                www.SetRequestHeader("Content-Type", "application/json");
                
                yield return www.SendWebRequest();
    
                if (www.result == UnityWebRequest.Result.ConnectionError || 
                    www.result == UnityWebRequest.Result.ProtocolError)
                {
                    Debug.Log("Error : " + www.error);
    
                    if (www.responseCode == 409) // 중복 사용자
                    {
                        // TODO: 중복 사용자 생성 팝업 표시
                        Debug.Log("중복 사용자");
                        GameManager.Instance.OpenConfirmPanel("이미 존재하는 사용자입니다.", () =>
                        {
                            failure?.Invoke();
                        });
                    }
                }
                else
                {
                    var result = www.downloadHandler.text;
                    Debug.Log("Result : " + result);
                    
                    // 회원가입 성공 팝업 표시
                    GameManager.Instance.OpenConfirmPanel("회원가입이 완료 되었습니다.", () =>
                    {
                        success?.Invoke();
                    });
                }
            }
        }
        
        public IEnumerator Signin(SigninData signinData, Action success, Action<int> failure)
        {
            string jsonString = JsonUtility.ToJson(signinData);
            byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonString);
    
            using (UnityWebRequest www =
                   new UnityWebRequest(Constants.ServerURL + "/users/signin", UnityWebRequest.kHttpVerbPOST))
            {
                www.uploadHandler = new UploadHandlerRaw(bodyRaw);
                www.downloadHandler = new DownloadHandlerBuffer();
                www.SetRequestHeader("Content-Type", "application/json");
                
                yield return www.SendWebRequest();
    
                if (www.result == UnityWebRequest.Result.ConnectionError ||
                    www.result == UnityWebRequest.Result.ProtocolError)
                {
                    
                }
                else
                {
                    // 점수 불러오기 기능을 구현하기 위해 cookie 값 저장 (수강생님 코드 참고)
                    var cookie = www.GetResponseHeader("set-cookie");
                    if (!string.IsNullOrEmpty(cookie))
                    {
                        //int lastIndex = cookie.LastIndexOf(";");
                        //string sid = cookie.Substring(0, lastIndex);
                        //PlayerPrefs.SetString("sid", sid);
                        
                        int lastIndex = cookie.IndexOf('='); // 첫 번째 '='의 위치 찾기
                        if (lastIndex != -1 && lastIndex + 1 < cookie.Length) // '='이 존재하고, 뒤에 값이 있을 경우
                        {
                            string sid = cookie.Substring(lastIndex + 1).Split(';')[0]; // '=' 다음부터 ';' 이전까지 추출
                            Debug.Log("Session ID : " + sid);
                            PlayerPrefs.SetString("sid", sid);
                        }
                        else
                        {
                            // 올바른 쿠키 형식이 아님
                        }
                    }
                    else
                    {
                        Debug.Log("쿠키가 비어있습니다.");
                    }
                    
                    var resultString = www.downloadHandler.text;
                    var result = JsonUtility.FromJson<SigninResult>(resultString);
    
                    if (result.result == 0)
                    {
                        // username이 유효하지 않음
                        GameManager.Instance.OpenConfirmPanel("Username이 유효하지 않습니다.", () =>
                        {
                            failure?.Invoke(0);
                        });
                    }
                    else if (result.result == 1)
                    {
                        // password가 유효하지 않음
                        GameManager.Instance.OpenConfirmPanel("Password가 유효하지 않습니다.", () =>
                        {
                            failure?.Invoke(1);
                        });
                    }
                    else if (result.result == 2)
                    {
                        // 성공
                        GameManager.Instance.OpenConfirmPanel("로그인에 성공하였습니다.", () =>
                        {
                            success?.Invoke();
                        });
                    }
                }
            }
        }
    
        public IEnumerator GetScore(Action<ScoreResult> success, Action failure)
        {
            using (UnityWebRequest www = 
                   new UnityWebRequest(Constants.ServerURL + "/users/score", UnityWebRequest.kHttpVerbGET))
            {
                www.downloadHandler = new DownloadHandlerBuffer();
                
                string sid = PlayerPrefs.GetString("sid", "");
                if (!string.IsNullOrEmpty(sid))
                {
                    www.SetRequestHeader("Cookie", sid);
                }
                
                yield return www.SendWebRequest();
    
                if (www.result == UnityWebRequest.Result.ConnectionError || 
                    www.result == UnityWebRequest.Result.ProtocolError)
                {
                    if (www.responseCode == 403)
                    {
                        Debug.Log("로그인이 필요합니다.");
                    }
                    
                    failure?.Invoke();
                }
                else
                {
                    var result = www.downloadHandler.text;
                    var userScore = JsonUtility.FromJson<ScoreResult>(result);
                    
                    Debug.Log(userScore.score);
                    
                    success?.Invoke(userScore);
                }
            }
        }
    
        public IEnumerator GetLeaderboard(Action<Scores> success, Action failure)
        {
            using (UnityWebRequest www = 
                   new UnityWebRequest(Constants.ServerURL + "/leaderboard", UnityWebRequest.kHttpVerbGET))
            {
                www.downloadHandler = new DownloadHandlerBuffer();
                
                string sid = PlayerPrefs.GetString("sid", "");
                if (!string.IsNullOrEmpty(sid))
                {
                    www.SetRequestHeader("Cookie", sid);
                }
                
                yield return www.SendWebRequest();
    
                if (www.result == UnityWebRequest.Result.ConnectionError || 
                    www.result == UnityWebRequest.Result.ProtocolError)
                {
                    if (www.responseCode == 403)
                    {
                        Debug.Log("로그인이 필요합니다.");
                    }
                    
                    failure?.Invoke();
                }
                else
                {
                    var result = www.downloadHandler.text;
                    var scores = JsonUtility.FromJson<Scores>(result);
                    
                    success?.Invoke(scores);
                }
            }
        }
    }

     

    >> SigninPanelController.cs

    using TMPro;
    using UnityEngine;
    
    public struct SigninData
    {
        public string username;
        public string password;
    }
    
    public struct SigninResult
    {
        public int result;
    }
    
    public struct ScoreResult
    {
        public string id;
        public string username;
        public string nickname;
        public int score;
    
    }
    
    public struct ScoreInfo
    {
        public string username;
        public string nickname;
        public int score;
    }
    
    public struct Scores
    {
        public ScoreInfo[] scores;
    }
    
    public class SigninPanelController : MonoBehaviour
    {
        [SerializeField] private TMP_InputField usernameInputField;
        [SerializeField] private TMP_InputField passwordInputField;
    
        public void OnClickSigninButton()
        {
            string username = usernameInputField.text;
            string password = passwordInputField.text;
    
            if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
            {
                // TODO: 누락된 값 입력 요청 팝업 표시
                return;
            }
    
            var signinData = new SigninData();
            signinData.username = username;
            signinData.password = password;
    
            StartCoroutine(NetworkManager.Instance.Signin(signinData, () =>
            {
                Destroy(gameObject);
            }, result =>
            {
                if (result == 0)
                {
                    usernameInputField.text = "";
                }
                else if (result == 1)
                {
                    passwordInputField.text = "";
                }
            }));
        }
    
        public void OnClickSignupButton()
        {
            GameManager.Instance.OpenSignupPanel();
        }
    }

     

    >> app.js

    var createError = require('http-errors');
    var express = require('express');
    var path = require('path');
    var cookieParser = require('cookie-parser');
    var logger = require('morgan');
    var mongodb = require('mongodb');       // 추가
    var MongoClient = mongodb.MongoClient;  // 추가

    var indexRouter = require('./routes/index');
    var usersRouter = require('./routes/users');
    var leaderboardRouter = require('./routes/leaderboard'); // 리더보드 구현
    const req = require('express/lib/request');
    const session = require('express-session');             // 추가
    var fileStore = require('session-file-store')(session); // 추가

    var app = express();

    // 로그인 만들기
    app.use(session({
      secret: process.env.SESSION_SECRET || 'session-login',
      resave: false,
      saveUninitialized: false,
      store: new fileStore({
        path: './sessions',
        ttl: 24 * 60 * 60, // 24시간
        reapInterval: 60 * 60 // 1시간
      }),
      cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        maxAge: 24 * 60 * 60 * 1000 // 24시간
      }
    }));

    // MongoDB 추가
    async function connectDB() {
      var databaseURL = "mongodb://localhost:27017/tictactoe";

      try {
        const database = await MongoClient.connect(databaseURL, {
          useNewUrlParser: true,
          useUnifiedTopology: true
        });
        console.log("DB 연결 완료 : " + databaseURL);
        app.set('database', database.db('tictactoe'));

        // 연결 종료 처리
        process.on("SIGINT", async () => {
          await database.close();
          console.log("DB 연결 종료");
          process.exit(0);
        });
      } catch (err) {
        console.error("DB 연결 실패 : " + err);
        process.exit(1);
      }
    }

    connectDB().catch(err => {
      console.error("초기 DB 연결 실패 : " + err);
      process.exit(1);
    });

    // view engine setup
    app.set('views', path.join(__dirname, 'views'));
    app.set('view engine', 'pug');

    app.use(logger('dev'));
    app.use(express.json());
    app.use(express.urlencoded({ extended: false }));
    app.use(cookieParser());
    app.use(express.static(path.join(__dirname, 'public')));

    app.use('/', indexRouter);
    app.use('/users', usersRouter);
    app.use('/leaderboard', leaderboardRouter); // 리더보드 구현

    // catch 404 and forward to error handler
    app.use(function(req, res, next) {
      next(createError(404));
    });

    // error handler
    app.use(function(err, req, res, next) {
      // set locals, only providing error in development
      res.locals.message = err.message;
      res.locals.error = req.app.get('env') === 'development' ? err : {};

      // render the error page
      res.status(err.status || 500);
      res.render('error');
    });

    module.exports = app;

     

    >> leaderboard.js

    var express = require('express');
    var router = express.Router();

    router.get('/', async function(req, res, next) {
        try {
            if (!req.session.isAuthenticated) {
                return res.status(400).send("로그인이 필요합니다.");
            }

            var database = req.app.get('database');
            var users = database.collection('users');

            const allUsers = await users.find({}, {
                projection: {
                    username: 1,
                    nickname: 1,
                    score: 1
                }
            }).sort({score: -1}).toArray();

            const scoreList = allUsers.map(user => ({
                username: user.username,
                nickname: user.nickname,
                score: user.score || 0
            }));

            const result = {
                scores: scoreList
            };

            res.status(200).json(result);

        } catch (err) {
            console.error("리더보드 조회 중 오류 발생 : ", err);
            res.status(500).send("서버 오류가 발생했습니다.");
        }
    });

    module.exports = router;

     

    >> www

    #!/usr/bin/env node

    /**
     * Module dependencies.
     */

    var app = require('../app');
    var debug = require('debug')('tictactoe-server:server');
    var http = require('http');
    var game = require('../game');  // 추가

    /**
     * Get port from environment and store in Express.
     */

    var port = normalizePort(process.env.PORT || '3000');
    app.set('port', port);

    /**
     * Create HTTP server.
     */

    var server = http.createServer(app);

    /**
     * 게임 서버 실행
     */
    game(server); // 추가

    /**
     * Listen on provided port, on all network interfaces.
     */

    server.listen(port);
    server.on('error', onError);
    server.on('listening', onListening);

    /**
     * Normalize a port into a number, string, or false.
     */

    function normalizePort(val) {
      var port = parseInt(val, 10);

      if (isNaN(port)) {
        // named pipe
        return val;
      }

      if (port >= 0) {
        // port number
        return port;
      }

      return false;
    }

    /**
     * Event listener for HTTP server "error" event.
     */

    function onError(error) {
      if (error.syscall !== 'listen') {
        throw error;
      }

      var bind = typeof port === 'string'
        ? 'Pipe ' + port
        : 'Port ' + port;

      // handle specific listen errors with friendly messages
      switch (error.code) {
        case 'EACCES':
          console.error(bind + ' requires elevated privileges');
          process.exit(1);
          break;
        case 'EADDRINUSE':
          console.error(bind + ' is already in use');
          process.exit(1);
          break;
        default:
          throw error;
      }
    }

    /**
     * Event listener for HTTP server "listening" event.
     */

    function onListening() {
      var addr = server.address();
      var bind = typeof addr === 'string'
        ? 'pipe ' + addr
        : 'port ' + addr.port;
      debug('Listening on ' + bind);
    }

     

    >> game.js

    const { v4: uuidv4 } = require('uuid');

    module.exports = function(server) {
        const io = require('socket.io')(server);

        var rooms = [];

        io.on('connection', function(socket) {
            // 클라이언트가 연결되면 실행되는 이벤트 핸들러
            console.log('사용자가 연결 되었습니다.');

            if (rooms.length > 0) {
                var roomId = rooms.shift();
                socket.join(roomId);
                socket.emit('joinRoom', { roomId: roomId});
                socket.to(roomId).emit('startGame', { userId: socket.id });
            } else {
                var roomId = uuidv4();
                socket.join(roomId);
                socket.emit('createRoom', { roomId: roomId});
                rooms.push(roomId);
            }

            socket.on('leaveRoom', function() {
                socket.leave(roomId);
                socket.emit('leaveRoom', { roomId: roomId });
                socket.to(roomId).emit('gameEnded', { userId: socket.id });
            });

            socket.on('sendMessage', function(message) {
                console.log('메세지를 받았습니다 : ' + message.nickName + ' : ' + message.message + ' : ' + message.roomId);
                socket.to(message.roomId).emit('receiveMessage', { nickName: message.nickName, message: message.message });
            });

            socket.on('disconnect', function() {
                console.log('사용자가 연결을 해제했습니다.');
            });
        });
    }