マルチプレイの環境構築【後編】

komekokokusan.hatenadiary.jp


1.ZepetoPlayersを作成
「Publish」横の▼マークから「Login」を選択してログインを行います。
図ではログインした後の為名前が表示されています。

その後、下記の手順を行ってください。
Create a ZEPETO Character

!注意
CharacterControllerは作成しないでください。
もし作成している場合、無効化してください。


2.ClientStarterを作成
Assets内で右クリック「作成」>「ZEPETO」>「TypeScript」
名前を「ClientStarter」に変更します。

import {ZepetoScriptBehaviour} from 'ZEPETO.Script'
import {ZepetoWorldMultiplay} from 'ZEPETO.World'
import {Room, RoomData} from 'ZEPETO.Multiplay'
import {Player, State, Vector3} from 'ZEPETO.Multiplay.Schema'
import {CharacterState, SpawnInfo, ZepetoPlayers, ZepetoPlayer, CharacterJumpState} from 'ZEPETO.Character.Controller'
import * as UnityEngine from "UnityEngine";


export default class ClientStarterV2 extends ZepetoScriptBehaviour {

    public multiplay: ZepetoWorldMultiplay;

    private room: Room;
    private currentPlayers: Map<string, Player> = new Map<string, Player>();

    private zepetoPlayer: ZepetoPlayer;

    private Start() {
        this.multiplay.RoomCreated += (room: Room) => { this.room = room; };
        this.multiplay.RoomJoined += (room: Room) => { room.OnStateChange += this.OnStateChange; };
        this.StartCoroutine(this.SendMessageLoop(0.04));
    }

    // Send the local character transform to the server at the scheduled Interval Time.
    private* SendMessageLoop(tick: number) {
        while (true) {
            yield new UnityEngine.WaitForSeconds(tick);

            if (this.room != null && this.room.IsConnected) {
                const hasPlayer = ZepetoPlayers.instance.HasPlayer(this.room.SessionId);
                if (hasPlayer) {
                    const character = ZepetoPlayers.instance.GetPlayer(this.room.SessionId).character;                                  
                    this.SendTransform(character.transform);
                    this.SendState(character.CurrentState);
                }
            }
        }
    }

    private OnStateChange(state: State, isFirst: boolean) {

        // When the first OnStateChange event is received, a full state snapshot is recorded.
        if (isFirst) {

            // [CharacterController] (Local) Called when the Player instance is fully loaded in Scene
            ZepetoPlayers.instance.OnAddedLocalPlayer.AddListener(() => {
                const myPlayer = ZepetoPlayers.instance.LocalPlayer.zepetoPlayer;
                this.zepetoPlayer = myPlayer;
            });

            // [CharacterController] (Local) Called when the Player instance is fully loaded in Scene
            ZepetoPlayers.instance.OnAddedPlayer.AddListener((sessionId: string) => {
                const isLocal = this.room.SessionId === sessionId;
                if (!isLocal) {
                    const player: Player = this.currentPlayers.get(sessionId);

                    // [RoomState] Called whenever the state of the player instance is updated. 
                    player.OnChange += (changeValues) => this.OnUpdatePlayer(sessionId, player);
                }
            });
        }

        const join = new Map<string, Player>();
        const leave = new Map<string, Player>(this.currentPlayers);

        state.players.ForEach((sessionId: string, player: Player) => {
            if (!this.currentPlayers.has(sessionId)) {join.set(sessionId, player);}
            leave.delete(sessionId);
        });

        // [RoomState] Create a player instance for players that enter the Room
        join.forEach((player: Player, sessionId: string) => this.OnJoinPlayer(sessionId, player));

        // [RoomState] Remove the player instance for players that exit the room
        leave.forEach((player: Player, sessionId: string) => this.OnLeavePlayer(sessionId, player));
    }

    private OnJoinPlayer(sessionId: string, player: Player) {
        console.log(`[OnJoinPlayer] players - sessionId : ${sessionId}`);
        this.currentPlayers.set(sessionId, player);

        const spawnInfo = new SpawnInfo();
        const position = this.ParseVector3(player.transform.position);
        const rotation = this.ParseVector3(player.transform.rotation);
        spawnInfo.position = position;
        spawnInfo.rotation = UnityEngine.Quaternion.Euler(rotation);

        const isLocal = this.room.SessionId === player.sessionId;
        ZepetoPlayers.instance.CreatePlayerWithUserId(sessionId, player.zepetoUserId, spawnInfo, isLocal);
    }

    private OnLeavePlayer(sessionId: string, player: Player) {
        console.log(`[OnRemove] players - sessionId : ${sessionId}`);
        this.currentPlayers.delete(sessionId);

        ZepetoPlayers.instance.RemovePlayer(sessionId);
    }

