Tic-Tac-Toe PhaserJS 튜토리얼 #

이 튜토리얼에서는 PhaserJS와 Nakama 서버를 사용하여 온라인 tic-tac-toe 게임인 XOXO를 만듭니다.

이 게임과 튜토리얼은 Nakama의 강력한 인증, 매치메이킹, 실시간 멀티플레이어 기능을 강조합니다.

전체 튜토리얼 소스 코드는 GitHub에서 사용할 수 있습니다.

필수 조건 #

튜토리얼을 쉽게 따라하기 위해서는 진행하기 전에 다음을 실시합니다:

Nakama 서버 실행 #

XOXO 게임을 실행하려면 먼저 Nakama가 실행 중이어야 합니다.

Docker를 설치하고 Nakama 프로젝트 템플릿을 복제했으면 리포지토리를 복제한 폴더로 이동한 후 다음 명령을 실행합니다:

1
docker compose up

JavaScript 프레임워크 구성 #

여기에서 이 프로젝트에 필요한 TypeScript 종속성을 설치하고 TypeScript 코드를 JavaScript로 변환하고 PhaserJS를 Svelte JavaScript 프레임워크에 추가합니다.

  1. NPM을 설치하여 종속성을 관리합니다. 터미널 창에서:
1
npm install
  1. Nakama 서버를 시작하기 전에 TypeScript 코드를 JS로 변환합니다:
1
npx tsc
  1. index.html 파일에 PhaserJS 스크립트 태그를 추가합니다:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
        <meta charset='utf-8'>
        <meta name='viewport' content='width=device-width,initial-scale=1'>
        <title>Svelte app</title>
        <link rel='icon' type='image/png' href='/favicon.png'>
        <link rel='stylesheet' href='/global.css'>
        <link rel='stylesheet' href='/build/bundle.css'>
        // PhaserJS script tag
        <script src="//cdn.jsdelivr.net/npm/phaser@3.54.0/dist/phaser.min.js"></script>
        <script defer src='/build/bundle.js'></script>
</head>
<body>
</body>
</html>
  1. 이제 다음을 실행하여 로컬에서 자신의 애플리케이션을 실행할 수 있습니다:
1
npm run dev

여러분의 애플리케이션은 localhost:5000에서 사용할 수 있습니다.

UI 빌드 #

  1. App.svelte 파일에 PhaserJS 구성을 추가하여 시작합니다(main.js 파일에 가져온 게임의 기본 진입점):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script>
        import MainMenu from "./scenes/MainMenu";
        import Matchmaking from "./scenes/Matchmaking";
        import InGame from "./scenes/InGame";
        import CONFIG from "./config";
        const config = {
                type: Phaser.AUTO,
                width: CONFIG.WIDTH,
                height: CONFIG.HEIGHT,
                backgroundColor: "#FF4C4C",
                scene: [MainMenu, Matchmaking, InGame],
        };
        new Phaser.Game(config);
</script>
1
2
3
4
5
6
7
8
import App from './App.svelte';

const app = new App({
    target: document.body,
    props: {}
});

export default app;
  1. 다음으로 게임 설정을 위한 config.js 파일을 만듭니다. 이 경우 게임 캔버스의 높이와 너비는 다음과 같습니다:
1
2
3
4
5
const CONFIG = {
      WIDTH: 414,
      HEIGHT: 736
}
export default CONFIG
  1. 이제 게임의 Phaser 장면을 생성하여 관련 로직을 그룹화할 수 있습니다. 우리 게임에는 메인 메뉴 장면, 매치메이킹 장면, 게임 내 장면의 세 가지가 있습니다:

MainMenu.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

// ...

    create() {
        Nakama.authenticate()

        // Create the Welcome banner
        this.add
            .text(CONFIG.WIDTH / 2, 75, "Welcome to", {
                fontFamily: "Arial",
                fontSize: "24px",
            })
            .setOrigin(0.5);

        this.add
            .text(CONFIG.WIDTH / 2, 123, "XOXO", {
                fontFamily: "Arial",
                fontSize: "72px",
            })
            .setOrigin(0.5);

        this.add.grid(
            CONFIG.WIDTH / 2,
            CONFIG.HEIGHT / 2,
            300,
            300,
            100,
            100,
            0xffffff,
            0,
            0xffca27
        );

        // Create a button to start the game
        const playBtn = this.add
            .rectangle(CONFIG.WIDTH / 2, 625, 225, 70, 0xffca27)
            .setInteractive({ useHandCursor: true });

        const playBtnText = this.add
            .text(CONFIG.WIDTH / 2, 625, "Begin", {
                fontFamily: "Arial",
                fontSize: "36px",
            })
            .setOrigin(0.5);

        playBtn.on("pointerdown", () => {
            Nakama.findMatch()
            this.scene.start("in-game");
        });

        // ...
    }
}

Matchmaking.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

// ...

    create() {
        this.add
            .text(CONFIG.WIDTH / 2, 125, "Searching for an opponent...", {
                fontFamily: "Arial",
                fontSize: "24px",
            })
            .setOrigin(0.5);

        this.anims.create({
            key: "spinnerAnimation",
            frames: this.anims.generateFrameNumbers("spinner"),
            frameRate: 30,
            repeat: Phaser.FOREVER,
        });

        this.add
            .sprite(CONFIG.WIDTH / 2, CONFIG.HEIGHT / 2, "spinner")
            .play("spinnerAnimation")
            .setScale(0.5);
    }
}

