Add networked Fighter3D minigame

This commit is contained in:
ud18010
2026-08-10 16:47:16 +08:00
parent bff617091d
commit ef7f24738e
53 changed files with 5458 additions and 19 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 85085aa41f000aa4dbdb531fd2f5202b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,13 @@
# Fighter3D
Networked 2.5D fighting mini game prototype.
## MVP Controls
- `A` / `LeftArrow`: move left
- `D` / `RightArrow`: move right
- `S` / `DownArrow` / `LeftShift`: guard
- `J`: light attack
- `K`: heavy attack
The server room is authoritative for combat state, damage, block, KO, timeout, and settlement.
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 55544ae8081da7b48835dd846c66ff7d
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"gameId": "fighter3d",
"serverSrcDir": "Fighter3D",
"serverProject": "Fighter3D.Server/Fighter3D.Server.csproj",
"serverAssembly": "Fighter3D.Server.dll",
"serverEntryType": "Fighter3D.Server.FighterServerRoom",
"clientProject": "Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj",
"coreDll": "Fighter3D.Core.dll",
"clientDll": "Fighter3D.Client.dll",
"clientEntryType": "Fighter3D.Client.FighterGameClient",
"minFrameworkVersion": 1,
"playerCount": 2,
"tickRateHz": 20
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e0ac72934f4783e4f88a7564bac49bd6
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6eb52d097b913dd4a9bff20cb4b0247d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8338e8813a2215943a9d02c93539a5a8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f43bad7e65f7b9242a7dfcaa0aefe943
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,5 @@
{
"type": "fighter3d",
"version": 1,
"buffs": []
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: eed808c579abf7c46be3ece4492e802c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
{
"type": "fighter3d",
"version": 1,
"flyObjects": [
{
"id": "fighter3d_blade_wave",
"duration": 1.2,
"movement": { "kind": "Line", "speed": 6.0 },
"collision": { "shape": "Sphere", "radius": 0.4, "hitLimit": 1 },
"timeline": [
{ "time": 0.0, "kind": "Effect", "prefab": "Assets/Game/Art/Effect/Prefab/skill/9110009_zhujue_quantao_skill02d_1.prefab", "duration": 1.2 }
],
"logicEffects": []
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1223a987b29ef3b4a9dd4ec31da27697
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
{
"type": "fighter3d",
"version": 1,
"behaviors": [
{
"id": "fighter3d_light",
"duration": 0.45,
"canBeInterrupted": true,
"input": "DirectionOrTarget",
"timeline": [
{ "time": 0.0, "kind": "Animation", "animation": "140_skill01a_qt", "duration": 0.45 },
{ "time": 0.12, "kind": "Effect", "prefab": "Assets/Game/Art/Effect/Prefab/skill/9110004_zhujue_quantao_skill01a.prefab", "duration": 1.0 }
],
"logicEffects": []
},
{
"id": "fighter3d_heavy",
"duration": 0.75,
"canBeInterrupted": true,
"input": "DirectionOrTarget",
"timeline": [
{ "time": 0.0, "kind": "Animation", "animation": "140_skill02a_qt", "duration": 0.75 },
{ "time": 0.18, "kind": "Projectile", "flyObjectId": "fighter3d_blade_wave" }
],
"logicEffects": []
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 24e933ae77efcdc48a59b0db7fcb1cf5
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8e30407f2677042458c0ab4703890606
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 64b9b67b84ee6ae4c94dc2c93a97361d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cff69aff2bb11fc4f8e323dac4b4231f
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,340 @@
using Fighter3D.Core;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using XGame.MiniGame;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace Fighter3D.Client
{
public sealed class FighterGameClient : IGameClient
{
private const ushort InputOpcode = 1;
private const ushort SnapshotOpcode = 100;
private const string UiPrefabPath = "Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab";
private const string BehaviorCatalogType = "fighter3d";
private const string BehaviorConfigRoot = "Assets/MiniGames/Fighter3D/res/Config/ActorBehavior";
private readonly FighterLogic _logic = new FighterLogic();
private IGameClientCtx _ctx;
private FighterState _state;
private FighterHud _hud;
private MiniGameActorPresentation _actors;
private int _lastAxis = int.MinValue;
private FighterButtons _lastButtons = (FighterButtons)255;
public void OnEnter(IGameClientCtx ctx)
{
_ctx = ctx;
_actors = MiniGameActorPresentation.Create("Fighter3D_ActorPresentation", BehaviorCatalogType, BehaviorConfigRoot);
RenderActors(_state);
_ctx.Assets.Load(UiPrefabPath, OnPrefabLoaded);
_ctx.Logger.Info("fighter3d client entered");
}
public void OnNetMessage(NetMessage message)
{
if (message.Opcode != SnapshotOpcode)
return;
_state = _logic.Decode(message.Payload);
_hud?.Render(_state);
RenderActors(_state);
}
public void OnUpdate(float deltaTime)
{
if (_ctx == null)
return;
int axis = ReadAxis();
FighterButtons buttons = ReadButtons();
if (axis != _lastAxis || buttons != _lastButtons)
{
SendControls(axis, buttons);
_lastAxis = axis;
_lastButtons = buttons;
}
_hud?.Render(_state);
RenderActors(_state);
_actors?.Tick(deltaTime);
}
public void OnExit()
{
_ctx?.Logger.Info("fighter3d client exited");
_hud?.Destroy();
_actors?.Destroy();
_hud = null;
_actors = null;
_ctx = null;
_state = null;
_lastAxis = int.MinValue;
_lastButtons = (FighterButtons)255;
}
private void OnPrefabLoaded(object loaded)
{
if (_ctx == null)
return;
GameObject root = loaded as GameObject;
if (root != null)
{
root = UnityEngine.Object.Instantiate(root);
root.name = "UI_Fighter3D";
}
else
{
_ctx.Logger.Info("fighter3d ui prefab missing, using runtime hud");
root = FighterHud.CreateFallbackRoot();
}
UnityEngine.Object.DontDestroyOnLoad(root);
_hud = new FighterHud(root);
_hud.Render(_state);
}
private void RenderActors(FighterState state)
{
if (_actors == null)
return;
if (state == null)
{
_actors.RenderActor(CreateActorFrame(0, -1f, 1, FighterAction.Idle, true));
_actors.RenderActor(CreateActorFrame(1, 1f, -1, FighterAction.Idle, true));
return;
}
_actors.RenderActor(CreateActorFrame(state.Fighters[0], state.Phase != FighterPhase.Finished));
_actors.RenderActor(CreateActorFrame(state.Fighters[1], state.Phase != FighterPhase.Finished));
}
private void SendControls(int axis, FighterButtons buttons)
{
var writer = new PacketWriter();
writer.WriteVarInt(axis);
writer.WriteByte((byte)buttons);
_ctx.Send(new NetMessage(InputOpcode, writer.ToArray()));
}
private static int ReadAxis()
{
bool left = Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.LeftArrow);
bool right = Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.RightArrow);
if (left == right)
return 0;
return left ? -1 : 1;
}
private static FighterButtons ReadButtons()
{
FighterButtons buttons = FighterButtons.None;
if (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow) || Input.GetKey(KeyCode.LeftShift))
buttons |= FighterButtons.Guard;
if (Input.GetKey(KeyCode.J))
buttons |= FighterButtons.Light;
if (Input.GetKey(KeyCode.K))
buttons |= FighterButtons.Heavy;
return buttons;
}
private static MiniGameActorFrame CreateActorFrame(FighterUnitState unit, bool running)
{
return CreateActorFrame(unit.Seat, unit.X, unit.Facing, unit.Action, running && unit.Hp > 0);
}
private static MiniGameActorFrame CreateActorFrame(int seat, float x, int facing, FighterAction action, bool alive)
{
int actorId = seat + 1;
return new MiniGameActorFrame
{
ActorId = actorId,
Figure = actorId,
TeamId = actorId,
Position = new Vector3(x, action == FighterAction.Knockout ? 0.16f : 0f, 0f),
Forward = facing >= 0 ? Vector3.right : Vector3.left,
Moving = action == FighterAction.Moving,
Alive = alive && action != FighterAction.Knockout,
TriggerKey = action.ToString(),
BehaviorId = GetBehaviorId(action)
};
}
private static string GetBehaviorId(FighterAction action)
{
if (action == FighterAction.LightAttack)
return "fighter3d_light";
if (action == FighterAction.HeavyAttack)
return "fighter3d_heavy";
return null;
}
}
internal sealed class FighterHud
{
private readonly GameObject _root;
private readonly HudText _title;
private readonly HudText _leftText;
private readonly HudText _rightText;
private readonly Image _leftHp;
private readonly Image _rightHp;
public FighterHud(GameObject root)
{
_root = root;
_title = FindText("Txt_Title");
_leftText = FindText("Txt_Left");
_rightText = FindText("Txt_Right");
_leftHp = Find<Image>("Img_LeftHpFill");
_rightHp = Find<Image>("Img_RightHpFill");
}
public void Render(FighterState state)
{
if (state == null)
{
_title.Set("Fighter3D waiting for snapshot");
_leftText.Set("P1");
_rightText.Set("P2");
SetFill(_leftHp, 1f);
SetFill(_rightHp, 1f);
return;
}
var left = state.Fighters[0];
var right = state.Fighters[1];
string title = state.Phase == FighterPhase.Finished
? state.WinnerSeat == 2 ? "Draw" : "Winner Seat " + state.WinnerSeat
: "Fighter3D " + state.ElapsedSeconds.ToString("0.0") + "s";
_title.Set(title);
_leftText.Set("P1 HP " + left.Hp + " " + left.Action + " X " + left.X.ToString("0.00"));
_rightText.Set("P2 HP " + right.Hp + " " + right.Action + " X " + right.X.ToString("0.00"));
SetFill(_leftHp, left.Hp / 1000f);
SetFill(_rightHp, right.Hp / 1000f);
}
public void Destroy()
{
if (_root != null)
UnityEngine.Object.Destroy(_root);
}
public static GameObject CreateFallbackRoot()
{
var root = new GameObject("UI_Fighter3D_Runtime");
var canvas = root.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
root.AddComponent<CanvasScaler>().referenceResolution = new Vector2(2048, 1024);
root.AddComponent<GraphicRaycaster>();
CreateText(root.transform, "Txt_Title", new Vector2(0, 430), new Vector2(900, 70), 34, TextAnchor.MiddleCenter);
CreateText(root.transform, "Txt_Left", new Vector2(-520, 340), new Vector2(700, 60), 28, TextAnchor.MiddleLeft);
CreateText(root.transform, "Txt_Right", new Vector2(520, 340), new Vector2(700, 60), 28, TextAnchor.MiddleRight);
CreateHpBar(root.transform, "LeftHp", new Vector2(-520, 285));
CreateHpBar(root.transform, "RightHp", new Vector2(520, 285));
CreateText(root.transform, "Txt_Help", new Vector2(0, -420), new Vector2(900, 50), 22, TextAnchor.MiddleCenter)
.text = "A/D move S guard J light K heavy";
return root;
}
private T Find<T>(string name) where T : Component
{
Transform child = FindChild(_root.transform, name);
return child == null ? null : child.GetComponent<T>();
}
private HudText FindText(string name)
{
Transform child = FindChild(_root.transform, name);
return child == null ? HudText.Empty : new HudText(child.GetComponent<Text>(), child.GetComponent<TMP_Text>());
}
private static Transform FindChild(Transform root, string name)
{
if (root.name == name)
return root;
for (int i = 0; i < root.childCount; i++)
{
Transform found = FindChild(root.GetChild(i), name);
if (found != null)
return found;
}
return null;
}
private static Text CreateText(Transform parent, string name, Vector2 anchoredPosition, Vector2 size, int fontSize, TextAnchor alignment)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var rect = go.AddComponent<RectTransform>();
rect.sizeDelta = size;
rect.anchoredPosition = anchoredPosition;
var text = go.AddComponent<Text>();
text.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
text.fontSize = fontSize;
text.alignment = alignment;
text.color = Color.white;
return text;
}
private static void CreateHpBar(Transform parent, string name, Vector2 anchoredPosition)
{
var bg = new GameObject("Img_" + name + "Bg");
bg.transform.SetParent(parent, false);
var bgRect = bg.AddComponent<RectTransform>();
bgRect.sizeDelta = new Vector2(700, 30);
bgRect.anchoredPosition = anchoredPosition;
var bgImage = bg.AddComponent<Image>();
bgImage.color = new Color(0.12f, 0.12f, 0.12f, 0.85f);
var fill = new GameObject("Img_" + name + "Fill");
fill.transform.SetParent(bg.transform, false);
var fillRect = fill.AddComponent<RectTransform>();
fillRect.anchorMin = new Vector2(0, 0);
fillRect.anchorMax = new Vector2(1, 1);
fillRect.offsetMin = Vector2.zero;
fillRect.offsetMax = Vector2.zero;
var fillImage = fill.AddComponent<Image>();
fillImage.type = Image.Type.Filled;
fillImage.fillMethod = Image.FillMethod.Horizontal;
fillImage.color = name == "LeftHp" ? new Color(0.18f, 0.8f, 0.42f) : new Color(0.9f, 0.23f, 0.22f);
}
private static void SetFill(Image image, float value)
{
if (image != null)
image.fillAmount = Mathf.Clamp01(value);
}
private readonly struct HudText
{
public static readonly HudText Empty = new HudText(null, null);
private readonly Text _legacy;
private readonly TMP_Text _tmp;
public HudText(Text legacy, TMP_Text tmp)
{
_legacy = legacy;
_tmp = tmp;
}
public void Set(string value)
{
if (_legacy != null)
_legacy.text = value;
if (_tmp != null)
_tmp.text = value;
}
}
}
}
@@ -0,0 +1,309 @@
using System;
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace Fighter3D.Core
{
public sealed class FighterLogic : IGameLogic<FighterState, FighterInput, FighterEvent>
{
public const int TickRate = 60;
public const float FixedDelta = 1f / TickRate;
public const float StageHalfWidth = 4f;
public const float RoundSeconds = 60f;
private const float MoveSpeed = 2.6f;
private const int MaxHp = 1000;
public FighterState CreateInitial(RoomConfig config, IRandom random)
{
var state = new FighterState
{
Phase = FighterPhase.Running,
WinnerSeat = -1
};
InitFighter(state.Fighters[0], 0, -1f, 1);
InitFighter(state.Fighters[1], 1, 1f, -1);
return state;
}
public StepResult<FighterState, FighterEvent> Step(FighterState state, FighterInput input, IRandom random)
{
var events = new List<FighterEvent>();
if (state == null)
state = CreateInitial(null, random);
if (input.Kind == FighterInputKind.Controls)
{
if (input.Seat >= 0 && input.Seat < state.Fighters.Length)
{
state.Fighters[input.Seat].ControlAxis = input.Axis < 0 ? -1 : input.Axis > 0 ? 1 : 0;
state.Fighters[input.Seat].HeldButtons = input.Buttons;
}
return new StepResult<FighterState, FighterEvent>(state, events);
}
if (state.Phase == FighterPhase.Finished)
return new StepResult<FighterState, FighterEvent>(state, events);
state.TickAccumulator += Math.Max(0f, input.Dt);
while (state.TickAccumulator + 0.000001f >= FixedDelta && state.Phase == FighterPhase.Running)
{
state.TickAccumulator -= FixedDelta;
AdvanceFixedTick(state, events);
}
return new StepResult<FighterState, FighterEvent>(state, events);
}
public byte[] Encode(FighterState state)
{
var writer = new PacketWriter(96);
writer.WriteByte((byte)state.Phase);
writer.WriteVarInt(state.Tick);
writer.WriteSingle(state.TickAccumulator);
writer.WriteSingle(state.ElapsedSeconds);
writer.WriteVarInt(state.WinnerSeat);
for (int i = 0; i < 2; i++)
WriteFighter(writer, state.Fighters[i]);
return writer.ToArray();
}
public FighterState Decode(byte[] data)
{
var reader = new PacketReader(data);
var state = new FighterState
{
Phase = (FighterPhase)reader.ReadByte(),
Tick = reader.ReadVarInt(),
TickAccumulator = reader.ReadSingle(),
ElapsedSeconds = reader.ReadSingle(),
WinnerSeat = reader.ReadVarInt()
};
for (int i = 0; i < 2; i++)
ReadFighter(reader, state.Fighters[i]);
return state;
}
private static void InitFighter(FighterUnitState fighter, int seat, float x, int facing)
{
fighter.Seat = seat;
fighter.X = x;
fighter.Hp = MaxHp;
fighter.Facing = facing;
fighter.Action = FighterAction.Idle;
fighter.ActionFrame = 0;
fighter.StunTicks = 0;
fighter.ControlAxis = 0;
fighter.HeldButtons = FighterButtons.None;
fighter.HasHit = false;
}
private static void AdvanceFixedTick(FighterState state, List<FighterEvent> events)
{
state.Tick++;
state.ElapsedSeconds += FixedDelta;
UpdateFacing(state);
for (int i = 0; i < 2; i++)
AdvanceFighter(state, i, events);
ResolveTimeout(state, events);
}
private static void UpdateFacing(FighterState state)
{
var a = state.Fighters[0];
var b = state.Fighters[1];
if (Math.Abs(a.X - b.X) < 0.0001f)
return;
a.Facing = a.X < b.X ? 1 : -1;
b.Facing = b.X < a.X ? 1 : -1;
}
private static void AdvanceFighter(FighterState state, int seat, List<FighterEvent> events)
{
var fighter = state.Fighters[seat];
if (fighter.Action == FighterAction.Knockout)
return;
if (fighter.Action == FighterAction.HitStun || fighter.Action == FighterAction.BlockStun)
{
fighter.ActionFrame++;
fighter.StunTicks--;
if (fighter.StunTicks <= 0)
EnterAction(fighter, FighterAction.Idle);
return;
}
if (fighter.Action == FighterAction.LightAttack || fighter.Action == FighterAction.HeavyAttack)
{
fighter.ActionFrame++;
TryResolveAttack(state, fighter, events);
if (fighter.ActionFrame >= TotalFrames(fighter.Action))
EnterAction(fighter, FighterAction.Idle);
return;
}
if ((fighter.HeldButtons & FighterButtons.Heavy) != 0)
{
EnterAction(fighter, FighterAction.HeavyAttack);
return;
}
if ((fighter.HeldButtons & FighterButtons.Light) != 0)
{
EnterAction(fighter, FighterAction.LightAttack);
return;
}
if (fighter.ControlAxis != 0)
{
fighter.Action = FighterAction.Moving;
fighter.X = Clamp(fighter.X + fighter.ControlAxis * MoveSpeed * FixedDelta, -StageHalfWidth, StageHalfWidth);
}
else
{
fighter.Action = FighterAction.Idle;
}
}
private static void TryResolveAttack(FighterState state, FighterUnitState attacker, List<FighterEvent> events)
{
if (attacker.HasHit || !IsActive(attacker.Action, attacker.ActionFrame))
return;
var defender = state.Fighters[1 - attacker.Seat];
if (defender.Action == FighterAction.Knockout)
return;
float distance = Math.Abs(defender.X - attacker.X);
if (distance > Range(attacker.Action))
return;
if (Math.Sign(defender.X - attacker.X) != attacker.Facing)
return;
attacker.HasHit = true;
if (IsGuardingAway(defender))
{
EnterStun(defender, FighterAction.BlockStun, BlockStun(attacker.Action));
Push(defender, attacker.Facing, BlockPush(attacker.Action));
events.Add(new FighterEvent { Kind = FighterEventKind.Block, AttackerSeat = attacker.Seat, DefenderSeat = defender.Seat });
return;
}
defender.Hp = Math.Max(0, defender.Hp - Damage(attacker.Action));
Push(defender, attacker.Facing, HitPush(attacker.Action));
if (defender.Hp <= 0)
{
EnterAction(defender, FighterAction.Knockout);
state.Phase = FighterPhase.Finished;
state.WinnerSeat = attacker.Seat;
events.Add(new FighterEvent { Kind = FighterEventKind.Finished, AttackerSeat = attacker.Seat, DefenderSeat = defender.Seat, WinnerSeat = attacker.Seat });
}
else
{
EnterStun(defender, FighterAction.HitStun, HitStun(attacker.Action));
events.Add(new FighterEvent { Kind = FighterEventKind.Hit, AttackerSeat = attacker.Seat, DefenderSeat = defender.Seat });
}
}
private static bool IsGuardingAway(FighterUnitState defender)
{
return (defender.HeldButtons & FighterButtons.Guard) != 0
|| defender.ControlAxis == -defender.Facing;
}
private static void EnterAction(FighterUnitState fighter, FighterAction action)
{
fighter.Action = action;
fighter.ActionFrame = 0;
fighter.StunTicks = 0;
fighter.HasHit = false;
}
private static void EnterStun(FighterUnitState fighter, FighterAction action, int stunTicks)
{
fighter.Action = action;
fighter.ActionFrame = 0;
fighter.StunTicks = stunTicks;
fighter.HasHit = false;
}
private static void Push(FighterUnitState fighter, int direction, float distance)
{
fighter.X = Clamp(fighter.X + direction * distance, -StageHalfWidth, StageHalfWidth);
}
private static void ResolveTimeout(FighterState state, List<FighterEvent> events)
{
float battleSeconds = state.Tick * FixedDelta;
if (state.Phase == FighterPhase.Finished || battleSeconds + 0.0001f < RoundSeconds)
return;
int hp0 = state.Fighters[0].Hp;
int hp1 = state.Fighters[1].Hp;
state.Phase = FighterPhase.Finished;
state.WinnerSeat = hp0 == hp1 ? 2 : hp0 > hp1 ? 0 : 1;
events.Add(new FighterEvent { Kind = FighterEventKind.Finished, WinnerSeat = state.WinnerSeat });
}
private static bool IsActive(FighterAction action, int frame)
{
return frame >= Startup(action) && frame < Startup(action) + Active(action);
}
private static int Startup(FighterAction action) => action == FighterAction.HeavyAttack ? 7 : 4;
private static int Active(FighterAction action) => action == FighterAction.HeavyAttack ? 4 : 3;
private static int TotalFrames(FighterAction action) => action == FighterAction.HeavyAttack ? 29 : 15;
private static int Damage(FighterAction action) => action == FighterAction.HeavyAttack ? 250 : 60;
private static int HitStun(FighterAction action) => action == FighterAction.HeavyAttack ? 18 : 12;
private static int BlockStun(FighterAction action) => action == FighterAction.HeavyAttack ? 14 : 8;
private static float Range(FighterAction action) => action == FighterAction.HeavyAttack ? 2.2f : 1.15f;
private static float HitPush(FighterAction action) => action == FighterAction.HeavyAttack ? 0.04f : 0.02f;
private static float BlockPush(FighterAction action) => action == FighterAction.HeavyAttack ? 0.03f : 0.015f;
private static float Clamp(float value, float min, float max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
private static void WriteFighter(PacketWriter writer, FighterUnitState fighter)
{
writer.WriteVarInt(fighter.Seat);
writer.WriteSingle(fighter.X);
writer.WriteVarInt(fighter.Hp);
writer.WriteVarInt(fighter.Facing);
writer.WriteByte((byte)fighter.Action);
writer.WriteVarInt(fighter.ActionFrame);
writer.WriteVarInt(fighter.StunTicks);
writer.WriteVarInt(fighter.ControlAxis);
writer.WriteByte((byte)fighter.HeldButtons);
writer.WriteBool(fighter.HasHit);
}
private static void ReadFighter(PacketReader reader, FighterUnitState fighter)
{
fighter.Seat = reader.ReadVarInt();
fighter.X = reader.ReadSingle();
fighter.Hp = reader.ReadVarInt();
fighter.Facing = reader.ReadVarInt();
fighter.Action = (FighterAction)reader.ReadByte();
fighter.ActionFrame = reader.ReadVarInt();
fighter.StunTicks = reader.ReadVarInt();
fighter.ControlAxis = reader.ReadVarInt();
fighter.HeldButtons = (FighterButtons)reader.ReadByte();
fighter.HasHit = reader.ReadBool();
}
}
}
@@ -0,0 +1,104 @@
using System;
namespace Fighter3D.Core
{
public enum FighterPhase : byte
{
Running = 0,
Finished = 1
}
public enum FighterAction : byte
{
Idle = 0,
Moving = 1,
LightAttack = 2,
HeavyAttack = 3,
HitStun = 4,
BlockStun = 5,
Knockout = 6
}
[Flags]
public enum FighterButtons : byte
{
None = 0,
Guard = 1,
Light = 2,
Heavy = 4
}
public enum FighterInputKind : byte
{
Tick = 0,
Controls = 1
}
public enum FighterEventKind : byte
{
Hit = 0,
Block = 1,
Finished = 2
}
public sealed class FighterUnitState
{
public int Seat;
public float X;
public int Hp;
public int Facing;
public FighterAction Action;
public int ActionFrame;
public int StunTicks;
public int ControlAxis;
public FighterButtons HeldButtons;
public bool HasHit;
}
public sealed class FighterState
{
public int Tick;
public float TickAccumulator;
public float ElapsedSeconds;
public FighterPhase Phase;
public int WinnerSeat = -1;
public FighterUnitState[] Fighters =
{
new FighterUnitState { Seat = 0 },
new FighterUnitState { Seat = 1 }
};
}
public struct FighterInput
{
public FighterInputKind Kind;
public int Seat;
public int Axis;
public FighterButtons Buttons;
public float Dt;
public static FighterInput Tick(float dt)
{
return new FighterInput { Kind = FighterInputKind.Tick, Dt = dt };
}
public static FighterInput Controls(int seat, int axis, FighterButtons buttons)
{
return new FighterInput
{
Kind = FighterInputKind.Controls,
Seat = seat,
Axis = axis < 0 ? -1 : axis > 0 ? 1 : 0,
Buttons = buttons
};
}
}
public struct FighterEvent
{
public FighterEventKind Kind;
public int AttackerSeat;
public int DefenderSeat;
public int WinnerSeat;
}
}
@@ -527,7 +527,23 @@ namespace XGame.EditorTools
string spriteName = (string)comp["sprite"];
Sprite sprite = ResolveSprite(spriteName, fileName);
img.sprite = sprite;
img.type = string.Equals((string)comp["imageType"], "Sliced", StringComparison.OrdinalIgnoreCase) ? Image.Type.Sliced : Image.Type.Simple;
string imageType = (string)comp["imageType"];
if (string.Equals(imageType, "Sliced", StringComparison.OrdinalIgnoreCase))
{
img.type = Image.Type.Sliced;
}
else if (string.Equals(imageType, "Filled", StringComparison.OrdinalIgnoreCase))
{
img.type = Image.Type.Filled;
img.fillMethod = ParseFillMethod((string)comp["fillMethod"]);
img.fillOrigin = 0;
img.fillClockwise = true;
img.fillAmount = comp["fillAmount"] != null ? Mathf.Clamp01((float)comp["fillAmount"]) : 1f;
}
else
{
img.type = Image.Type.Simple;
}
if (comp["preserveAspect"] != null) img.preserveAspect = (bool)comp["preserveAspect"];
img.raycastTarget = comp["raycastTarget"] == null || (bool)comp["raycastTarget"];
img.color = ParseColor((string)comp["color"], Color.white);
@@ -932,6 +948,20 @@ namespace XGame.EditorTools
}
}
private static Image.FillMethod ParseFillMethod(string method)
{
switch (method)
{
case "Vertical": return Image.FillMethod.Vertical;
case "Radial90": return Image.FillMethod.Radial90;
case "Radial180": return Image.FillMethod.Radial180;
case "Radial360": return Image.FillMethod.Radial360;
case "Horizontal":
default:
return Image.FillMethod.Horizontal;
}
}
private static TMP_InputField.ContentType ParseInputContentType(string contentType)
{
switch (contentType)
@@ -11,6 +11,14 @@ public sealed class ActorBehaviorAnimatorControllerPathTests
Is.EqualTo("Assets/Game/Art/Actor/Controller/fighter.controller"));
}
[Test]
public void GetControllerPath_Fighter3D_ReturnsGeneratedFighterController()
{
Assert.That(
ActorBehaviorAnimatorControllerPaths.GetControllerPath("fighter3d"),
Is.EqualTo("Assets/Game/Art/Actor/Controller/fighter.controller"));
}
[Test]
public void GetControllerPath_UnknownType_ReturnsNull()
{
@@ -0,0 +1,199 @@
using NUnit.Framework;
using System.IO;
using System.Reflection;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
using XGame;
using XGame.EditorTools;
using XWorld.Framework.ActorBehavior;
public sealed class GameLobbyControllerLayoutTests
{
private const string PrefabPath = "Assets/Game/Art/UI/Prefab/UI_GameLobby.prefab";
private const string Fighter3DJsonPath = "../Doc/UIPrefabCreater/UI_Fighter3D.json";
private const string Fighter3DPrefabPath = "Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab";
private const string Fighter3DStagePrefabPath = "Assets/MiniGames/Fighter3D/res/Prefab/Fighter3D_BattleStage.prefab";
private const string Fighter3DBehaviorConfigRoot = "Assets/MiniGames/Fighter3D/res/Config/ActorBehavior";
[Test]
public void LobbyPanel_DocksToLeftMiddle()
{
GameObject instance = CreateLobbyInstance();
try
{
GameLobbyController controller = instance.GetComponent<GameLobbyController>();
Assert.That(controller, Is.Not.Null);
controller.BeginGames();
RectTransform panel = FindRect(instance.transform, "Panel_Lobby");
Assert.That(panel.anchorMin, Is.EqualTo(new Vector2(0f, 0.5f)));
Assert.That(panel.anchorMax, Is.EqualTo(new Vector2(0f, 0.5f)));
Assert.That(panel.pivot, Is.EqualTo(new Vector2(0f, 0.5f)));
Assert.That(panel.anchoredPosition, Is.EqualTo(new Vector2(24f, 0f)));
Assert.That(panel.sizeDelta, Is.EqualTo(new Vector2(520f, 296f)));
}
finally
{
Object.DestroyImmediate(instance);
}
}
[Test]
public void CollapseButton_StaysAttachedToLobbyPanelInLeftMiddleLayout()
{
GameObject instance = CreateLobbyInstance();
try
{
GameLobbyController controller = instance.GetComponent<GameLobbyController>();
Assert.That(controller, Is.Not.Null);
controller.BeginGames();
RectTransform button = FindRect(instance.transform, "Btn_Collapse");
Assert.That(button.anchorMin, Is.EqualTo(new Vector2(0f, 0.5f)));
Assert.That(button.anchorMax, Is.EqualTo(new Vector2(0f, 0.5f)));
Assert.That(button.pivot, Is.EqualTo(new Vector2(0f, 0.5f)));
Assert.That(button.anchoredPosition, Is.EqualTo(new Vector2(544f, 0f)));
Assert.That(button.sizeDelta, Is.EqualTo(new Vector2(48f, 72f)));
}
finally
{
Object.DestroyImmediate(instance);
}
}
[Test]
public void LobbyGameListPoll_WaitsForRefreshInterval()
{
MethodInfo method = FindGameListPollMethod();
Assert.That((bool)method.Invoke(null, new object[] { true, true, false, 4f, 5f }), Is.False);
Assert.That((bool)method.Invoke(null, new object[] { true, true, false, 5f, 5f }), Is.True);
}
[Test]
public void LobbyGameListPoll_DoesNotRunOutsideIdleConnectedLobby()
{
MethodInfo method = FindGameListPollMethod();
Assert.That((bool)method.Invoke(null, new object[] { false, true, false, 5f, 5f }), Is.False);
Assert.That((bool)method.Invoke(null, new object[] { true, false, false, 5f, 5f }), Is.False);
Assert.That((bool)method.Invoke(null, new object[] { true, true, true, 5f, 5f }), Is.False);
}
[Test]
public void Fighter3DFallbackHud_UsesUnity2022BuiltinFont()
{
string source = File.ReadAllText("Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs");
Assert.That(source, Does.Contain("LegacyRuntime.ttf"));
Assert.That(source, Does.Not.Contain("Arial.ttf"));
}
[Test]
public void Fighter3DHudSource_CanBindGeneratedTmpText()
{
string source = File.ReadAllText("Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs");
Assert.That(source, Does.Contain("using TMPro;"));
Assert.That(source, Does.Contain("TMP_Text"));
}
[Test]
public void Fighter3DClientSource_UsesCommonActorPresentation()
{
string source = File.ReadAllText("Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs");
Assert.That(source, Does.Contain("MiniGameActorPresentation"));
Assert.That(source, Does.Contain(Fighter3DBehaviorConfigRoot));
Assert.That(source, Does.Not.Contain(Fighter3DStagePrefabPath));
Assert.That(source, Does.Not.Contain("OnStagePrefabLoaded"));
Assert.That(source, Does.Not.Contain("FighterStage"));
}
[Test]
public void Fighter3DBehaviorConfigs_AreMiniGameScopedAndValid()
{
string skillJson = ReadAssetText(Fighter3DBehaviorConfigRoot + "/fighter3d_skill.json");
string flyObjectJson = ReadAssetText(Fighter3DBehaviorConfigRoot + "/fighter3d_flyobj.json");
string buffJson = ReadAssetText(Fighter3DBehaviorConfigRoot + "/fighter3d_buff.json");
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter3d", skillJson, flyObjectJson, buffJson);
Assert.That(catalog.Type, Is.EqualTo("fighter3d"));
Assert.That(catalog.Behaviors.ContainsKey("fighter3d_light"), Is.True);
Assert.That(catalog.Behaviors.ContainsKey("fighter3d_heavy"), Is.True);
Assert.That(catalog.FlyObjects.ContainsKey("fighter3d_blade_wave"), Is.True);
}
[Test]
public void Fighter3DUiJson_PassesPrefabBuilderValidation()
{
ValidationResult result = JsonUIPrefabBuilder.Validate(Fighter3DJsonPath);
Assert.That(result.Ok, Is.True, string.Join("\n", result.Errors));
}
[Test]
public void Fighter3DPrefab_ProvidesHudBindings()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(Fighter3DPrefabPath);
Assert.That(prefab, Is.Not.Null);
Assert.That(FindComponent<TextMeshProUGUI>(prefab.transform, "Txt_Title"), Is.Not.Null);
Assert.That(FindComponent<TextMeshProUGUI>(prefab.transform, "Txt_Left"), Is.Not.Null);
Assert.That(FindComponent<TextMeshProUGUI>(prefab.transform, "Txt_Right"), Is.Not.Null);
Image leftHp = FindComponent<Image>(prefab.transform, "Img_LeftHpFill");
Image rightHp = FindComponent<Image>(prefab.transform, "Img_RightHpFill");
Assert.That(leftHp, Is.Not.Null);
Assert.That(rightHp, Is.Not.Null);
Assert.That(leftHp.type, Is.EqualTo(Image.Type.Filled));
Assert.That(leftHp.fillMethod, Is.EqualTo(Image.FillMethod.Horizontal));
Assert.That(rightHp.type, Is.EqualTo(Image.Type.Filled));
Assert.That(rightHp.fillMethod, Is.EqualTo(Image.FillMethod.Horizontal));
}
private static GameObject CreateLobbyInstance()
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
Assert.That(prefab, Is.Not.Null);
return Object.Instantiate(prefab);
}
private static RectTransform FindRect(Transform root, string name)
{
Transform child = root.FindTransform(name);
Assert.That(child, Is.Not.Null);
RectTransform rect = child.GetComponent<RectTransform>();
Assert.That(rect, Is.Not.Null);
return rect;
}
private static MethodInfo FindGameListPollMethod()
{
MethodInfo method = typeof(CSharpClientApp).GetMethod(
"ShouldPollGameList",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
return method;
}
private static T FindComponent<T>(Transform root, string name) where T : Component
{
Transform child = root.FindTransform(name);
return child == null ? null : child.GetComponent<T>();
}
private static string ReadAssetText(string assetPath)
{
TextAsset asset = AssetDatabase.LoadAssetAtPath<TextAsset>(assetPath);
Assert.That(asset, Is.Not.Null, assetPath);
return asset.text;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e7a70267c2bce264fb04b0eb9dd0db99
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -14,6 +14,7 @@ namespace XGame.ActorBehavior
public static string GetControllerPath(string catalogType)
{
return string.Equals(catalogType, LobbyActorBehaviorRuntime.DefaultCatalogType, StringComparison.OrdinalIgnoreCase)
|| string.Equals(catalogType, "fighter3d", StringComparison.OrdinalIgnoreCase)
? "Assets/Game/Art/Actor/Controller/fighter.controller"
: null;
}
@@ -16,18 +16,23 @@ namespace XGame.ActorBehavior
public const string ConfigRoot = "Assets/Game/Config/ActorBehavior";
public static IEnumerator LoadCatalog(string type, Action<BehaviorConfigCatalog> onLoaded)
{
yield return LoadCatalogFromRoot(type, ConfigRoot, onLoaded);
}
public static IEnumerator LoadCatalogFromRoot(string type, string configRoot, Action<BehaviorConfigCatalog> onLoaded)
{
string skillJson = null;
string flyObjectJson = null;
string buffJson = null;
yield return LoadText(GetSkillPath(type), text => skillJson = text);
yield return LoadText(GetFlyObjectPath(type), text => flyObjectJson = text);
yield return LoadText(GetBuffPath(type), text => buffJson = text);
yield return LoadText(GetSkillPath(type, configRoot), text => skillJson = text);
yield return LoadText(GetFlyObjectPath(type, configRoot), text => flyObjectJson = text);
yield return LoadText(GetBuffPath(type, configRoot), text => buffJson = text);
if (string.IsNullOrEmpty(skillJson) || string.IsNullOrEmpty(flyObjectJson) || string.IsNullOrEmpty(buffJson))
{
Debug.LogError("[ActorBehaviorConfigLoader] missing behavior config for type=" + type);
Debug.LogError("[ActorBehaviorConfigLoader] missing behavior config for type=" + type + ", root=" + configRoot);
onLoaded?.Invoke(null);
yield break;
}
@@ -48,16 +53,38 @@ namespace XGame.ActorBehavior
return ConfigRoot + "/" + type + "_skill.json";
}
public static string GetSkillPath(string type, string configRoot)
{
return NormalizeRoot(configRoot) + "/" + type + "_skill.json";
}
public static string GetFlyObjectPath(string type)
{
return ConfigRoot + "/" + type + "_flyobj.json";
}
public static string GetFlyObjectPath(string type, string configRoot)
{
return NormalizeRoot(configRoot) + "/" + type + "_flyobj.json";
}
public static string GetBuffPath(string type)
{
return ConfigRoot + "/" + type + "_buff.json";
}
public static string GetBuffPath(string type, string configRoot)
{
return NormalizeRoot(configRoot) + "/" + type + "_buff.json";
}
private static string NormalizeRoot(string configRoot)
{
return string.IsNullOrEmpty(configRoot)
? ConfigRoot
: configRoot.TrimEnd('/', '\\');
}
private static IEnumerator LoadText(string assetPath, Action<string> onLoaded)
{
#if UNITY_EDITOR
@@ -26,6 +26,7 @@ namespace XGame
private const string LoginPlayerIdKey = "LoginPlayerId";
private const string LocalClientIdKey = "LocalClientId";
private const double TokenValidSeconds = 7 * 24 * 60 * 60;
private const float LobbyGameListRefreshIntervalSeconds = 3f;
public static CSharpClientApp Instance { get; private set; }
@@ -55,6 +56,7 @@ namespace XGame
private string matchMessage = string.Empty;
private string pendingGameId = string.Empty;
private int pendingGameVersion;
private float nextGameListRefreshTime;
private float nextLobbyMoveFrameLogTime;
private int m_pid;//当前已鉴权的玩家 id(来自登录/注册返回的 token)
@@ -113,6 +115,11 @@ namespace XGame
if (lobbyActive)
{
lobbyNet?.Pump();
float now = Time.unscaledTime;
if (ShouldPollGameList(lobbyActive, lobbyNet != null && lobbyNet.IsConnected, matchRequestActive, now, nextGameListRefreshTime))
{
RequestGameList();
}
}
}
@@ -435,9 +442,13 @@ namespace XGame
RefreshLobby();
return;
}
nextGameListRefreshTime = Time.unscaledTime + LobbyGameListRefreshIntervalSeconds;
lobbyNet.SendFramework(FrameworkOpcode.GameListRequest, Array.Empty<byte>());
}
private static bool ShouldPollGameList(bool isLobbyActive, bool hasConnectedLobbyNet, bool isMatchRequestActive, float now, float nextPollTime)
=> isLobbyActive && hasConnectedLobbyNet && !isMatchRequestActive && now >= nextPollTime;
private void RefreshLobby()
{
if (lobbyController == null)
@@ -0,0 +1,625 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using XGame.ActorBehavior;
using XWorld.Framework.ActorBehavior;
using UObject = UnityEngine.Object;
namespace XGame.MiniGame
{
public struct MiniGameActorFrame
{
public int ActorId;
public int Figure;
public int TeamId;
public Vector3 Position;
public Vector3 Forward;
public bool Moving;
public bool Alive;
public string TriggerKey;
public string BehaviorId;
}
public sealed class MiniGameActorPresentation
{
private sealed class ActorAvatar
{
public int ActorId;
public int Figure;
public int TeamId;
public GameObject Obj;
public Animator Animator;
public Vector3 Target;
public bool Moving;
public bool Alive;
public bool IsPlaceholder;
public bool HasPosition;
public string AnimState;
public string LastTriggerKey;
public Vector3 LastForward = Vector3.forward;
}
private sealed class CoroutineHost : MonoBehaviour
{
}
private const string CharacterConfigResourcePath = "Config/CharacterConfig";
private const string FighterStandState = "140_stand01_qt";
private const string FighterWalkState = "140_walk01_qt";
private const string LegacyStandState = "Stand";
private const string LegacyWalkState = "Walk";
private const float MoveSpeed = 12f;
private const float StopDistance = 0.04f;
private readonly Dictionary<int, ActorAvatar> _avatars = new Dictionary<int, ActorAvatar>();
private readonly Dictionary<int, GameObject> _prefabCache = new Dictionary<int, GameObject>();
private readonly HashSet<int> _prefabLoading = new HashSet<int>();
private readonly string _catalogType;
private readonly string _behaviorConfigRoot;
private readonly GameObject _root;
private readonly Transform _actorRoot;
private readonly CoroutineHost _coroutineHost;
private readonly ActorBehaviorPresentation _behaviorPresentation;
private readonly BehaviorWorld _behaviorWorld;
private CharacterConfigData _characterConfig;
private bool _catalogLoading;
private bool _catalogLoaded;
private bool _disposed;
private MiniGameActorPresentation(string rootName, string catalogType, string behaviorConfigRoot)
{
_catalogType = catalogType;
_behaviorConfigRoot = behaviorConfigRoot;
_root = new GameObject(string.IsNullOrEmpty(rootName) ? "MiniGameActorPresentation" : rootName);
UObject.DontDestroyOnLoad(_root);
_coroutineHost = _root.AddComponent<CoroutineHost>();
_behaviorPresentation = _root.AddComponent<ActorBehaviorPresentation>();
_behaviorWorld = new BehaviorWorld(new BehaviorWorldOptions
{
Mode = BehaviorRunMode.ClientPresentation
});
_actorRoot = new GameObject("Actors").transform;
_actorRoot.SetParent(_root.transform, false);
EnsureScenePresentation();
EnsureCharacterConfig();
EnsureBehaviorCatalog();
}
public static MiniGameActorPresentation Create(string rootName, string catalogType, string behaviorConfigRoot)
{
if (string.IsNullOrEmpty(catalogType))
{
throw new ArgumentException("catalogType is required", nameof(catalogType));
}
return new MiniGameActorPresentation(rootName, catalogType, behaviorConfigRoot);
}
public void RenderActor(MiniGameActorFrame frame)
{
if (_disposed || frame.ActorId <= 0)
{
return;
}
int figure = frame.Figure <= 0 ? 1 : frame.Figure;
ActorAvatar avatar = GetOrCreateAvatar(frame.ActorId, figure);
if (avatar == null || avatar.Obj == null)
{
return;
}
avatar.TeamId = frame.TeamId;
avatar.Alive = frame.Alive;
avatar.Target = frame.Position;
avatar.Moving = frame.Moving;
Vector3 forward = ResolveForward(frame.Forward, avatar.LastForward);
avatar.LastForward = forward;
if (!avatar.HasPosition)
{
avatar.Obj.transform.position = frame.Position;
avatar.Target = frame.Position;
avatar.HasPosition = true;
}
avatar.Obj.transform.forward = forward;
RegisterOrUpdateActor(avatar);
StartBehaviorIfNeeded(avatar, frame.BehaviorId, frame.TriggerKey, forward);
SetMoveState(avatar, IsAvatarMoving(avatar));
}
public void Tick(float deltaTime)
{
if (_disposed)
{
return;
}
float step = Mathf.Max(MoveSpeed * Mathf.Max(deltaTime, 0f), 0.01f);
foreach (ActorAvatar avatar in _avatars.Values)
{
if (avatar == null || avatar.Obj == null)
{
continue;
}
Vector3 before = avatar.Obj.transform.position;
avatar.Obj.transform.position = Vector3.MoveTowards(before, avatar.Target, step);
RegisterOrUpdateActor(avatar);
if (!_behaviorWorld.HasActiveBehavior(avatar.ActorId))
{
SetMoveState(avatar, IsAvatarMoving(avatar));
}
}
_behaviorWorld.Tick(deltaTime);
DrainBehaviorEvents();
}
public void RemoveActor(int actorId)
{
if (!_avatars.TryGetValue(actorId, out ActorAvatar avatar))
{
return;
}
_behaviorPresentation.UnregisterActor(actorId, true);
_behaviorWorld.RemoveActor(actorId);
DestroyUnityObject(avatar.Obj);
_avatars.Remove(actorId);
}
public void Destroy()
{
if (_disposed)
{
return;
}
_disposed = true;
_avatars.Clear();
DestroyUnityObject(_root);
}
private ActorAvatar GetOrCreateAvatar(int actorId, int figure)
{
if (_avatars.TryGetValue(actorId, out ActorAvatar avatar))
{
if (avatar.Figure == figure)
{
return avatar;
}
RemoveActor(actorId);
}
GameObject obj = InstantiateAvatar(figure, out bool isPlaceholder);
avatar = new ActorAvatar
{
ActorId = actorId,
Figure = figure,
TeamId = actorId,
Obj = obj,
Animator = obj != null ? obj.GetComponentInChildren<Animator>() : null,
Target = obj != null ? obj.transform.position : Vector3.zero,
Alive = true,
IsPlaceholder = isPlaceholder
};
if (obj != null)
{
obj.name = "MiniGameActor_" + actorId + "_F" + figure;
_behaviorPresentation.RegisterActor(actorId, avatar.Animator, obj.transform, obj.transform);
ApplyActorBehaviorAnimatorController(avatar);
}
_avatars[actorId] = avatar;
return avatar;
}
private GameObject InstantiateAvatar(int figure, out bool isPlaceholder)
{
GameObject prefab = GetCachedPrefabOrLoadAsync(figure);
isPlaceholder = prefab == null;
GameObject obj = prefab != null
? UObject.Instantiate(prefab, _actorRoot)
: GameObject.CreatePrimitive(PrimitiveType.Capsule);
if (prefab == null)
{
obj.transform.SetParent(_actorRoot, false);
obj.transform.localScale = new Vector3(0.7f, 1f, 0.7f);
}
obj.SetActive(true);
ConfigureAvatarRenderers(obj);
return obj;
}
private GameObject GetCachedPrefabOrLoadAsync(int figure)
{
if (_prefabCache.TryGetValue(figure, out GameObject prefab) && prefab != null)
{
return prefab;
}
if (_prefabLoading.Add(figure))
{
_coroutineHost.StartCoroutine(CoLoadActorPrefab(figure));
}
return null;
}
private IEnumerator CoLoadActorPrefab(int figure)
{
string prefabPath = GetPrefabPath(figure);
if (string.IsNullOrEmpty(prefabPath))
{
_prefabLoading.Remove(figure);
Debug.LogError("[MiniGameActorPresentation] missing prefab path for figure=" + figure);
yield break;
}
UObject loaded = null;
yield return XResLoader.coLoadRes(prefabPath, typeof(GameObject), obj => loaded = obj);
_prefabLoading.Remove(figure);
GameObject prefab = loaded as GameObject;
if (prefab == null)
{
Debug.LogWarning("[MiniGameActorPresentation] actor prefab load failed: " + prefabPath);
yield break;
}
_prefabCache[figure] = prefab;
UpgradePlaceholders(figure, prefab);
}
private void UpgradePlaceholders(int figure, GameObject prefab)
{
foreach (ActorAvatar avatar in _avatars.Values)
{
if (avatar == null || avatar.Figure != figure || !avatar.IsPlaceholder || avatar.Obj == null)
{
continue;
}
Vector3 position = avatar.Obj.transform.position;
Quaternion rotation = avatar.Obj.transform.rotation;
string objectName = avatar.Obj.name;
DestroyUnityObject(avatar.Obj);
avatar.Obj = UObject.Instantiate(prefab, _actorRoot);
avatar.Obj.name = objectName;
avatar.Obj.transform.position = position;
avatar.Obj.transform.rotation = rotation;
avatar.Obj.SetActive(true);
ConfigureAvatarRenderers(avatar.Obj);
avatar.Animator = avatar.Obj.GetComponentInChildren<Animator>();
avatar.IsPlaceholder = false;
avatar.AnimState = null;
_behaviorPresentation.RegisterActor(avatar.ActorId, avatar.Animator, avatar.Obj.transform, avatar.Obj.transform);
ApplyActorBehaviorAnimatorController(avatar);
SetMoveState(avatar, avatar.Moving);
}
}
private void ApplyActorBehaviorAnimatorController(ActorAvatar avatar)
{
if (avatar == null || avatar.Obj == null || avatar.Animator == null)
{
return;
}
_coroutineHost.StartCoroutine(ApplyActorBehaviorAnimatorControllerAsync(
avatar.ActorId,
avatar.Obj,
avatar.Animator));
}
private IEnumerator ApplyActorBehaviorAnimatorControllerAsync(int actorId, GameObject actorObject, Animator animator)
{
bool applied = false;
yield return ActorBehaviorAnimatorControllerLoader.ApplyController(_catalogType, animator, value => applied = value);
if (!applied || actorObject == null || animator == null)
{
yield break;
}
if (!_avatars.TryGetValue(actorId, out ActorAvatar avatar) || avatar.Obj != actorObject || avatar.Animator != animator)
{
yield break;
}
avatar.AnimState = null;
SetMoveState(avatar, avatar.Moving);
}
private void StartBehaviorIfNeeded(ActorAvatar avatar, string behaviorId, string triggerKey, Vector3 forward)
{
if (avatar == null)
{
return;
}
if (string.IsNullOrEmpty(behaviorId))
{
if (!string.IsNullOrEmpty(triggerKey))
{
avatar.LastTriggerKey = triggerKey;
}
return;
}
string key = string.IsNullOrEmpty(triggerKey) ? behaviorId : triggerKey;
if (avatar.LastTriggerKey == key)
{
return;
}
avatar.LastTriggerKey = key;
if (!_catalogLoaded || _behaviorWorld.HasActiveBehavior(avatar.ActorId))
{
return;
}
BehaviorStartResult result = _behaviorWorld.TryStartBehavior(
avatar.ActorId,
behaviorId,
BehaviorInput.FromDirection(forward.x, forward.z));
if (!result.Success)
{
Debug.LogWarning("[MiniGameActorPresentation] start behavior failed actorId=" + avatar.ActorId +
", behaviorId=" + behaviorId + ", error=" + result.Error);
}
}
private void RegisterOrUpdateActor(ActorAvatar avatar)
{
if (avatar == null || avatar.Obj == null)
{
return;
}
_behaviorWorld.AddActor(new BehaviorActorState
{
ActorId = avatar.ActorId,
CatalogType = _catalogType,
TeamId = avatar.TeamId,
Position = ToBehavior(avatar.Obj.transform.position),
Forward = ToBehaviorForward(avatar.Obj.transform.forward),
Radius = 0.5f,
Alive = avatar.Alive
});
}
private void DrainBehaviorEvents()
{
IReadOnlyList<BehaviorEvent> events = _behaviorWorld.DrainEvents();
if (events.Count == 0)
{
return;
}
_behaviorPresentation.PlayEvents(events);
for (int i = 0; i < events.Count; i++)
{
BehaviorEvent evt = events[i];
if (evt.Kind != BehaviorEventKind.BehaviorStopped)
{
continue;
}
if (_avatars.TryGetValue(evt.ActorId, out ActorAvatar avatar))
{
avatar.AnimState = null;
SetMoveState(avatar, avatar.Moving);
}
}
}
private void EnsureBehaviorCatalog()
{
if (_catalogLoaded || _catalogLoading)
{
return;
}
_catalogLoading = true;
_coroutineHost.StartCoroutine(CoLoadBehaviorCatalog());
}
private IEnumerator CoLoadBehaviorCatalog()
{
BehaviorConfigCatalog catalog = null;
yield return ActorBehaviorConfigLoader.LoadCatalogFromRoot(_catalogType, _behaviorConfigRoot, loaded => catalog = loaded);
_catalogLoading = false;
if (catalog == null)
{
yield break;
}
_behaviorWorld.RegisterCatalog(catalog);
_catalogLoaded = true;
}
private bool EnsureCharacterConfig()
{
if (_characterConfig != null)
{
return true;
}
TextAsset asset = Resources.Load<TextAsset>(CharacterConfigResourcePath);
if (asset == null)
{
Debug.LogError("[MiniGameActorPresentation] missing character config resource: " + CharacterConfigResourcePath);
return false;
}
_characterConfig = JsonUtility.FromJson<CharacterConfigData>(asset.text);
if (_characterConfig == null || _characterConfig.GetDefault() == null)
{
Debug.LogError("[MiniGameActorPresentation] invalid character config.");
return false;
}
return true;
}
private string GetPrefabPath(int figure)
{
return EnsureCharacterConfig() ? _characterConfig.GetPrefabPath(figure) : null;
}
private static bool IsAvatarMoving(ActorAvatar avatar)
{
return avatar != null
&& (avatar.Moving || Vector3.Distance(avatar.Obj.transform.position, avatar.Target) > StopDistance);
}
private void SetMoveState(ActorAvatar avatar, bool moving)
{
if (avatar == null || avatar.Animator == null || _behaviorWorld.HasActiveBehavior(avatar.ActorId))
{
return;
}
string state = ResolveMoveState(avatar.Animator, moving);
if (string.IsNullOrEmpty(state) || avatar.AnimState == state)
{
return;
}
avatar.AnimState = state;
avatar.Animator.CrossFade(state, 0.1f);
}
private static string ResolveMoveState(Animator animator, bool moving)
{
string fighterState = moving ? FighterWalkState : FighterStandState;
if (HasAnimatorState(animator, fighterState))
{
return fighterState;
}
string legacyState = moving ? LegacyWalkState : LegacyStandState;
return HasAnimatorState(animator, legacyState) ? legacyState : null;
}
private static bool HasAnimatorState(Animator animator, string state)
{
return animator != null
&& animator.runtimeAnimatorController != null
&& animator.HasState(0, Animator.StringToHash(state));
}
private void EnsureScenePresentation()
{
GameObject lightObject = new GameObject("MiniGameDirectionalLight");
lightObject.transform.SetParent(_root.transform, false);
lightObject.transform.rotation = Quaternion.Euler(45f, -35f, 0f);
Light light = lightObject.AddComponent<Light>();
light.type = LightType.Directional;
light.intensity = 1.15f;
GameObject cameraObject = new GameObject("MiniGameCamera");
cameraObject.transform.SetParent(_root.transform, false);
cameraObject.transform.position = new Vector3(0f, 4.2f, -6.2f);
cameraObject.transform.rotation = Quaternion.Euler(31f, 0f, 0f);
Camera camera = cameraObject.AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.06f, 0.075f, 0.09f, 1f);
camera.fieldOfView = 42f;
camera.nearClipPlane = 0.1f;
camera.farClipPlane = 80f;
GameObject ground = GameObject.CreatePrimitive(PrimitiveType.Cube);
ground.name = "MiniGameGround";
ground.transform.SetParent(_root.transform, false);
ground.transform.localPosition = new Vector3(0f, -0.05f, 0f);
ground.transform.localScale = new Vector3(7.5f, 0.1f, 2.8f);
Renderer renderer = ground.GetComponent<Renderer>();
if (renderer != null)
{
renderer.sharedMaterial = CreateRuntimeMaterial(new Color(0.16f, 0.18f, 0.19f, 1f));
}
}
private static void ConfigureAvatarRenderers(GameObject obj)
{
if (obj == null)
{
return;
}
Renderer[] renderers = obj.GetComponentsInChildren<Renderer>(true);
for (int i = 0; i < renderers.Length; i++)
{
Renderer renderer = renderers[i];
if (renderer == null)
{
continue;
}
renderer.lightProbeUsage = LightProbeUsage.BlendProbes;
renderer.reflectionProbeUsage = ReflectionProbeUsage.BlendProbes;
}
}
private static Material CreateRuntimeMaterial(Color color)
{
Shader shader = Shader.Find("Universal Render Pipeline/Lit");
if (shader == null)
{
shader = Shader.Find("Standard");
}
var material = new Material(shader);
material.color = color;
return material;
}
private static Vector3 ResolveForward(Vector3 forward, Vector3 fallback)
{
forward.y = 0f;
if (forward.sqrMagnitude <= 0.0001f)
{
forward = fallback;
}
forward.y = 0f;
if (forward.sqrMagnitude <= 0.0001f)
{
forward = Vector3.forward;
}
return forward.normalized;
}
private static BehaviorVector3 ToBehavior(Vector3 value)
{
return new BehaviorVector3(value.x, value.y, value.z);
}
private static BehaviorVector3 ToBehaviorForward(Vector3 value)
{
Vector3 forward = ResolveForward(value, Vector3.forward);
return new BehaviorVector3(forward.x, 0f, forward.z);
}
private static void DestroyUnityObject(UObject obj)
{
if (obj == null)
{
return;
}
if (Application.isPlaying)
{
UObject.Destroy(obj);
}
else
{
UObject.DestroyImmediate(obj);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 61b12bbed7cddf446a576ec50f6a915c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -30,7 +30,7 @@ namespace XGame
private const float LobbyPanelWidth = 520f;
private const float LobbyPanelHeight = 296f;
private const float LobbyPanelLeft = 24f;
private const float LobbyPanelTop = -24f;
private const float LobbyPanelMiddleY = 0f;
private const float CollapsedPanelWidth = 56f;
private void Awake()
@@ -208,10 +208,10 @@ namespace XGame
buttonGo.transform.SetParent(parent, false);
RectTransform rt = buttonGo.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 1f);
rt.anchorMax = new Vector2(0f, 1f);
rt.pivot = new Vector2(0f, 1f);
rt.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelTop);
rt.anchorMin = new Vector2(0f, 0.5f);
rt.anchorMax = new Vector2(0f, 0.5f);
rt.pivot = new Vector2(0f, 0.5f);
rt.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelMiddleY);
rt.sizeDelta = new Vector2(48f, 72f);
Image img = buttonGo.GetComponent<Image>();
@@ -254,18 +254,18 @@ namespace XGame
if (panelLobby != null)
{
panelLobby.anchorMin = new Vector2(0f, 1f);
panelLobby.anchorMax = new Vector2(0f, 1f);
panelLobby.pivot = new Vector2(0f, 1f);
panelLobby.anchoredPosition = new Vector2(LobbyPanelLeft, LobbyPanelTop);
panelLobby.anchorMin = new Vector2(0f, 0.5f);
panelLobby.anchorMax = new Vector2(0f, 0.5f);
panelLobby.pivot = new Vector2(0f, 0.5f);
panelLobby.anchoredPosition = new Vector2(LobbyPanelLeft, LobbyPanelMiddleY);
panelLobby.sizeDelta = new Vector2(LobbyPanelWidth, LobbyPanelHeight);
}
if (collapseButtonRect != null)
{
collapseButtonRect.anchorMin = new Vector2(0f, 1f);
collapseButtonRect.anchorMax = new Vector2(0f, 1f);
collapseButtonRect.pivot = new Vector2(0f, 1f);
collapseButtonRect.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelTop);
collapseButtonRect.anchorMin = new Vector2(0f, 0.5f);
collapseButtonRect.anchorMax = new Vector2(0f, 0.5f);
collapseButtonRect.pivot = new Vector2(0f, 0.5f);
collapseButtonRect.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelMiddleY);
collapseButtonRect.sizeDelta = new Vector2(48f, 72f);
}