    private OnUpdatePlayer(sessionId: string, player: Player) {

        const position = this.ParseVector3(player.transform.position);
        const zepetoPlayer = ZepetoPlayers.instance.GetPlayer(sessionId);

        let moveDir = UnityEngine.Vector3.op_Subtraction(position, zepetoPlayer.character.transform.position);
        moveDir = new UnityEngine.Vector3(moveDir.x, 0, moveDir.z);

        if (moveDir.magnitude < 0.05) {
            if (player.state === CharacterState.MoveTurn)
                return;
            zepetoPlayer.character.StopMoving();
        } else {
            zepetoPlayer.character.MoveContinuously(moveDir);
        }

        if (player.state === CharacterState.Jump) {
            if (zepetoPlayer.character.CurrentState !== CharacterState.Jump) {zepetoPlayer.character.Jump(); }
            if (player.subState === CharacterJumpState.JumpDouble) { zepetoPlayer.character.DoubleJump(); }
        }
    }

    private SendTransform(transform: UnityEngine.Transform) {
        const data = new RoomData();

        const pos = new RoomData();
        pos.Add("x", transform.localPosition.x);
        pos.Add("y", transform.localPosition.y);
        pos.Add("z", transform.localPosition.z);
        data.Add("position", pos.GetObject());

        const rot = new RoomData();
        rot.Add("x", transform.localEulerAngles.x);
        rot.Add("y", transform.localEulerAngles.y);
        rot.Add("z", transform.localEulerAngles.z);
        data.Add("rotation", rot.GetObject());
        this.room.Send("onChangedTransform", data.GetObject());
    }

    private SendState(state: CharacterState) {
        const data = new RoomData();
        data.Add("state", state);
        if(state === CharacterState.Jump) {  data.Add("subState", this.zepetoPlayer.character.MotionV2.CurrentJumpState); }
        this.room.Send("onChangedState", data.GetObject());
    }

    private ParseVector3(vector3: Vector3): UnityEngine.Vector3 { return new UnityEngine.Vector3 ( vector3.x, vector3.y,  vector3.z );}
}


ヒエラルキーの「+」から「空のオブジェクトを作成」を選択。
名称を「ClientStarter」に変更します。

インスペクターにて先ほど作成したScriptを追加、Multiplay欄を「WorldMultiPlay」に変更します。
「WorldMultiPlay」は前編で作成したオブジェクトです。

このコンポーネントは、ClientにRoomCreatedやRoomJoined等のServerRoomEventを連動させるInterfaceを提供します。
詳しくはこちらを参照ください。


3.マルチプレイが可能かテスト
サーバーマーク(ZEPETOマークの横)に緑丸が表示されていることを確認してください。
ZEPETOマークを押下して、下図画面が表示される為「OK」ボタンを押下します。


QRコードが表示される為、ZEPETOアプリのQRコードスキャナから読み込み、作成したWorldに入場します。


自分と、「やみ」にも入場してもらいました。

マルチプレイの環境構築【Appendix】

ここではマルチプレイの環境構築についての補足を記載します。

komekokokusan.hatenadiary.jp
komekokokusan.hatenadiary.jp

schemas.jsonについて

前編で公開した内容のインスペクターについて補足します。
Room StateとはRoomに接続しているプレイヤーと関連情報、オブジェクトの位置等を管理するためのStatePropertyです。
Schema Typesはサーバーとクライアント間の通信用のデータ構造です。

index.tsについて

前編で公開した内容のソースコードの各Eventについて補足します。

onCreate(options: SandboxOptions) Roomが作成されたときに一度呼び出される為、Room初期化ロジックを追加できます。
onJoin(client: SandboxPlayer) ClientがRoomに入場するときに呼び出されます。
このときのClientのIDと文字情報はSendboxPlayerに含まれています。
onLeave(client: SandboxPlayer, consented?: boolean) ClientがRoomから退場するときに呼び出されます。
constendはClientが切断を要求した場合はTrue、それ以外はFalseに設定される値です。
onTick(deltaTime: number) 一定のIntervalTimeごとに呼び出されます。
IntervalTimeはSandboxOptionsでTickIntervalに設定された値が適用されます。

ClientStarterについて

後編で公開した内容のソースコードの各Eventについて補足します。

OnStateChange(state: State, isFirst: boolean) 最初の接続時とその後サーバーでStateが変更されると呼び出されます。
StateはRoom Stateで定義されたSchemaにアクセス可能。
isFirstは最初の接続時のときはTrue,以降はFalseに設定される値です。
OnJoinPlayer(sessionId: string, player: Player) Playerが入場したときに呼び出されます。
OnLeavePlayer(sessionId: string, player: Player) Playerが退場するときに呼び出されます。
OnUpdatePlayer(sessionId: string, player: Player) 位置情報などPlayerの情報が変更されたときに呼び出されます。


