# 井字棋PhaserJS

**URL:** https://heroiclabs.com/docs/zh/nakama/tutorials/javascript/xoxo/
**Summary:** 使用Nakama和PhaserJS创建井字棋游戏的端到端教程。

---


# 井字棋PhaserJS教程

{{< youtube "videoseries?list=PLOAExZcDNj9v8Ne6pXtOdhCycZIpubsZV" >}}

在此教程中，您将使用[PhaserJS](https://phaser.io/)和Nakama服务器创建井字棋游戏XOXO。

此游戏和教程集中体现了 Nakama 强大的[身份验证](../../../concepts/authentication/)、[匹配](../../../concepts/multiplayer/matchmaker/)和[实时多人游戏](../../../concepts/multiplayer/relayed/)功能。

{{< note "important" >}}
完整教程源代码可从[GitHub](https://github.com/heroiclabs/xoxo-phaserjs)获得。
{{< / note >}}

## 前提条件

为轻松学习本教程，请在继续之前执行以下操作：

* [安装Node LTS](https://nodejs.org/en/download/)
* [安装TypeScript](https://www.typescriptlang.org/download)
* [安装Phaser](https://phaser.io/download)
* [安装Nakama](../../../getting-started/install/docker/)
* [安装Nakama JavaScript客户端](../../../client-libraries/javascript/)
* [安装Svelte](https://svelte.dev/blog/the-easiest-way-to-get-started)
* [复制Nakama项目模板](https://github.com/heroiclabs/nakama-project-template)
* [准备PhaserJS游戏引擎](#configuring-your-javascript-framework)

### 运行Nakama服务器

为运行XOXO游戏，您必须首先确保Nakama正在运行。

[安装Docker](https://docs.docker.com/get-docker/)并复制[Nakama项目模板](https://github.com/heroiclabs/nakama-project-template)后，进入存储库复制到的文件夹，运行以下命令：

```bash
docker compose up
```

### 配置JavaScript框架

此处您将安装此项目所需要的TypeScript依赖项，将TypeScript代码转换为JavaScript，并将PhaserJS添加到Svelte JavaScript框架中。

1. 安装NPM以管理依赖项。在终端窗口中：

```bash
npm install
```

2. 在启动Nakama服务器之前，将TypeScript代码转换为JS：

```bash
npx tsc
```

3. 将PhaserJS脚本标记添加到`index.html`文件：

```html
<!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>
```

4. 现在可以运行您的应用程序，方法是通过运行：

```bash
npm run dev
```

您的应用程序可从`localhost:5000`获得。

## 构建UI

1. 首先将PhaserJS配置添加到`App.svelte`文件（您游戏的主入口点，在`main.js`文件中导入）：

```svelte
<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>
```

```javascript
import App from './App.svelte';

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

export default app;
```

2. 下一步，为游戏设置创建`config.js`文件。在这种情况下，游戏画布的高度和宽度：

```javascript
const CONFIG = {
      WIDTH: 414,
      HEIGHT: 736
}
export default CONFIG
```

3. 现在，您可以开始创建游戏的Phaser场景以组合相关逻辑。此游戏有三个逻辑：主菜单、匹配和游戏场景：

**MainMenu.js**
```javascript

// ...

    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**
```javascript

// ...

    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**
```javascript

// ...

    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服务器，并对其进行[设备身份验证](../../../concepts/authentication/#device)配置：

**nakama.js**
```javascript
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);

    }

    // ...
```

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

2. 此处您还将配置[多人游戏](../../../concepts/multiplayer/relayed/)功能。阅读[权威多人游戏](../../../concepts/multiplayer/authoritative/)和[匹配程序](../../../concepts/multiplayer/matchmaker/)文档已了解更多信息。

**nakama.js**
```javascript
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()
```

大功告成，您可以开始游戏了！

## 延伸阅读

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

* [JavaScript客户端指南](../../../client-libraries/javascript/)
* [身份验证](../../../concepts/authentication/)
* [权威多人游戏](../../../concepts/multiplayer/authoritative/)
* [匹配程序](../../../concepts/multiplayer/matchmaker/)
