← กรณีการใช้งาน

ล็อบบี้ การจับคู่ และกระดานคะแนนใน Unity

ล็อบบี้ presence “จับคู่สำเร็จ” และกระดานคะแนนแบบแคช ด้วย PusherClient for .NET

ไม่ใช่ทุกข้อความในเกมที่ต้องใช้เกมเซิร์ฟเวอร์ ล็อบบี้ "จับคู่สำเร็จ" เพื่อนออนไลน์ แชต กระดานคะแนน และประกาศกิจกรรมต่าง ๆ ล้วนเป็นอีเวนต์ คือเล็ก ไม่บ่อย และส่งถึงเฉพาะผู้เล่นที่สนใจ Hibira ส่งอีเวนต์เหล่านี้ผ่าน WebSocket ด้วยไลบรารี .NET โอเพนซอร์สชื่อ PusherClient แบ็กเอนด์ของคุณเผยแพร่ได้ด้วยการเรียก HTTP ครั้งเดียว โดยไม่ต้องมีเซิร์ฟเวอร์ socket ของตัวเอง ส่วนสถานะที่เปลี่ยนทุกเฟรม ให้ใช้ระบบเครือข่ายของเกมต่อไป (Netcode for GameObjects, Photon, Mirror หรือที่เขียนเอง)

สิ่งที่ผู้เล่นได้รับ

  • ล็อบบี้ที่เห็นว่าใครอยู่และใครพร้อมแล้ว โดยไม่ต้องรีเฟรช
  • "จับคู่สำเร็จ" ทันทีที่ระบบจับคู่ตัดสิน แทนการรอวงกลมหมุนของการ polling
  • กระดานคะแนนและการนับถอยหลังของกิจกรรมที่เป็นปัจจุบันตั้งแต่วินาทีที่เปิดหน้าจอ

ฟีเจอร์ที่ใช้

  • PusherClient (pusher-websocket-dotnet) ใน Unity
  • แชนเนล presence (presence-) สำหรับล็อบบี้และปาร์ตี้
  • อีเวนต์ฝั่งไคลเอนต์ (client-*) สำหรับ "พร้อมแล้ว" และสัญญาณเล็ก ๆ อื่น ๆ ระหว่างผู้เล่น
  • แชนเนลส่วนตัว (private-) สำหรับข้อความถึงผู้เล่นคนเดียว
  • แชนเนลแคช (cache-) ที่ส่งกระดานคะแนนล่าสุดให้ผู้เล่นทุกคนที่เปิดดู

ขั้นที่ 1: เชื่อมต่อครั้งเดียว จัดการอีเวนต์บนเธรดหลัก

เพิ่มแพ็กเกจ PusherClient จาก NuGet (เช่นผ่าน NuGetForUnity) แล้ววางคอมโพเนนต์นี้บน GameObject ในซีนแรก:

using System;
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using PusherClient;
using UnityEngine;

public class Realtime : MonoBehaviour
{
    public static Realtime Instance { get; private set; }
    public Pusher Client { get; private set; }

    // PusherClient raises events on a background thread; Unity's API only works on the main thread.
    readonly ConcurrentQueue<Action> mainThread = new ConcurrentQueue<Action>();
    public void Post(Action action) => mainThread.Enqueue(action);

    async void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);
        Client = new Pusher("YOUR_APP_KEY", new PusherOptions
        {
            Host = "ws.hibira.io",
            Encrypted = true,
            ChannelAuthorizer = new HttpChannelAuthorizer("https://api.example.com/pusher/auth")
            {
                AuthenticationHeader = new AuthenticationHeaderValue("Bearer", Session.Token),
            },
        });
        Client.Error += (sender, error) => Debug.LogWarning(error);
        await Client.ConnectAsync();
    }

    void Update()
    {
        while (mainThread.TryDequeue(out var action)) action();
    }

    async void OnApplicationQuit() => await Client.DisconnectAsync();
}

ตั้งค่า Host ไม่ใช่ Cluster เพราะการตั้ง cluster จะทำให้ไลบรารีชี้กลับไปที่ pusher.com app secret อยู่ที่แบ็กเอนด์ของคุณ ซึ่งลงนามแชนเนลส่วนตัวและ presence ที่ endpoint ยืนยันสิทธิ์