InGame.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

// ...

    updateBoard(board) {
        board.forEach((element, index) => {
            let newImage = this.INDEX_TO_POS[index]

            if (element === 1) {
                this.phaser.add.image(newImage.x, newImage.y, "O");
            } else if (element === 2) {
                this.phaser.add.image(newImage.x, newImage.y, "X");
            }
        })
    }

    updatePlayerTurn() {
        this.playerTurn = !this.playerTurn

        if (this.playerTurn) {
            this.headerText.setText("Your turn!")
        } else {
            this.headerText.setText("Opponents turn!")
        }
    }

    setPlayerTurn(data) {
        let userId = localStorage.getItem("user_id");
        if (data.marks[userId] === 1) {
            this.playerTurn = true;
            this.playerPos = 1;
            this.headerText.setText("Your turn!")
        } else {
            this.headerText.setText("Opponents turn!")
        }
    }

    endGame(data) {
        this.updateBoard(data.board)

        if (data.winner === this.playerPos) {
            this.headerText.setText("Winner!")
        } else {
            this.headerText.setText("You loose :(")
        }
    }

    nakamaListener() {
        Nakama.socket.onmatchdata = (result) => {
            switch (result.op_code) {
                case 1:
                    this.gameStarted = true;
                    this.setPlayerTurn(result.data)
                    break;
                case 2:
                    console.log(result.data)
                    this.updateBoard(result.data.board)
                    this.updatePlayerTurn()
                    break;
                case 3:
                    this.endGame(result.data)
                    break;
            }
        };
    }

    // ...

        // Register the player move in the correct square
        this.nakamaListener()

        this.add
            .rectangle(
                gridCenterX - gridCellWidth,
                topY,
                gridCellWidth,
                gridCellWidth
            )
            .setInteractive({ useHandCursor: true })
            .on("pointerdown", async () => {
                await Nakama.makeMove(0)
            });

        this.add
            .rectangle(gridCenterX, topY, gridCellWidth, gridCellWidth)
            .setInteractive({ useHandCursor: true })
            .on("pointerdown", () => {
                Nakama.makeMove(1)
            });

        // ...
    }
}

메인 메뉴 장면의 경우 Nakama(아래에서 설명)에서 사용자를 인증하고 “XOXO에 오신 것을 환영합니다” 배너와 버튼을 표시하여 이를 클릭하면 사용자를 게임 내 장면으로 이동합니다.

매치메이킹 장면의 경우 Nakama가 사용자의 상대방을 찾는 동안 대기 중인 스피너만 표시합니다.

게임 내 장면의 경우 플레이어가 X와 O를 입력할 수 있도록 9개의 개별 사각형으로 구성된 대화형 보드를 만듭니다. 또한 플레이어의 차례를 설정 및 업데이트하고 게임을 종료하는 게임플레이 함수를 정의합니다. 여기에 정의된 nakamaListener은(는) 웹 소켓을 통해 이러한 작업을 Nakama 서버에 전달합니다.

Nakama 연결 #

  1. 다음으로 클라이언트를 Nakama 서버에 연결하고 이를 장치 인증을 위해 구성합니다:

nakama.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Client } from "@heroiclabs/nakama-js";
import { v4 as uuidv4 } from "uuid";

class Nakama {
    constructor() {
        this.client
        this.session
        this.socket
        this.matchID
    }

    async authenticate() {
        this.client = new Client("defaultkey", "localhost", "7350");
        this.client.ssl = false;

        let deviceId = localStorage.getItem("deviceId");
        if (!deviceId) {
            deviceId = uuidv4();
            localStorage.setItem("deviceId", deviceId);
        }

        this.session = await this.client.authenticateDevice(deviceId, true);
        localStorage.setItem("user_id", this.session.user_id);

        const trace = false;
        this.socket = this.client.createSocket(this.useSSL, trace);
        await this.socket.connect(this.session);

    }

    // ...
두 개의 브라우저 창을 사용하여 로컬에서 게임을 테스트할 때는 localStorage에서 가져오기보다는 deviceId을(를) uuidv4()(으)로 설정해야 합니다. 그렇지 않으면 동일한 사용자로 두 번 인증되어 첫 번째 세션 연결이 끊어지고 게임을 플레이할 수 없게 됩니다.
  1. 여기에서 멀티플레이어 기능도 구성합니다. 이에 대해서는 권한 보유 멀티플레이어매치메이커 문서를 참조하세요.

nakama.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { Client } from "@heroiclabs/nakama-js";
import { v4 as uuidv4 } from "uuid";

class Nakama {
    constructor() {
        this.client
        this.session
        this.socket
        this.matchID
    }

    // ...

    async findMatch() {
        const rpcid = "find_match";
        const matches = await this.client.rpc(this.session, rpcid, {});

        this.matchID = matches.payload.matchIds[0]
        await this.socket.joinMatch(this.matchID);
        console.log("Matched joined!")
    }

    async makeMove(index) {
        var data = { "position": index };
        await this.socket.sendMatchState(this.matchID, 4, data);
        console.log("Match data sent")
    }
}

export default new Nakama()

이제 게임할 준비가 되었습니다!

추가 사항 #

다음을 통해 이 튜토리얼의 주제와 기능을 자세히 알아보세요: