井字棋PhaserJS教程 #

在此教程中,您将使用PhaserJS和Nakama服务器创建井字棋游戏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. 将PhaserJS脚本标记添加到index.html文件:
 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. 首先将PhaserJS配置添加到App.svelte文件(您游戏的主入口点,在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为用户查找对手时只显示一个等待旋转器。

在游戏中场景中,您将创建一个由9个方块组成的互动板,供玩家输入X和O。您还可以定义游戏功能,以设置和更新玩家回合数,并结束游戏。此处定义的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() {
        const useSSL = false;
        this.client = new Client("defaultkey", "localhost", "7350", useSSL);

        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);

    }

    // ...
在通过两个浏览器窗口在本地测试游戏时时,应当将deviceId设置为uuidv4(),而不是从localStorage获取它,否则您将作为同一用户进行两次身份验证,导致第一个会话断开连接,使得游戏无法进行。
  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()

大功告成,您可以开始游戏了!

延伸阅读 #

通过以下内容进一步了解本教程中的主题和功能: