Dokumentáció
A SMAASH Unity kliense a Photon Fusion 2 hálózati SDK-t használja a valós idejű multiplayer megvalósításához. Az alábbiakban azok az alapfogalmak szerepelnek, amelyek a kód megértéséhez szükségesek.
A NetworkBehaviour hozzáférést ad a Fusion-specifikus életciklus-metódusokhoz és a hálózati tulajdonságokhoz.
public class PlayerMovement : NetworkBehaviour { ... }A [Networked] attribútummal jelölt property-k értékét a Fusion automatikusan replikálja minden csatlakozott kliensnek. Az értéket csak az StateAuthority (a szerver/host) írhatja, a kliensek csak olvashatják.
// PlayerMovement.cs
[Networked] public bool IsFacingLeft { get; set; }
[Networked] public float NetworkSpeed { get; set; }
[Networked] public bool NetworkIsJumping { get; set; }A Fusion megkülönbözteti, hogy egy adott kliensnek van-e joga az objektum inputját küldeni, vagy az objektum állapotát írni:
HasInputAuthority: az a kliens, aki a karaktert vezérli (a helyi játékos). Például csak ő látja a saját kameráját, ő kezeli az inputot.HasStateAuthority: általában a host/szerver. Ő írja a[Networked]property-ket, ő hajtja végre a fizikát és a sebzés logikát.
// CameraController.cs – kamera csak a helyi játékosnál aktív
if (cam) cam.enabled = Object.HasInputAuthority;
// PlayerMovement.cs – animáció értékeket csak a szerver írja
if (Object.HasStateAuthority)
{
NetworkSpeed = Mathf.Abs(rb.velocity.x);
NetworkIsJumping = !IsGrounded();
}A Spawned() a Start()/Awake() hálózatos megfelelője: akkor hívódik, amikor a hálózati objektum létrejön és a Fusion már beállította az authority-ket. Emiatt itt biztonságos lekérdezni, hogy a kliens HasInputAuthority-e.
// PlayerMovement.cs
public override void Spawned()
{
rb = GetComponent<Rigidbody2D>();
if (Object.HasInputAuthority && (jumpButtonOwner == null || jumpButtonOwner == this))
{
jumpButtonOwner = this;
isJumpButtonOwner = true;
}
extraJumps = maxAirJumps;
SetupJumpButton();
}A Fusion nem a Unity Update()-jét, hanem a FixedUpdateNetwork()-öt használja a játéklogika futtatásához. Ez minden hálózati tick-ben fut (alapértelmezetten 30/s vagy 60/mp), és determinisztikus – azaz minden kliensen pontosan ugyanabban a sorrendben hajtódik végre, ami megelőzi a szinkronizációs problémákat.
// PlayerMovement.cs
public override void FixedUpdateNetwork()
{
if (isCountingDown) return;
PlayerHealth playerHealth = GetComponent<PlayerHealth>();
if (playerHealth != null && playerHealth.isDead)
{
rb.velocity = new Vector2(0, rb.velocity.y);
UpdateNetworkedAnimationValues();
return;
}
if (GetInput(out NetworkInputData data))
{
rb.velocity = new Vector2(data.moveInput.x * speed, rb.velocity.y);
}
// ...
}A GetInput() metódus csak egy azon a kliensen ad vissza adatot, ahol input történt – a többi kliensen üres struktúrát ad vissza.
A Render() minden képkockában fut (ellentétben a FixedUpdateNetwork()-kel), és kizárólag vizuális frissítésre való. Mivel a [Networked] értékek már szinkronban vannak, itt biztonságosan olvashatók az animáció értékeinek a beállításához.
// PlayerMovement.cs
public override void Render()
{
if (animator)
{
animator.SetFloat("speed", NetworkSpeed);
animator.SetBool("isJumping", NetworkIsJumping);
}
spriteRenderer.flipX = IsFacingLeft;
}Az RPC (Remote Procedure Call) olyan metódus, amelyet az egyik kliensen hívnak meg, de egy másik kliensen (vagy a szerveren) fut le. A Fusion RPC-k az [Rpc] attribútummal jelöltek, és meg kell adni, hogy honnan érkezik a hívás (RpcSources) és hova szól (RpcTargets).
A SMAASH-ban három fő RPC irányt használ a kód:
| Forrás → Cél | Mikor használják |
|---|---|
InputAuthority → StateAuthority |
A játékos kliens kér valamit a szervertől (pl. sebzés, lövés) |
StateAuthority → All |
A szerver eredményt küld minden kliensnek (pl. halál animáció) |
All → StateAuthority |
Bárki küldhet kérést a szervernek |
// PlayerHealth.cs – a kliens sebzést kér a szervertől
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
private void RPC_RequestDamage(int damage)
{
if (isDead) return;
CurrentHealth = Mathf.Max(0, CurrentHealth - damage);
if (CurrentHealth <= 0)
{
isDead = true;
RPC_BroadcastDeath(Object.InputAuthority.PlayerId);
}
}
// A szerver értesít mindenkit a halálról
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
private void RPC_BroadcastDeath(int deadPlayerId)
{
if (animator) animator.SetBool("isDead", true);
if (meleeAttack) meleeAttack.enabled = false;
if (playerMovement) playerMovement.enabled = false;
// ...
NetworkHandler.Instance.HandleMatchEnded(deadPlayerId);
}Az összes hálózaton átküldendő inputot tároló struktúra. Az INetworkInput interfész implementálása szükséges ahhoz, hogy a Fusion automatikusan kezelje a szállítását.
public struct NetworkInputData : INetworkInput
{
public Vector2 moveInput;
public bool jumpPressed;
}A GetNetworkInput() metódus billentyűzet és mobil joystick inputokat olvas, és egységes struktúrában adja vissza. A NetworkHandler.OnInput() hívja minden tick-ben.
public NetworkInputData GetNetworkInput()
{
NetworkInputData data = new NetworkInputData();
Vector2 keyboardInput = Vector2.zero;
if (Keyboard.current != null)
{
if (Keyboard.current.aKey.isPressed || Keyboard.current.leftArrowKey.isPressed)
keyboardInput.x = -1;
if (Keyboard.current.dKey.isPressed || Keyboard.current.rightArrowKey.isPressed)
keyboardInput.x = 1;
}
Vector2 joystickInput = Vector2.zero;
if (joystick != null)
joystickInput = new Vector2(joystick.Horizontal, joystick.Vertical);
// Billentyűzet prioritása van a joystick felett
data.moveInput = keyboardInput.magnitude > 0.1f ? keyboardInput : joystickInput;
bool keyboardJump = Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame;
data.jumpPressed = keyboardJump || jumpButtonPressed;
return data;
}A NetworkHandler az OnInput callbackben hívja ezt a metódust, és a Fusion-nak adja át:
// NetworkHandler.cs
public void OnInput(NetworkRunner runner, NetworkInput input)
{
var data = new NetworkInputData();
if (runner.TryGetPlayerObject(runner.LocalPlayer, out var playerObj))
{
var handler = playerObj.GetComponent<LocalInputHandler>();
if (handler != null)
data = handler.GetNetworkInput();
}
input.Set(data);
}A FixedUpdateNetwork()-ben a GetInput() kinyeri a kliens inputját, és a Rigidbody sebességét frissíti:
public override void FixedUpdateNetwork()
{
if (isCountingDown) return;
PlayerHealth playerHealth = GetComponent<PlayerHealth>();
if (playerHealth != null && playerHealth.isDead)
{
rb.velocity = new Vector2(0, rb.velocity.y);
UpdateNetworkedAnimationValues();
return;
}
if (GetInput(out NetworkInputData data))
rb.velocity = new Vector2(data.moveInput.x * speed, rb.velocity.y);
if (jumpRequestedFromButton)
{
jumpRequestedFromButton = false;
Jump();
}
UpdateNetworkedAnimationValues();
}Az ugrás logika megkülönbözteti a talajról és a levegőből történő ugrást (kettős ugrás implementációhoz):
void Jump()
{
if (IsGrounded())
{
rb.velocity = new Vector2(rb.velocity.x, jumpingPower);
extraJumps = maxAirJumps;
return;
}
if (extraJumps > 0)
{
rb.velocity = new Vector2(rb.velocity.x, jumpingPower);
extraJumps--;
}
}
// Talajérzékelés: kis sugarú körrel ellenőrzi a groundLayer-t
bool IsGrounded() => Physics2D.OverlapCircle(groundCheck.position, 0.2f, groundLayer);Az animációs értékeket a szerver frissíti (HasStateAuthority), de a Render() minden kliensen alkalmazza azokat:
void UpdateNetworkedAnimationValues()
{
if (Object.HasStateAuthority)
{
NetworkSpeed = Mathf.Abs(rb.velocity.x);
NetworkIsJumping = !IsGrounded();
if (rb.velocity.x > 0.1f) IsFacingLeft = false;
else if (rb.velocity.x < -0.1f) IsFacingLeft = true;
}
}
public override void Render()
{
if (animator)
{
animator.SetFloat("speed", NetworkSpeed);
animator.SetBool("isJumping", NetworkIsJumping);
}
spriteRenderer.flipX = IsFacingLeft;
}A közelharci támadás RPC-láncon keresztül működik: a kliens kér → szerver ellenőriz és sebez → szerver értesít mindenkit az animációról.
private void OnAttackInput(InputAction.CallbackContext context)
{
if (!canAttack) return;
RPC_PerformAttack(spriteRenderer.flipX);
StartCoroutine(AttackCooldown());
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RPC_PerformAttack(bool isFacingLeft)
{
Transform activePoint = isFacingLeft ? attackPointOpposite : attackPoint;
//Egy kört rajzol ki, ahol keresi az enemyLayerrel rendelkező objektumokat és vissza adja azt a változóba
Collider2D hitEnemy = Physics2D.OverlapCircle(activePoint.position, attackRange, enemyLayer);
if (hitEnemy != null)
{
if (hitEnemy.TryGetComponent<PlayerHealth>(out var health))
health.TakeDamageCaller(damage);
}
RPC_BroadcastAttack(); // animáció minden kliensen
}
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
private void RPC_BroadcastAttack()
{
StartCoroutine(PlayAttackAnimation());
}A lövedék hálózati spawnjához a kliens RPC-t küld a szervernek, amely létrehozza az objektumot a Fusion Runner.Spawn() metódusával:
private void OnAttackInput(InputAction.CallbackContext context)
{
if (!canAttack) return;
// A két kiindulási pont (jobbra vagy balra néz a karakter) közül kiválasztjuk azt, amelyik a megfelelő
Transform activePoint = spriteRenderer.flipX ? attackPointOpposite : attackPoint;
SpawnBulletRpc(activePoint.position, activePoint.rotation, spriteRenderer.flipX);
StartCoroutine(AttackCooldown());
}
[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
public void SpawnBulletRpc(Vector3 position, Quaternion rotation, bool facingLeft)
{
if (Runner != null && bulletPrefab.IsValid)
{
// Runner.Spawn: hálózati objektumot hoz létre, minden kliensnek replikálva
NetworkObject bulletNetObj = Runner.Spawn(bulletPrefab, position, rotation);
Bullet bullet = bulletNetObj.GetComponent<Bullet>();
if (bullet != null)
{
Vector2 fireDirection = facingLeft ? Vector2.left : Vector2.right;
bullet.SetDirection(fireDirection);
}
}
}A Bullet is NetworkBehaviour, így mozgása szinkronizált. Ütközéskor hálózati despawn történik:
public override void FixedUpdateNetwork()
{
if (rb != null)
rb.velocity = direction * speed;
}
public void SetDirection(Vector2 newDirection)
{
direction = newDirection.normalized;
// Elforgatja a lövedéket a jó irányba
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
transform.rotation = Quaternion.AngleAxis(angle, Vector3.forward);
}
void OnTriggerEnter2D(Collider2D collision)
{
if (collision.TryGetComponent<PlayerHealth>(out var health))
{
health.TakeDamageCaller(damage);
// Runner.Despawn: hálózaton minden kliensről eltávolítja az objektumot
if (Runner != null)
Runner.Despawn(Object);
else
Destroy(gameObject);
}
}Az életerő egy [Networked] property, amelynek változásakor automatikusan fut a UI frissítés:
[Networked, OnChangedRender(nameof(OnHealthChanged))]
private int CurrentHealth { get; set; }
[Networked] public bool isDead { get; set; }A Spawned()-ban a játékos PlayerId-je alapján dől el, melyik sarokba rakja a játékos életerejét:
public override void Spawned()
{
if (UIManager.Instance != null)
{
// PlayerId páratlan → bal felső sarok, páros → jobb felső sarok
if (Object.InputAuthority.PlayerId % 2 != 0)
myUIBar = UIManager.Instance.healthBar1;
else
myUIBar = UIManager.Instance.healthBar2;
}
if (Object.HasStateAuthority)
{
CurrentHealth = maxHealth;
isDead = false;
}
UpdateVisuals();
}A sebzés kétlépéses RPC-n keresztül megy:
// 1. lépés: a sérülést elszenvedő karakter bármely kliensről kérhet sebzést (ezt hívjuk meg a támadás scriptekből), paraméterként bekérjük a támadás által okozozz sebzés mértékét
public void TakeDamageCaller(int damage)
{
if (isDead) return;
RPC_RequestDamage(damage);
}
// 2. lépés: a szerver érvényesíti és végrehajtja
[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
private void RPC_RequestDamage(int damage)
{
if (isDead) return;
CurrentHealth = Mathf.Max(0, CurrentHealth - damage);
if (CurrentHealth <= 0)
{
isDead = true;
RPC_BroadcastDeath(Object.InputAuthority.PlayerId);
}
}
// 3. lépés: halál esemény szétküldése minden kliensnek, leáll a mozgás mindkét játékosnál és a NetworkHandle scriptből meghivja a HandleMatchEnded függvényt, ami a halott játékos Id-ját kéri be paraméterként
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
private void RPC_BroadcastDeath(int deadPlayerId)
{
if (animator) animator.SetBool("isDead", true);
if (meleeAttack) meleeAttack.enabled = false;
if (playerMovement) playerMovement.enabled = false;
var rb = GetComponent<Rigidbody2D>();
if (rb) rb.constraints = RigidbodyConstraints2D.FreezeAll;
NetworkHandler.Instance.HandleMatchEnded(deadPlayerId);
}A játék indításakor a NetworkHandler létrehoz egy NetworkRunner objektumot, és meghívja a Fusion StartGame() metódusát:
// Szoba létrehozása vagy szobához csatlakozás után fut le
public void RoomCreateAndJoin()
{
if (_pendingCharacterSelectMode == GameMode.Single)
{
StartGame(GameMode.Single, "LocalTestRoom");
return;
}
SceneManager.LoadScene(_waitingRoomSceneName);
// Use a coroutine to create/join room after scene loads
StartCoroutine(CreateOrJoinRoomAfterSceneLoad());
}
private IEnumerator CreateOrJoinRoomAfterSceneLoad()
{
// Wait for the scene to load
yield return null;
yield return null;
string roomName = string.IsNullOrWhiteSpace(_pendingRoomName) ? "DefaultRoom" : _pendingRoomName;
if (_pendingCharacterSelectMode == GameMode.Host)
{
Debug.Log("[NetworkHandler] Creating room: " + roomName);
StartGame(GameMode.Host, roomName);
}
else
{
Debug.Log("[NetworkHandler] Joining room: " + roomName);
StartGame(GameMode.Client, roomName);
}
}
async void StartGame(GameMode mode, string roomName)
{
if (_isConnecting || _isCancellingMatchmaking || _isDisposing) return;
_isConnecting = true;
// NetworkRunner: a Fusion kapcsolat motorja, alapértelmezetten szerepel a Fusion-ben – egy DontDestroyOnLoad objektumon él, tehát a jelenetek váltása alatt továbbra is fut ez a script
GameObject runnerObj = new GameObject("NetworkRunner");
DontDestroyOnLoad(runnerObj);
_runner = runnerObj.AddComponent<NetworkRunner>();
_runner.ProvideInput = true; // ez a kliens küld inputot
_runner.AddCallbacks(this); // a NetworkHandler kapja a Fusion callbackeket
runnerObj.AddComponent<NetworkSceneManagerDefault>();
await _runner.StartGame(new StartGameArgs
{
GameMode = mode, // Host, Client vagy Single (tesztelésre)
SessionName = _lastRoomName,
PlayerCount = mode == GameMode.Single ? 1 : 2,
SceneManager = _runner.GetComponent<NetworkSceneManagerDefault>()
});
}Amikor egy játékos csatlakozik (OnPlayerJoined), a host elmenti a karakterválasztást és ellenőrzi, megvan-e már mindenki:
public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
int playerCount = runner.ActivePlayers.Count();
UpdateWaitingRoomStatus(playerCount);
if (player == runner.LocalPlayer)
{
int mySelection = PlayerPrefs.GetInt("selectedOption", 0);
if (runner.IsServer)
{
_playerSelections.Add(player, mySelection);
CheckStartCondition(runner);
}
else
{
// Kliens reliable adatként küldi a karakterválasztást a hostnak
runner.SendReliableDataToServer(default, BitConverter.GetBytes(mySelection));
}
}
}
private void CheckStartCondition(NetworkRunner runner)
{
if (!runner.IsServer || _sceneLoadRequested) return;
if (runner.ActivePlayers.Count() >= 2 && _playerSelections.Count >= 2)
{
_sceneLoadRequested = true;
runner.LoadScene(_gameSceneName); // Fusion-on keresztül tölti be a jelenetet mindenkinél
}
}Jelenet betöltése után a szerver spawnolja a karaktereket a megfelelő spawn pontokra:
public void OnSceneLoadDone(NetworkRunner runner)
{
if (!runner.IsServer) return;
foreach (var player in runner.ActivePlayers)
{
if (_spawnedCharacters.ContainsKey(player)) continue;
int index = _playerSelections.TryGetValue(player, out int sel) ? sel : 0;
Character characterData = characterDatabase.GetCharacter(index);
bool isLeftSide = (player == runner.LocalPlayer);
string spawnPointName = isLeftSide ? player1SpawnPointName : player2SpawnPointName;
GameObject spawnPointObj = GameObject.Find(spawnPointName);
Vector3 spawnPos = spawnPointObj.transform.position;
// runner.Spawn: hálózati prefabot hoz létre, az adott PlayerRef ownership-jével
NetworkObject obj = runner.Spawn(
characterData.playerPrefab, spawnPos, spawnPointObj.transform.rotation, player);
// SetPlayerObject: összeköti a PlayerRef-et a hálózati objektummal
// – ez szükséges ahhoz, hogy az OnInput callback tudja, ki küldte az inputot
runner.SetPlayerObject(player, obj);
_spawnedCharacters.Add(player, obj);
}
}Játékos halála után a NetworkHandler összegyűjti az adatokat és elküldi a backend API-nak:
private IEnumerator PostMatchResultAndReturnToLobby(int deadPlayerId)
{
string endedAt = DateTime.UtcNow.ToString("yyyy-MM-dd");
int localPhotonPlayerId = _runner != null ? _runner.LocalPlayer.PlayerId : -1;
string localResult = localPhotonPlayerId == deadPlayerId ? "lose" : "win";
string networkStatus = _lastGameMode == GameMode.Single ? "offline" : "online";
var payload = new MatchResultDto
{
session_id = ResolveSessionId(),
started_at = _matchStartedAt,
ended_at = endedAt,
level_id = levelId,
participation = new MatchParticipationDto
{
player_id = PlayerPrefs.GetInt("selected_profile_id", -1),
character_id = ResolveLocalCharacterId(),
result = localResult,
network_status = networkStatus
}
};
// AuthClient.PostAuthorizedJson elvégzi a tényleges HTTP POST kérést
// matchResultEndpoint: a végpont ahova a POST megy
yield return StartCoroutine(authClient.PostAuthorizedJson(
matchResultEndpoint, payload, (ok, body) =>
{
if (!ok) Debug.LogWarning($"Match result post failed: {body}");
else Debug.Log($"Match result posted: {body}");
}));
CancelMatchmaking();
}A session azonosító generálása: a Fusion szoba nevéből determinisztikus GUID jön létre MD5 hash segítségével, hogy az adatbázis ugyanazt az azonosítót kapja minden klienstől:
private static string ToDeterministicGuid(string value)
{
if (Guid.TryParse(value, out var parsed))
return parsed.ToString();
string normalized = string.IsNullOrWhiteSpace(value) ? "smaash-session" : value.Trim();
using var md5 = MD5.Create();
byte[] hash = md5.ComputeHash(Encoding.UTF8.GetBytes(normalized));
return new Guid(hash).ToString();
}Az AuthClient osztály felelős minden, a SMAASH webes backenddel folytatott kommunikációért. A backend URL konfigurálható: fejlesztés alatt localhost, élesben a https://smaash-web.onrender.com cím aktív.
[SerializeField] private bool useLocalhost = false;
[SerializeField] private string localhostUrl = "http://localhost:8080";
[SerializeField] private string deployedUrl = "https://smaash-web.onrender.com";
public string BaseUrl => (useLocalhost ? localhostUrl : deployedUrl).TrimEnd('/');A bejelentkezési kérés egy UnityWebRequest POST hívás JSON törzzsel. Siker esetén a kapott JWT tokeneket a PlayerPrefs-be menti, amelyek az alkalmazás újraindítása után is elérhetők.
private IEnumerator Login(string email, string password, Action<bool, string> done)
{
var json = JsonUtility.ToJson(new GameLoginRequest { email = email, password = password });
using var req = new UnityWebRequest($"{BaseUrl}/api/game-login", "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
done(false, req.downloadHandler.text);
yield break;
}
var resp = JsonUtility.FromJson<GameLoginResponse>(req.downloadHandler.text);
SaveTokens(resp.accessToken, resp.refreshToken);
done(true, "");
}Alkalmazás indításakor a TryAutoLogin() ellenőrzi a mentett tokeneket. Ha az access token még érvényes, egyenesen a profilválasztóba lép; ha lejárt de van refresh token, megpróbálja megújítani:
private IEnumerator TryAutoLogin()
{
string savedAccess = PlayerPrefs.GetString(AccessKey, "");
string savedRefresh = PlayerPrefs.GetString(RefreshKey, "");
if (string.IsNullOrEmpty(savedAccess) && string.IsNullOrEmpty(savedRefresh))
yield break;
// Ha az access token még érvényes, nem kell bejelentkezni
if (!string.IsNullOrEmpty(savedAccess) && IsJwtNotExpired(savedAccess))
{
AccessToken = savedAccess;
if (SceneManager.GetActiveScene().name != profileSelectScene)
SceneManager.LoadScene(profileSelectScene);
yield break;
}
// Access token lejárt → megpróbálja refresh tokennel megújítani
if (!string.IsNullOrEmpty(savedRefresh))
{
bool refreshed = false;
yield return RefreshToken(ok => refreshed = ok);
if (refreshed && SceneManager.GetActiveScene().name != profileSelectScene)
SceneManager.LoadScene(profileSelectScene);
}
}
private IEnumerator RefreshToken(Action<bool> done)
{
string currentRefresh = PlayerPrefs.GetString(RefreshKey, "");
if (string.IsNullOrEmpty(currentRefresh))
{
done?.Invoke(false);
yield break;
}
var json = JsonUtility.ToJson(new RefreshRequestDto { refreshToken = currentRefresh });
using var req = new UnityWebRequest($"{BaseUrl}/api/game-refresh", "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success || req.responseCode != 200)
{
ClearTokens();
done?.Invoke(false);
yield break;
}
var resp = JsonUtility.FromJson<RefreshResponseDto>(req.downloadHandler.text);
SaveTokens(resp.accessToken, resp.refreshToken);
done?.Invoke(true);
}A token lejáratát a JWT payload exp mezőjéből ellenőrzi, base64 dekódolással:
private bool IsJwtNotExpired(string jwt)
{
var parts = jwt.Split('.');
if (parts.Length < 2) return false;
try
{
var payload = JsonUtility.FromJson<JwtPayload>(DecodeBase64Url(parts[1]));
return payload != null && payload.exp > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
catch { return false; }
}
private string DecodeBase64Url(string s)
{
// JWT base64url formátum: '+' helyett '-', '/' helyett '_', padding nélkül
s = s.Replace('-', '+').Replace('_', '/');
switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; }
return Encoding.UTF8.GetString(Convert.FromBase64String(s));
}A profil lista lekérésekor először a JWT-ből kinyeri a felhasználói azonosítót, majd autorizált GET kérést küld:
public IEnumerator GetMyProfiles(Action<bool, PlayerProfileDto[]> done)
{
string token = AccessToken;
if (string.IsNullOrEmpty(token))
token = PlayerPrefs.GetString(AccessKey, "");
// User ID kinyerése a JWT sub mezőjéből
int userId = GetUserIdFromToken(token);
if (userId < 0) { done(false, null); yield break; }
using var req = UnityWebRequest.Get($"{BaseUrl}/api/users/{userId}/profiles");
req.SetRequestHeader("Authorization", $"Bearer {token}");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
done(false, null);
yield break;
}
var rawJson = req.downloadHandler.text?.Trim();
PlayerProfileDto[] profiles;
// A backend válasz lehet sima JSON tömb vagy wrapper objektum – mindkettőt kezeli
if (!string.IsNullOrEmpty(rawJson) && rawJson.StartsWith("["))
profiles = JsonHelper.FromJsonArray<PlayerProfileDto>(rawJson);
else
{
var resp = JsonUtility.FromJson<PlayerProfileListResponse>(rawJson);
profiles = resp != null ? resp.profiles : Array.Empty<PlayerProfileDto>();
}
done(true, profiles);
}A GetUserIdFromToken() a JWT közepső (payload) szegmensét dekódolja és kinyeri a sub mezőt:
private int GetUserIdFromToken(string token)
{
var parts = token.Split('.');
if (parts.Length < 2) return -1;
try
{
var json = DecodeBase64Url(parts[1]);
var payload = JsonUtility.FromJson<GameJwtPayload>(json);
if (payload == null || payload.sub <= 0) return -1;
return (int)payload.sub;
}
catch { return -1; }
}Ez a metódus újrafelhasználható minden olyan kéréshez, amely JWT tokent igényel (pl. meccs eredmény beküldés):
public IEnumerator PostAuthorizedJson<TPayload>(string endpoint, TPayload payload, Action<bool, string> done)
{
string token = AccessToken;
if (string.IsNullOrWhiteSpace(token))
token = PlayerPrefs.GetString(AccessKey, "");
string json = JsonUtility.ToJson(payload);
using var req = new UnityWebRequest($"{BaseUrl}{endpoint}", UnityWebRequest.kHttpVerbPOST);
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("Accept", "application/json");
req.SetRequestHeader("Authorization", $"Bearer {token}"); // JWT Bearer token
yield return req.SendWebRequest();
bool ok = req.result == UnityWebRequest.Result.Success
&& req.responseCode >= 200 && req.responseCode < 300;
string body = req.downloadHandler?.text ?? $"HTTP {req.responseCode}";
done?.Invoke(ok, body);
}A Unity JsonUtility nem tudja kezelni a gyökér szintű JSON tömböket ([{...},{...}]). A JsonHelper wrapper objektumba csomagolja a tömböt, majd azt használja:
public static T[] FromJsonArray<T>(string json)
{
if (string.IsNullOrEmpty(json)) return Array.Empty<T>();
// Unity JsonUtility trükk: a tömböt egy {"items": [...]} objektumba csomagolja
var wrapped = "{\"items\":" + json + "}";
var result = JsonUtility.FromJson<ArrayWrapper<T>>(wrapped);
return result != null && result.items != null ? result.items : Array.Empty<T>();
}A profilképeket a backend külön végpontján éri el a játék, autorizált textúra kérésekkel:
private IEnumerator LoadAvatarFromUri(string uri, Image targetImage)
{
using var request = UnityWebRequestTexture.GetTexture(uri);
string token = authClient != null ? authClient.AccessToken : string.Empty;
if (string.IsNullOrWhiteSpace(token))
token = PlayerPrefs.GetString("access_token", "");
if (!string.IsNullOrWhiteSpace(token))
request.SetRequestHeader("Authorization", $"Bearer {token}");
request.SetRequestHeader("Accept", "image/*");
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success) yield break;
// A letöltött textúrából Unity Sprite-ot hoz létre
var texture = DownloadHandlerTexture.GetContent(request);
var sprite = Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
100f
);
targetImage.sprite = sprite;
targetImage.preserveAspect = true;
}ScriptableObject-ként létrehozott asset, amely az összes karaktert tartalmazza. A Unity Editorban szerkeszthető, és a NetworkHandler is hivatkozik rá a spawn során.
| Mező/Tulajdonság | Típus | Leírás |
|---|---|---|
character[] |
Character[] |
Karakterek tömbje |
CharacterCount |
int (get) |
Karakterek száma |
GetCharacter(index) |
Character |
Adott indexű karakter visszaadása |
| Mező | Típus | Leírás |
|---|---|---|
character_id |
int |
Backend adatbázis azonosítója |
characterSprite |
Sprite |
Karakterválasztón megjelenő kép |
character_name |
string |
Karakter neve |
playerPrefab |
NetworkPrefabRef |
Fusion által kezelt prefab referencia (nem sima GameObject) |
A karakterválasztó képernyő vezérlője. A NextOption() és BackOption() metódusok körbejárással lépteti a karaktereket és menti a választást PlayerPrefs-be:
public void NextOption()
{
selectedOption++;
if (selectedOption >= characterDatabase.CharacterCount)
selectedOption = 0;
UpdateCharacter(selectedOption);
Save();
}
private void UpdateCharacter(int selectedOption)
{
Character character = characterDatabase.GetCharacter(selectedOption);
artworkSprite.sprite = character.characterSprite;
nameText.text = character.character_name;
}
private void Save()
{
PlayerPrefs.SetInt("selectedOption", selectedOption);
PlayerPrefs.SetString("character_name", nameText.text);
PlayerPrefs.Save();
}