Room EventListener

RoomCreated(Room) Roomが作成され接続可能な場合に呼び出されます。生成されたRoomを引数に渡します。
RoomJoined(Room) Roomに接続されたときに呼び出されます。接続されたRoomを引数に渡します。
RoomLeave(RoomLeaveEvent) Roomから退場するときに呼び出されます。
RoomReconnected(Room) Room に再接続されたときに呼び出されます。
RoomError(RoomErrorEvent) Room にエラーが発生したときに呼び出されます。

マルチプレイの環境構築【前編】

ゲームと言ったらマルチプレイですよね。

今回は、マルチプレイに必要な環境を整えていきます。
前回公開した下記の手順は済ませておく必要があります。
komekokokusan.hatenadiary.jp


後編はこちら
komekokokusan.hatenadiary.jp


1.Multiplay Serverを作成
Assetsフォルダ内で 「作成」>「ZEPETO」 >「 Multiplay Server」 を選択。
「World.MultiPlay」というフォルダが追加されます。


次にindex.tsとschemas.jsonを編集します。
各Fileのインスペクター、ソースコードの補足についてはこちら


・index.ts
サーバーのメインロジックコードを担当するファイル。

import {Sandbox, SandboxOptions, SandboxPlayer} from "ZEPETO.Multiplay";
import {DataStorage} from "ZEPETO.Multiplay.DataStorage";
import {Player, Transform, Vector3} from "ZEPETO.Multiplay.Schema";

export default class extends Sandbox {

    storageMap:Map<string,DataStorage> = new Map<string, DataStorage>();
    
    constructor() {super();}

    onCreate(options: SandboxOptions) {
        // Called when the Room object is created.
        // Handle the state or data initialization of the Room object.

        this.onMessage("onChangedTransform", (client, message) => {
            const player = this.state.players.get(client.sessionId);
            const transform = new Transform();
            transform.position = new Vector3();
            transform.position.x = message.position.x;
            transform.position.y = message.position.y;
            transform.position.z = message.position.z;

            transform.rotation = new Vector3();
            transform.rotation.x = message.rotation.x;
            transform.rotation.y = message.rotation.y;
            transform.rotation.z = message.rotation.z;

            if (player) {player.transform = transform;}
        });

        this.onMessage("onChangedState", (client, message) => {
            const player = this.state.players.get(client.sessionId);
            if (player) {
                player.state = message.state;
                player.subState = message.subState; // Character Controller V2
            }
        });
    }
       
    async onJoin(client: SandboxPlayer) {

        // Create the player object defined in schemas.json and set the initial value.
        console.log(`[OnJoin] sessionId : ${client.sessionId}, userId : ${client.userId}`)

        const player = new Player();
        player.sessionId = client.sessionId;

        if (client.userId) { player.zepetoUserId = client.userId;}

        // [DataStorage] DataStorage Load of the entered Player
        const storage: DataStorage = client.loadDataStorage();

        this.storageMap.set(client.sessionId,storage);

        let visit_cnt = await storage.get("VisitCount") as number;
        if (visit_cnt == null) visit_cnt = 0;

        console.log(`[OnJoin] ${client.sessionId}'s visiting count : ${visit_cnt}`)

        // [DataStorage] Update Player's visit count and then Storage Save
        await storage.set("VisitCount", ++visit_cnt);

        // Manage the Player object using sessionId, a unique key value of the client object.
        // The client can check the information about the player object added by set by adding the add_OnAdd event to the players object.
        this.state.players.set(client.sessionId, player);
    }

    onTick(deltaTime: number): void {
        //  It is repeatedly called at each set time in the server, and a certain interval event can be managed using deltaTime.
    }

    async onLeave(client: SandboxPlayer, consented?: boolean) {
        // By setting allowReconnection, it is possible to maintain connection for the circuit, but clean up immediately in the basic guide.
        // The client can check the information about the deleted player object by adding the add_OnRemove event to the players object.
        this.state.players.delete(client.sessionId);
    }
}


・schemas.json
サーバーとクライアント間の通信用のファイル。

{
"State" : {"players" : {"map" : "Player"}},
"Player" : {"sessionId" : "string","zepetoUserId" : "string","transform" : "Transform","state" : "number","subState" : "number"},
"Transform" : {"position" : "Vector3","rotation" : "Vector3"},
"Vector3" : {"x" : "number","y" : "number","z" : "number"}
}


