483 lines
16 KiB
C#
483 lines
16 KiB
C#
|
using BulletHellTemplate.Core.Events;
|
|||
|
using BulletHellTemplate.VFX;
|
|||
|
using Cysharp.Threading.Tasks;
|
|||
|
#if FUSION2
|
|||
|
using Fusion;
|
|||
|
#endif
|
|||
|
using UnityEngine;
|
|||
|
|
|||
|
namespace BulletHellTemplate
|
|||
|
{
|
|||
|
/// <summary>
|
|||
|
/// Networked singleton object that replicates global gameplay counters.
|
|||
|
/// Place it in the scene or let the Host spawn it via SpawnAsync.
|
|||
|
/// </summary>
|
|||
|
#if FUSION2
|
|||
|
[RequireComponent(typeof(NetworkObject))]
|
|||
|
#endif
|
|||
|
public sealed class GameplaySync :
|
|||
|
#if FUSION2
|
|||
|
NetworkBehaviour
|
|||
|
#else
|
|||
|
MonoBehaviour
|
|||
|
#endif
|
|||
|
{
|
|||
|
public static GameplaySync Instance { get; private set; }
|
|||
|
private GameplayManager GM => GameplayManager.Singleton;
|
|||
|
public enum PerkKind : byte { SkillPerk = 0, StatPerk = 1, BaseSkill = 2 }
|
|||
|
private void Awake()
|
|||
|
{
|
|||
|
if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); }
|
|||
|
else Destroy(gameObject);
|
|||
|
}
|
|||
|
|
|||
|
private void OnDestroy()
|
|||
|
{
|
|||
|
if (Instance == this) Instance = null;
|
|||
|
}
|
|||
|
|
|||
|
#if FUSION2
|
|||
|
// ───── Utility Properties ─────
|
|||
|
[Networked, OnChangedRender(nameof(OnMatchStarted))]
|
|||
|
public NetworkBool MatchStarted { get; set; }
|
|||
|
[Networked, OnChangedRender(nameof(OnTimerChanged))]
|
|||
|
public int TimerSecs { get; set; }
|
|||
|
private float _secAcc = 0f;
|
|||
|
public bool RunnerActive => Runner && Runner.IsRunning;
|
|||
|
public bool IsHost => RunnerActive && HasStateAuthority;
|
|||
|
|
|||
|
|
|||
|
public override void FixedUpdateNetwork()
|
|||
|
{
|
|||
|
if (!HasStateAuthority || !MatchStarted)
|
|||
|
return;
|
|||
|
|
|||
|
_secAcc += Runner.DeltaTime;
|
|||
|
if (_secAcc >= 1f)
|
|||
|
{
|
|||
|
_secAcc -= 1f;
|
|||
|
|
|||
|
if (TimerSecs > 0)
|
|||
|
{
|
|||
|
TimerSecs--;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// ───── RPCs called on all clients ─────
|
|||
|
private void OnMatchStarted()
|
|||
|
{
|
|||
|
if (!MatchStarted) return;
|
|||
|
|
|||
|
if (GameplayManager.Singleton)
|
|||
|
GameplayManager.Singleton.upgradeMode = UpgradeMode.UpgradeOnButtonClick;
|
|||
|
|
|||
|
GameplayManager.Singleton?.StartGameplay();
|
|||
|
|
|||
|
if (HasStateAuthority)
|
|||
|
TimerSecs = GameplayManager.Singleton.GetSurvivalTime();
|
|||
|
}
|
|||
|
|
|||
|
private void OnTimerChanged()
|
|||
|
{
|
|||
|
if (GameplayManager.Singleton)
|
|||
|
GameplayManager.Singleton.SetTimeRemaining(TimerSecs);
|
|||
|
|
|||
|
EventBus.Publish(new GameTimerTickEvent(TimerSecs));
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.All, RpcTargets.All, InvokeLocal = false)]
|
|||
|
private void RPC_PlayerDied(NetworkId playerId)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
var ce = nobj.GetComponent<CharacterEntity>();
|
|||
|
if (ce && !ce.IsDead) ce.OnDeath();
|
|||
|
}
|
|||
|
|
|||
|
public void SyncPlayerDied(CharacterEntity ce)
|
|||
|
{
|
|||
|
if (!RunnerActive) return;
|
|||
|
var nob = ce.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
RPC_PlayerDied(nob.Id);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.All, RpcTargets.All, InvokeLocal = false)]
|
|||
|
private void RPC_PlayerRevived(NetworkId playerId)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
var ce = nobj.GetComponent<CharacterEntity>();
|
|||
|
if (ce && ce.IsDead) ce.CharacterRevive();
|
|||
|
|
|||
|
// fechar UI de revive no dono (se for local e é ele)
|
|||
|
if (nobj.HasInputAuthority) UIGameplay.Singleton?.HideRevivePanel();
|
|||
|
}
|
|||
|
|
|||
|
public void SyncPlayerRevived(CharacterEntity ce)
|
|||
|
{
|
|||
|
if (!RunnerActive) return;
|
|||
|
var nob = ce.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
|
|||
|
RPC_PlayerRevived(nob.Id);
|
|||
|
if (HasStateAuthority) RPC_DeathEnd(nob.Id);
|
|||
|
}
|
|||
|
|
|||
|
// UI de revive para dono do personagem
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_StartReviveCountdown(NetworkId playerId, int seconds)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
if (nobj.HasInputAuthority)
|
|||
|
UIGameplay.Singleton?.ShowRevivePanel(seconds);
|
|||
|
}
|
|||
|
public void NotifyStartReviveCountdown(CharacterEntity ce, int seconds)
|
|||
|
{
|
|||
|
if (!RunnerActive || !HasStateAuthority) return;
|
|||
|
var nob = ce.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
|
|||
|
RPC_StartReviveCountdown(nob.Id, seconds);
|
|||
|
RPC_DeathStart(nob.Id, seconds);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_ReviveTick(NetworkId playerId, int remain)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
if (nobj.HasInputAuthority)
|
|||
|
UIGameplay.Singleton?.UpdateReviveCountdown(remain);
|
|||
|
}
|
|||
|
public void NotifyReviveTick(CharacterEntity ce, int remain)
|
|||
|
{
|
|||
|
if (!RunnerActive || !HasStateAuthority) return;
|
|||
|
var nob = ce.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
|
|||
|
RPC_ReviveTick(nob.Id, remain);
|
|||
|
RPC_DeathTick(nob.Id, remain);
|
|||
|
}
|
|||
|
|
|||
|
/// <summary>
|
|||
|
/// Broadcast a team defeat (no revive online). Called only by StateAuthority.
|
|||
|
/// </summary>
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_TeamDefeat()
|
|||
|
{
|
|||
|
GameplayManager.Singleton.SetMatchFinished();
|
|||
|
GameplayManager.Singleton.CancelAllRevives();
|
|||
|
|
|||
|
GameplayManager.Singleton.PauseGame();
|
|||
|
UIGameplay.Singleton.DisplayEndGameScreen(false);
|
|||
|
UIGameplay.Singleton.ClearAllDeathEntries();
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.All, RpcTargets.All, InvokeLocal = false)]
|
|||
|
private void RPC_ApplyPerk(NetworkId playerId, PerkKind kind, int index)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
var ce = nobj.GetComponent<CharacterEntity>();
|
|||
|
if (!ce) return;
|
|||
|
|
|||
|
var gm = GameplayManager.Singleton;
|
|||
|
|
|||
|
switch (kind)
|
|||
|
{
|
|||
|
case PerkKind.SkillPerk:
|
|||
|
var sp = gm.skillPerkData[index];
|
|||
|
gm.SetPerkLevel(sp);
|
|||
|
ce.GetComponent<CharacterAttackComponent>()?.ApplySkillPerk(sp, /*isReplica*/ true);
|
|||
|
break;
|
|||
|
|
|||
|
case PerkKind.StatPerk:
|
|||
|
var st = gm.statPerkData[index];
|
|||
|
gm.SetPerkLevel(st);
|
|||
|
ce.ApplyStatPerk(st, gm.GetPerkLevel(st));
|
|||
|
break;
|
|||
|
|
|||
|
case PerkKind.BaseSkill:
|
|||
|
var bs = ce.GetCharacterData().skills[index];
|
|||
|
gm.LevelUpBaseSkill(bs);
|
|||
|
break;
|
|||
|
}
|
|||
|
|
|||
|
UIGameplay.Singleton?.UpdateSkillPerkUI();
|
|||
|
UIGameplay.Singleton?.UpdateStatPerkUI();
|
|||
|
}
|
|||
|
|
|||
|
// ── Death feed ─────────────────────────────────
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_DeathStart(NetworkId playerId, int seconds)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
var ce = nobj.GetComponent<CharacterEntity>();
|
|||
|
UIGameplay.Singleton?.ShowDeathEntry(nobj, ce, seconds);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_DeathTick(NetworkId playerId, int remain)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
UIGameplay.Singleton?.UpdateDeathCountdown(nobj, remain);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_DeathEnd(NetworkId playerId)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
UIGameplay.Singleton?.HideDeathEntry(nobj);
|
|||
|
}
|
|||
|
|
|||
|
public void SyncPerkChosen(CharacterEntity ce, PerkKind kind, int index)
|
|||
|
{
|
|||
|
if (!RunnerActive) return;
|
|||
|
var nob = ce.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
RPC_ApplyPerk(nob.Id, kind, index);
|
|||
|
}
|
|||
|
|
|||
|
/// <summary>
|
|||
|
/// Host/leader calls this when he detects a full team wipe.
|
|||
|
/// </summary>
|
|||
|
public void SyncTeamDefeat()
|
|||
|
{
|
|||
|
if (!RunnerActive || !HasStateAuthority)
|
|||
|
return;
|
|||
|
|
|||
|
RPC_TeamDefeat();
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.All, RpcTargets.All)]
|
|||
|
private void RPC_SetGold(int total)
|
|||
|
{
|
|||
|
if (!HasStateAuthority)
|
|||
|
GM.ForceSetGold(total);
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
[Rpc(RpcSources.All, RpcTargets.All)]
|
|||
|
private void RPC_SetXP(int total)
|
|||
|
{
|
|||
|
if (!HasStateAuthority)
|
|||
|
GM.ForceSetXP(total);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_SetMonstersKilled(int total)
|
|||
|
{
|
|||
|
if (!HasStateAuthority)
|
|||
|
GM.ForceSetMonstersKilled(total);
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
// ───── APIs called by GameplayManager ─────
|
|||
|
public void SyncGold(int total)
|
|||
|
{
|
|||
|
if (!RunnerActive)
|
|||
|
GM.ForceSetGold(total); // offline mode
|
|||
|
else if (IsHost)
|
|||
|
RPC_SetGold(total); // host sends
|
|||
|
}
|
|||
|
|
|||
|
public void SyncXP(int total)
|
|||
|
{
|
|||
|
if (!RunnerActive)
|
|||
|
GM.ForceSetXP(total);
|
|||
|
else if (IsHost)
|
|||
|
RPC_SetXP(total);
|
|||
|
}
|
|||
|
|
|||
|
public void SyncMonsters(int total)
|
|||
|
{
|
|||
|
if (!RunnerActive)
|
|||
|
GM.ForceSetMonstersKilled(total);
|
|||
|
else if (IsHost)
|
|||
|
RPC_SetMonstersKilled(total);
|
|||
|
}
|
|||
|
/// <summary>
|
|||
|
/// RPC that tells _all_ clients to spawn a drop for the given monster.
|
|||
|
/// </summary>
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_SpawnDropForMonster(NetworkId monsterId, bool isGold, Vector3 position, int amount)
|
|||
|
{
|
|||
|
|
|||
|
if (!Runner.TryFindObject(monsterId, out var nobj))
|
|||
|
return;
|
|||
|
|
|||
|
var me = nobj.GetComponent<BaseMonsterEntity>();
|
|||
|
if (me == null)
|
|||
|
return;
|
|||
|
|
|||
|
var prefab = isGold ? me.GoldDropPrefab : me.ExpDropPrefab;
|
|||
|
if (prefab == null)
|
|||
|
return;
|
|||
|
|
|||
|
var drop = DropPool.Spawn(prefab, position);
|
|||
|
drop.SetValue(amount);
|
|||
|
}
|
|||
|
|
|||
|
/// <summary>
|
|||
|
/// Called by Host to sync a drop on all clients.
|
|||
|
/// </summary>
|
|||
|
public void SyncSpawnDrop(BaseMonsterEntity monster, bool isGold, Vector3 position, int amount)
|
|||
|
{
|
|||
|
if (!RunnerActive || !HasStateAuthority)
|
|||
|
return;
|
|||
|
|
|||
|
var nob = monster.GetComponent<NetworkObject>();
|
|||
|
if (nob == null)
|
|||
|
return;
|
|||
|
|
|||
|
RPC_SpawnDropForMonster(nob.Id, isGold, position, amount);
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
/// <summary>
|
|||
|
/// RPC to configure monster values after it has been spawned.
|
|||
|
/// Called on all clients. The target monster is identified by its NetworkId.
|
|||
|
/// </summary>
|
|||
|
/// <param name="netId">Network ID of the monster</param>
|
|||
|
/// <param name="gold">Gold reward value</param>
|
|||
|
/// <param name="xp">XP reward value</param>
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_ConfigMonster(NetworkId netId, int gold, int xp)
|
|||
|
{
|
|||
|
if (Runner.TryFindObject(netId, out var nobj))
|
|||
|
{
|
|||
|
var me = nobj.GetComponent<MonsterEntity>();
|
|||
|
me?.ConfigureMonster(gold, xp);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/// <summary>
|
|||
|
/// Called by the Host after SpawnAsync to synchronize monster configuration.
|
|||
|
/// Does nothing in offline mode. Only the Host should call this.
|
|||
|
/// </summary>
|
|||
|
/// <param name="nobj">Spawned NetworkObject</param>
|
|||
|
/// <param name="gold">Gold reward value</param>
|
|||
|
/// <param name="xp">XP reward value</param>
|
|||
|
public void SyncConfigureMonster(NetworkObject nobj, int gold, int xp)
|
|||
|
{
|
|||
|
if (!RunnerActive) return;
|
|||
|
if (IsHost)
|
|||
|
RPC_ConfigMonster(nobj.Id, gold, xp);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_MonsterSkillCast(NetworkId monsterId, int skillIndex, Vector3 dir)
|
|||
|
{
|
|||
|
if (Runner.TryFindObject(monsterId, out var nobj))
|
|||
|
{
|
|||
|
var me = nobj.GetComponent<MonsterEntity>();
|
|||
|
me?.SkillRunner?.ExecuteSkill_Remote(skillIndex, dir);
|
|||
|
|
|||
|
me?.PlaySkillAnimNet(skillIndex);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
public void SyncMonsterSkillCast(MonsterEntity monster, int skillIndex, Vector3 dir)
|
|||
|
{
|
|||
|
if (!RunnerActive || !IsHost) return;
|
|||
|
var nob = monster.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
RPC_MonsterSkillCast(nob.Id, skillIndex, dir);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_MonsterHp(NetworkId monsterId, float hp)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(monsterId, out var nobj))
|
|||
|
return;
|
|||
|
|
|||
|
if (nobj.HasStateAuthority)
|
|||
|
return;
|
|||
|
|
|||
|
var mh = nobj.GetComponent<MonsterHealth>();
|
|||
|
mh?.ApplyRemoteHp(hp);
|
|||
|
}
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_BossSpawned(NetworkId bossId)
|
|||
|
{
|
|||
|
if (Runner.TryFindObject(bossId, out var nobj))
|
|||
|
{
|
|||
|
var me = nobj.GetComponent<MonsterEntity>();
|
|||
|
UIGameplay.Singleton?.SetFinalBoss(me);
|
|||
|
UIGameplay.Singleton?.ShowFinalBossMessage();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
public void SyncBossSpawned(MonsterEntity boss)
|
|||
|
{
|
|||
|
if (!RunnerActive || !HasStateAuthority) return;
|
|||
|
var nob = boss.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
RPC_BossSpawned(nob.Id);
|
|||
|
}
|
|||
|
|
|||
|
// ─── RPC for auto‐attack replication ───────────────────────────────────
|
|||
|
|
|||
|
[Rpc(RpcSources.All, RpcTargets.All, InvokeLocal = false)]
|
|||
|
private void RPC_PlayerAttack(NetworkId playerId, Vector3 dir)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
|
|||
|
var comp = nobj.GetComponent<CharacterAttackComponent>();
|
|||
|
if (comp == null) return;
|
|||
|
|
|||
|
bool replica = !nobj.HasStateAuthority;
|
|||
|
comp.AttackInternal(dir, replica);
|
|||
|
}
|
|||
|
|
|||
|
public void SyncPlayerAttack(CharacterEntity ce, Vector3 dir)
|
|||
|
{
|
|||
|
if (!RunnerActive) return;
|
|||
|
var nob = ce.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
RPC_PlayerAttack(nob.Id, dir);
|
|||
|
}
|
|||
|
|
|||
|
// ───────── SKILL ───────────────────────────────────────────────
|
|||
|
[Rpc(RpcSources.All, RpcTargets.All, InvokeLocal = false)]
|
|||
|
private void RPC_PlayerUseSkill(NetworkId playerId, int idx, Vector2 input)
|
|||
|
{
|
|||
|
if (!Runner.TryFindObject(playerId, out var nobj)) return;
|
|||
|
|
|||
|
var atk = nobj.GetComponent<CharacterAttackComponent>();
|
|||
|
if (atk == null) return;
|
|||
|
|
|||
|
var skill = atk.CharacterOwner.GetCharacterData().skills[idx];
|
|||
|
bool replica = !nobj.HasStateAuthority; // host
|
|||
|
atk.UseSkillInternal(idx, skill, input, replica);
|
|||
|
}
|
|||
|
|
|||
|
public void SyncPlayerUseSkill(CharacterEntity ce, int idx, Vector2 input)
|
|||
|
{
|
|||
|
if (!RunnerActive) return;
|
|||
|
var nob = ce.GetComponent<NetworkObject>();
|
|||
|
if (!nob) return;
|
|||
|
RPC_PlayerUseSkill(nob.Id, idx, input);
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
|
|||
|
private void RPC_EndGame()
|
|||
|
{
|
|||
|
if (!HasStateAuthority)
|
|||
|
GameplayManager.Singleton.EndGame();
|
|||
|
}
|
|||
|
|
|||
|
/// <summary>
|
|||
|
/// API pública: apenas o Host Shared‑Mode deve chamar para encerrar o jogo.
|
|||
|
/// </summary>
|
|||
|
public void SyncEndGame()
|
|||
|
{
|
|||
|
if (!Runner || !Runner.IsRunning || !HasStateAuthority)
|
|||
|
return;
|
|||
|
|
|||
|
RPC_EndGame();
|
|||
|
}
|
|||
|
#endif
|
|||
|
}
|
|||
|
}
|