ขั้นที่ 2: ล็อบบี้ที่มี presence และ "พร้อมแล้ว"

[Serializable]
public class Player { public string name; public int level; }

var rt = Realtime.Instance;
var lobby = await rt.Client.SubscribePresenceAsync<Player>("presence-lobby-" + lobbyId);

foreach (var m in lobby.GetMembers()) rt.Post(() => LobbyUI.Add(m.Key, m.Value));
lobby.MemberAdded += (sender, m) => rt.Post(() => LobbyUI.Add(m.Key, m.Value));
lobby.MemberRemoved += (sender, m) => rt.Post(() => LobbyUI.Remove(m.Key));

// "Ready" goes straight to the other players: turn on Allow client events in the app settings
lobby.Bind("client-ready", (PusherEvent e) => rt.Post(() => LobbyUI.SetReady(e.UserId, true)));
readyButton.onClick.AddListener(() => lobby.Trigger("client-ready", new { ready = true }));

endpoint ยืนยันสิทธิ์ของคุณเป็นผู้ตัดสินว่าใครเข้าล็อบบี้ได้ และส่ง user_id กับ user_info ของผู้เล่นแต่ละคน (ในที่นี้คือชื่อและเลเวล) ซึ่งสมาชิกทุกคนจะได้รับ

ขั้นที่ 3: จับคู่สำเร็จและกระดานคะแนนจากแบ็กเอนด์

// When the matchmaker pairs players
await pusher.trigger(players.map((p) => 'private-player-' + p.id), 'match-found', { matchId, server: address });

// The latest standings, kept for players who open the screen later
await pusher.trigger('cache-leaderboard-weekly', 'update', { top: await leaderboard.top(20) });
var rt = Realtime.Instance;

var me = await rt.Client.SubscribeAsync("private-player-" + Session.PlayerId);
me.Bind("match-found", (PusherEvent e) => rt.Post(() => Matchmaking.Join(JsonUtility.FromJson<Match>(e.Data))));

// Listen before subscribing, so the cached standings sent right after the subscription are not missed
rt.Client.BindAll((string eventName, PusherEvent e) =>
{
    if (e.ChannelName == "cache-leaderboard-weekly" && eventName == "update")
        rt.Post(() => LeaderboardUI.Render(JsonUtility.FromJson<Standings>(e.Data)));
});
await rt.Client.SubscribeAsync("cache-leaderboard-weekly");

trigger หนึ่งครั้งส่งได้สูงสุด 100 แชนเนล และนับเป็นหนึ่งข้อความต่อแชนเนลไม่ว่าจะมีผู้เล่นสมัครรับกี่คน ดูโค้ดฝั่งเซิร์ฟเวอร์สำหรับแบ็กเอนด์ได้ในคู่มือ Node.js และคู่มือ .NET

เคล็ดลับ

  • build แบบ WebGL เปิด socket จาก C# ไม่ได้ ในกรณีนี้ให้โหลด pusher-js บนหน้าเว็บ แล้วเรียกผ่านปลั๊กอิน .jslib ด้วยโฮสต์และแชนเนลเดียวกัน
  • บนมือถือ ให้ตัดการเชื่อมต่อเมื่อเกมหยุดชั่วคราว (OnApplicationPause) และเชื่อมต่อใหม่เมื่อกลับมาเล่น สำหรับข้อความขณะที่เกมปิดอยู่ ให้ใช้การแจ้งเตือนแบบพุช
  • แชนเนล presence รองรับสมาชิกสูงสุด 100 คน เหมาะกับล็อบบี้และปาร์ตี้ สำหรับจำนวน "ผู้เล่นออนไลน์" ทั้งเกม ดูผู้ชมจำนวนมาก
  • อีเวนต์ฝั่งไคลเอนต์ถูกจำกัดอัตราต่อการเชื่อมต่อ และแต่ละรายการนับเป็นข้อความ จึงควรส่งเมื่อสถานะเปลี่ยน ไม่ใช่ทุกเฟรม