インスペクターが下図のように自動で変更されます。


2.Logを表示
「ウィンドウ」>「ZEPETO」>「Multi Server」を選択

下図画面が表示される為、インスペクターの下部等に画面を固定します。

Log画面

以降は下図赤枠のアイコンから更新をかけることが出来ます。


3.「Play with MultiPlay Server」を有効化
「Publish」横の▼マークから「Play with MultiPlay Server」にチェックを入れます。


4.Clientを作成
ヒエラルキーの「+」から「空のオブジェクトを作成」を選択。
名称を「WorldMultiPlay」に変更

インスペクターにて「Zepeto World MultiPlay」を追加します。


ここまでの手順を終えると、実行時に下記Clientの接続Logが出力されます。


続きは次回纏めます!

Worldの情報を作成

マルチプレイの環境構築について纏めようと思ったのですが、思っていたよりも手順が多かったので小分けします。

1.ZEPETO Studioにログインし、Worldを作成
下図画面から「Worldを作る」ボタンを押下。

必要情報を入力し、「作る」ボタンを押下。
このWorldIDをUnity側との連携でこの後使用していきます。


2.Unityに連携
「Publish」の横の▼マークから「Open World Settings」を選択。

「World ID」欄に先ほど設定したWorldIDをコピペ


完了!
これらの設定は別にマルチプレイに限らず必要なので、初手でやっておくと良いです。

ZEPETOCamera傾いている問題

こんばんは。最近2月なのに暖かくなってきましたね。

下記ドキュメントを参考にUnityのインストール等環境を整え、自分のアバターを実行時に召喚出来るところまで進めました。

Welcome! ZEPETO Developers

 

自分のアバターが本当にWorldに入れるのか試してみたところ...

 

地面が傾いている

 

何か変な設定でもしてしまったのか?

 

Plane(地面)は下記の通りに設定している為問題なし

Create a ZEPETO Character

 

ZEPETOCameraの設定値を確認するがこちらも問題はなさそう

 

ZEPETOCameraというのは下記のオブジェクト

実行時にヒエラルキーに現れ、位置などの設定値が変えられないためどうも読み取り専用のもよう。

 

何が悪いのかよく分からないまま、たまたま別日に別のアバターで実行してみた結果

 

傾いていない

 

最初のアバターで実行したらまた傾いたのだけど、

違うのは服装と顔と...首をかしげているかどうか

 

もしかしてZEPETOCamera、アバターの目線に合うように設定されている?

 

ZEPETOCameraの中身を差し替える手順を探しても良かったけど、どうせならば今度もなるべく簡単に自由に変えていけるように、ZEPETOCameraを無効化してMainCameraを有効にしようと考えました。

 

結論

 

直せました

 

手順は下記の通り、スクリプトの中身も全く同じです。

Top View Example

 

ただ上記はややUnity初心者には不親切(上級者向けって書いてあるしね)だったので以下補足

1.「Asset」画面にて右クリック「作成」>「ZEPETO」>「TypeScript」

2.下図では名称を「ViewController」に変更、内容は先述の通り

3.MainCameraの子にカメラを作成

4.インスペクターを編集

・タグを「MainCamera」に変更

・1.2.で作成したスクリプトドラッグ&ドロップ

・Custom Cameraを「MainCamera」に変更

・カメラの位置などの設定は図のTransform参照(好みで微調整を行えばOK)


今後の出来次第で微妙に変えるかもしれないけれど、ひとまず問題解決ということで。

ZEPETO日本語情報少なすぎる問題

初めまして。ZEPETOでWorldを作成しているこめこです。

UnityでWorldの製作を始めてからまだ20日くらい?

隙間時間に進めているので実際は1週間も経っていません。

 

1月半ばにZEPETOを始め、少しずつ慣れてきたところです。

 

ZEPETOのWorldやアイテムを個人が製作できるよ!

 

という公式からのアピールを見かけて、作ってみようと思った次第です。

Unityをがっつり触ってみたかったので、Worldの方を選択しました。

 

しかしZEPETOは韓国アプリ、開発者が集う公式Discordは英語Onlyだし公式ドキュメントもほとんど韓国語英語...

(ColorOSの画面翻訳やGoogleのページ翻訳機能に助けられています、感謝)

 

日本語の情報が全くないんですよね。

有志のブログなども全く見当たらず...。

 

非エンジニア向けにBuildItというソフトが用意されている為、多くの方はこちらを利用されているんじゃないかなと思います。

studio.zepeto.me

 

ということなので、個人的な備忘録も兼ねて誰かの参考になるような記事を書いていけたらなと思います。

何かありましたらコメントよろしくお願いします。