Add networked Fighter3D minigame
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
{'code.unity3d','06f648bb8c10a1bb88489b166e1f22be',13031},
|
||||||
|
{'res.unity3d','4238a196bc5f53eb9db13d8064339ace',9111983},
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"gameId": "fighter3d",
|
||||||
|
"version": 6,
|
||||||
|
"clientEntryType": "Fighter3D.Client.FighterGameClient",
|
||||||
|
"coreDll": "Fighter3D.Core.dll",
|
||||||
|
"clientDll": "Fighter3D.Client.dll",
|
||||||
|
"minFrameworkVersion": 1,
|
||||||
|
"codeAb": "code.unity3d",
|
||||||
|
"assets": [
|
||||||
|
"res.unity3d"
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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"];
|
string spriteName = (string)comp["sprite"];
|
||||||
Sprite sprite = ResolveSprite(spriteName, fileName);
|
Sprite sprite = ResolveSprite(spriteName, fileName);
|
||||||
img.sprite = sprite;
|
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"];
|
if (comp["preserveAspect"] != null) img.preserveAspect = (bool)comp["preserveAspect"];
|
||||||
img.raycastTarget = comp["raycastTarget"] == null || (bool)comp["raycastTarget"];
|
img.raycastTarget = comp["raycastTarget"] == null || (bool)comp["raycastTarget"];
|
||||||
img.color = ParseColor((string)comp["color"], Color.white);
|
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)
|
private static TMP_InputField.ContentType ParseInputContentType(string contentType)
|
||||||
{
|
{
|
||||||
switch (contentType)
|
switch (contentType)
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ public sealed class ActorBehaviorAnimatorControllerPathTests
|
|||||||
Is.EqualTo("Assets/Game/Art/Actor/Controller/fighter.controller"));
|
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]
|
[Test]
|
||||||
public void GetControllerPath_UnknownType_ReturnsNull()
|
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)
|
public static string GetControllerPath(string catalogType)
|
||||||
{
|
{
|
||||||
return string.Equals(catalogType, LobbyActorBehaviorRuntime.DefaultCatalogType, StringComparison.OrdinalIgnoreCase)
|
return string.Equals(catalogType, LobbyActorBehaviorRuntime.DefaultCatalogType, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(catalogType, "fighter3d", StringComparison.OrdinalIgnoreCase)
|
||||||
? "Assets/Game/Art/Actor/Controller/fighter.controller"
|
? "Assets/Game/Art/Actor/Controller/fighter.controller"
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,23 @@ namespace XGame.ActorBehavior
|
|||||||
public const string ConfigRoot = "Assets/Game/Config/ActorBehavior";
|
public const string ConfigRoot = "Assets/Game/Config/ActorBehavior";
|
||||||
|
|
||||||
public static IEnumerator LoadCatalog(string type, Action<BehaviorConfigCatalog> onLoaded)
|
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 skillJson = null;
|
||||||
string flyObjectJson = null;
|
string flyObjectJson = null;
|
||||||
string buffJson = null;
|
string buffJson = null;
|
||||||
|
|
||||||
yield return LoadText(GetSkillPath(type), text => skillJson = text);
|
yield return LoadText(GetSkillPath(type, configRoot), text => skillJson = text);
|
||||||
yield return LoadText(GetFlyObjectPath(type), text => flyObjectJson = text);
|
yield return LoadText(GetFlyObjectPath(type, configRoot), text => flyObjectJson = text);
|
||||||
yield return LoadText(GetBuffPath(type), text => buffJson = text);
|
yield return LoadText(GetBuffPath(type, configRoot), text => buffJson = text);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(skillJson) || string.IsNullOrEmpty(flyObjectJson) || string.IsNullOrEmpty(buffJson))
|
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);
|
onLoaded?.Invoke(null);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
@@ -48,16 +53,38 @@ namespace XGame.ActorBehavior
|
|||||||
return ConfigRoot + "/" + type + "_skill.json";
|
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)
|
public static string GetFlyObjectPath(string type)
|
||||||
{
|
{
|
||||||
return ConfigRoot + "/" + type + "_flyobj.json";
|
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)
|
public static string GetBuffPath(string type)
|
||||||
{
|
{
|
||||||
return ConfigRoot + "/" + type + "_buff.json";
|
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)
|
private static IEnumerator LoadText(string assetPath, Action<string> onLoaded)
|
||||||
{
|
{
|
||||||
#if UNITY_EDITOR
|
#if UNITY_EDITOR
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ namespace XGame
|
|||||||
private const string LoginPlayerIdKey = "LoginPlayerId";
|
private const string LoginPlayerIdKey = "LoginPlayerId";
|
||||||
private const string LocalClientIdKey = "LocalClientId";
|
private const string LocalClientIdKey = "LocalClientId";
|
||||||
private const double TokenValidSeconds = 7 * 24 * 60 * 60;
|
private const double TokenValidSeconds = 7 * 24 * 60 * 60;
|
||||||
|
private const float LobbyGameListRefreshIntervalSeconds = 3f;
|
||||||
|
|
||||||
public static CSharpClientApp Instance { get; private set; }
|
public static CSharpClientApp Instance { get; private set; }
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ namespace XGame
|
|||||||
private string matchMessage = string.Empty;
|
private string matchMessage = string.Empty;
|
||||||
private string pendingGameId = string.Empty;
|
private string pendingGameId = string.Empty;
|
||||||
private int pendingGameVersion;
|
private int pendingGameVersion;
|
||||||
|
private float nextGameListRefreshTime;
|
||||||
private float nextLobbyMoveFrameLogTime;
|
private float nextLobbyMoveFrameLogTime;
|
||||||
private int m_pid;//当前已鉴权的玩家 id(来自登录/注册返回的 token)
|
private int m_pid;//当前已鉴权的玩家 id(来自登录/注册返回的 token)
|
||||||
|
|
||||||
@@ -113,6 +115,11 @@ namespace XGame
|
|||||||
if (lobbyActive)
|
if (lobbyActive)
|
||||||
{
|
{
|
||||||
lobbyNet?.Pump();
|
lobbyNet?.Pump();
|
||||||
|
float now = Time.unscaledTime;
|
||||||
|
if (ShouldPollGameList(lobbyActive, lobbyNet != null && lobbyNet.IsConnected, matchRequestActive, now, nextGameListRefreshTime))
|
||||||
|
{
|
||||||
|
RequestGameList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,9 +442,13 @@ namespace XGame
|
|||||||
RefreshLobby();
|
RefreshLobby();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
nextGameListRefreshTime = Time.unscaledTime + LobbyGameListRefreshIntervalSeconds;
|
||||||
lobbyNet.SendFramework(FrameworkOpcode.GameListRequest, Array.Empty<byte>());
|
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()
|
private void RefreshLobby()
|
||||||
{
|
{
|
||||||
if (lobbyController == null)
|
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 LobbyPanelWidth = 520f;
|
||||||
private const float LobbyPanelHeight = 296f;
|
private const float LobbyPanelHeight = 296f;
|
||||||
private const float LobbyPanelLeft = 24f;
|
private const float LobbyPanelLeft = 24f;
|
||||||
private const float LobbyPanelTop = -24f;
|
private const float LobbyPanelMiddleY = 0f;
|
||||||
private const float CollapsedPanelWidth = 56f;
|
private const float CollapsedPanelWidth = 56f;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -208,10 +208,10 @@ namespace XGame
|
|||||||
buttonGo.transform.SetParent(parent, false);
|
buttonGo.transform.SetParent(parent, false);
|
||||||
|
|
||||||
RectTransform rt = buttonGo.GetComponent<RectTransform>();
|
RectTransform rt = buttonGo.GetComponent<RectTransform>();
|
||||||
rt.anchorMin = new Vector2(0f, 1f);
|
rt.anchorMin = new Vector2(0f, 0.5f);
|
||||||
rt.anchorMax = new Vector2(0f, 1f);
|
rt.anchorMax = new Vector2(0f, 0.5f);
|
||||||
rt.pivot = new Vector2(0f, 1f);
|
rt.pivot = new Vector2(0f, 0.5f);
|
||||||
rt.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelTop);
|
rt.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelMiddleY);
|
||||||
rt.sizeDelta = new Vector2(48f, 72f);
|
rt.sizeDelta = new Vector2(48f, 72f);
|
||||||
|
|
||||||
Image img = buttonGo.GetComponent<Image>();
|
Image img = buttonGo.GetComponent<Image>();
|
||||||
@@ -254,18 +254,18 @@ namespace XGame
|
|||||||
|
|
||||||
if (panelLobby != null)
|
if (panelLobby != null)
|
||||||
{
|
{
|
||||||
panelLobby.anchorMin = new Vector2(0f, 1f);
|
panelLobby.anchorMin = new Vector2(0f, 0.5f);
|
||||||
panelLobby.anchorMax = new Vector2(0f, 1f);
|
panelLobby.anchorMax = new Vector2(0f, 0.5f);
|
||||||
panelLobby.pivot = new Vector2(0f, 1f);
|
panelLobby.pivot = new Vector2(0f, 0.5f);
|
||||||
panelLobby.anchoredPosition = new Vector2(LobbyPanelLeft, LobbyPanelTop);
|
panelLobby.anchoredPosition = new Vector2(LobbyPanelLeft, LobbyPanelMiddleY);
|
||||||
panelLobby.sizeDelta = new Vector2(LobbyPanelWidth, LobbyPanelHeight);
|
panelLobby.sizeDelta = new Vector2(LobbyPanelWidth, LobbyPanelHeight);
|
||||||
}
|
}
|
||||||
if (collapseButtonRect != null)
|
if (collapseButtonRect != null)
|
||||||
{
|
{
|
||||||
collapseButtonRect.anchorMin = new Vector2(0f, 1f);
|
collapseButtonRect.anchorMin = new Vector2(0f, 0.5f);
|
||||||
collapseButtonRect.anchorMax = new Vector2(0f, 1f);
|
collapseButtonRect.anchorMax = new Vector2(0f, 0.5f);
|
||||||
collapseButtonRect.pivot = new Vector2(0f, 1f);
|
collapseButtonRect.pivot = new Vector2(0f, 0.5f);
|
||||||
collapseButtonRect.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelTop);
|
collapseButtonRect.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelMiddleY);
|
||||||
collapseButtonRect.sizeDelta = new Vector2(48f, 72f);
|
collapseButtonRect.sizeDelta = new Vector2(48f, 72f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<LangVersion>9.0</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<AssemblyName>Fighter3D.Client</AssemblyName>
|
||||||
|
<RootNamespace>Fighter3D.Client</RootNamespace>
|
||||||
|
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||||
|
<UnityEditorPath Condition="'$(UnityEditorPath)' == ''">D:\Program Files\Unity 2022.3.62f3\Editor</UnityEditorPath>
|
||||||
|
<ScriptAssemblies>$(MSBuildProjectDirectory)\..\..\Library\ScriptAssemblies</ScriptAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\Assets\MiniGames\Fighter3D\scripts~\Client\**\*.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Server\Framework.Shared\Framework.Shared.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
</ProjectReference>
|
||||||
|
<ProjectReference Include="..\..\..\Server\games-src\Fighter3D\Fighter3D.Core\Fighter3D.Core.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="UnityEngine">
|
||||||
|
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.CoreModule">
|
||||||
|
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.InputLegacyModule">
|
||||||
|
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.InputLegacyModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.TextRenderingModule">
|
||||||
|
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.TextRenderingModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.UIModule">
|
||||||
|
<HintPath>$(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.UIModule.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.UI">
|
||||||
|
<HintPath>$(ScriptAssemblies)\UnityEngine.UI.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Unity.TextMeshPro">
|
||||||
|
<HintPath>$(ScriptAssemblies)\Unity.TextMeshPro.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="XWorld.Link">
|
||||||
|
<HintPath>$(ScriptAssemblies)\XWorld.Link.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"prefabName": "UI_Fighter3D",
|
||||||
|
"prefabPath": "Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab",
|
||||||
|
"description": "Fighter3D battle HUD. Runtime loads this mini-game prefab directly, keeps it alive for the match, and binds Txt_Title, Txt_Left, Txt_Right, Img_LeftHpFill, and Img_RightHpFill by name.",
|
||||||
|
"canvas": {
|
||||||
|
"canvasScaler": {
|
||||||
|
"referenceResolution": [2048, 1024],
|
||||||
|
"matchWidthOrHeight": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"sprites": {
|
||||||
|
"ui_panel_header": "Assets/Game/Art/UI/Texture/Common/ui_panel_header.png",
|
||||||
|
"ui_panel_content": "Assets/Game/Art/UI/Texture/Common/ui_panel_content.png",
|
||||||
|
"progress_bg": "Assets/Game/Art/UI/Texture/Common/progress_bg.png",
|
||||||
|
"progress_fill_green": "Assets/Game/Art/UI/Texture/Common/progress_fill_green.png",
|
||||||
|
"progress_fill_warn": "Assets/Game/Art/UI/Texture/Common/progress_fill_warn.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "UI_Fighter3D",
|
||||||
|
"parent": null,
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 0],
|
||||||
|
"anchorMax": [1, 1],
|
||||||
|
"offsetMin": [0, 0],
|
||||||
|
"offsetMax": [0, 0]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Canvas",
|
||||||
|
"sortingOrder": 25000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "CanvasScaler",
|
||||||
|
"referenceResolution": [2048, 1024],
|
||||||
|
"matchWidthOrHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "GraphicRaycaster"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "CanvasGroup",
|
||||||
|
"alpha": 0.96,
|
||||||
|
"interactable": false,
|
||||||
|
"blocksRaycasts": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Panel_Top",
|
||||||
|
"parent": "UI_Fighter3D",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0.5, 1],
|
||||||
|
"anchorMax": [0.5, 1],
|
||||||
|
"pivot": [0.5, 1],
|
||||||
|
"anchoredPosition": [0, -28],
|
||||||
|
"sizeDelta": [1120, 112]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Image",
|
||||||
|
"sprite": "ui_panel_header",
|
||||||
|
"imageType": "Sliced",
|
||||||
|
"raycastTarget": false,
|
||||||
|
"color": "#FFFFFFFF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Shadow",
|
||||||
|
"effectColor": "#06101F66",
|
||||||
|
"effectDistance": [0, -6],
|
||||||
|
"useGraphicAlpha": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Txt_Title",
|
||||||
|
"parent": "Panel_Top",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0.5, 0.5],
|
||||||
|
"anchorMax": [0.5, 0.5],
|
||||||
|
"pivot": [0.5, 0.5],
|
||||||
|
"anchoredPosition": [0, 10],
|
||||||
|
"sizeDelta": [760, 64]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "TextMeshProUGUI",
|
||||||
|
"text": "Fighter3D",
|
||||||
|
"fontSize": 44,
|
||||||
|
"fontStyle": "Bold",
|
||||||
|
"alignment": "Center",
|
||||||
|
"color": "#F6F8FFFF",
|
||||||
|
"raycastTarget": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Outline",
|
||||||
|
"effectColor": "#102033CC",
|
||||||
|
"effectDistance": [2, -2],
|
||||||
|
"useGraphicAlpha": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Group_LeftHud",
|
||||||
|
"parent": "UI_Fighter3D",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 1],
|
||||||
|
"anchorMax": [0, 1],
|
||||||
|
"pivot": [0, 1],
|
||||||
|
"anchoredPosition": [48, -132],
|
||||||
|
"sizeDelta": [720, 128]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Image",
|
||||||
|
"sprite": "ui_panel_content",
|
||||||
|
"imageType": "Sliced",
|
||||||
|
"raycastTarget": false,
|
||||||
|
"color": "#1D3350CC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Shadow",
|
||||||
|
"effectColor": "#06101F5C",
|
||||||
|
"effectDistance": [0, -5],
|
||||||
|
"useGraphicAlpha": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Txt_Left",
|
||||||
|
"parent": "Group_LeftHud",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 1],
|
||||||
|
"anchorMax": [1, 1],
|
||||||
|
"pivot": [0.5, 1],
|
||||||
|
"anchoredPosition": [0, -16],
|
||||||
|
"sizeDelta": [-48, 52]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "TextMeshProUGUI",
|
||||||
|
"text": "P1",
|
||||||
|
"fontSize": 28,
|
||||||
|
"fontStyle": "Bold",
|
||||||
|
"alignment": "Left",
|
||||||
|
"color": "#F6F8FFFF",
|
||||||
|
"raycastTarget": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Outline",
|
||||||
|
"effectColor": "#102033CC",
|
||||||
|
"effectDistance": [1, -1],
|
||||||
|
"useGraphicAlpha": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Img_LeftHpBg",
|
||||||
|
"parent": "Group_LeftHud",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 0],
|
||||||
|
"anchorMax": [1, 0],
|
||||||
|
"pivot": [0.5, 0],
|
||||||
|
"anchoredPosition": [0, 24],
|
||||||
|
"sizeDelta": [-48, 40]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Image",
|
||||||
|
"sprite": "progress_bg",
|
||||||
|
"imageType": "Sliced",
|
||||||
|
"raycastTarget": false,
|
||||||
|
"color": "#FFFFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Img_LeftHpFill",
|
||||||
|
"parent": "Img_LeftHpBg",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 0],
|
||||||
|
"anchorMax": [1, 1],
|
||||||
|
"offsetMin": [0, 0],
|
||||||
|
"offsetMax": [0, 0]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Image",
|
||||||
|
"sprite": "progress_fill_green",
|
||||||
|
"imageType": "Filled",
|
||||||
|
"fillMethod": "Horizontal",
|
||||||
|
"raycastTarget": false,
|
||||||
|
"color": "#FFFFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Group_RightHud",
|
||||||
|
"parent": "UI_Fighter3D",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [1, 1],
|
||||||
|
"anchorMax": [1, 1],
|
||||||
|
"pivot": [1, 1],
|
||||||
|
"anchoredPosition": [-48, -132],
|
||||||
|
"sizeDelta": [720, 128]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Image",
|
||||||
|
"sprite": "ui_panel_content",
|
||||||
|
"imageType": "Sliced",
|
||||||
|
"raycastTarget": false,
|
||||||
|
"color": "#501D2FCC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Shadow",
|
||||||
|
"effectColor": "#06101F5C",
|
||||||
|
"effectDistance": [0, -5],
|
||||||
|
"useGraphicAlpha": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Txt_Right",
|
||||||
|
"parent": "Group_RightHud",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 1],
|
||||||
|
"anchorMax": [1, 1],
|
||||||
|
"pivot": [0.5, 1],
|
||||||
|
"anchoredPosition": [0, -16],
|
||||||
|
"sizeDelta": [-48, 52]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "TextMeshProUGUI",
|
||||||
|
"text": "P2",
|
||||||
|
"fontSize": 28,
|
||||||
|
"fontStyle": "Bold",
|
||||||
|
"alignment": "Right",
|
||||||
|
"color": "#F6F8FFFF",
|
||||||
|
"raycastTarget": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Outline",
|
||||||
|
"effectColor": "#102033CC",
|
||||||
|
"effectDistance": [1, -1],
|
||||||
|
"useGraphicAlpha": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Img_RightHpBg",
|
||||||
|
"parent": "Group_RightHud",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 0],
|
||||||
|
"anchorMax": [1, 0],
|
||||||
|
"pivot": [0.5, 0],
|
||||||
|
"anchoredPosition": [0, 24],
|
||||||
|
"sizeDelta": [-48, 40]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Image",
|
||||||
|
"sprite": "progress_bg",
|
||||||
|
"imageType": "Sliced",
|
||||||
|
"raycastTarget": false,
|
||||||
|
"color": "#FFFFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Img_RightHpFill",
|
||||||
|
"parent": "Img_RightHpBg",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0, 0],
|
||||||
|
"anchorMax": [1, 1],
|
||||||
|
"offsetMin": [0, 0],
|
||||||
|
"offsetMax": [0, 0]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "Image",
|
||||||
|
"sprite": "progress_fill_warn",
|
||||||
|
"imageType": "Filled",
|
||||||
|
"fillMethod": "Horizontal",
|
||||||
|
"raycastTarget": false,
|
||||||
|
"color": "#FFFFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Txt_Help",
|
||||||
|
"parent": "UI_Fighter3D",
|
||||||
|
"active": true,
|
||||||
|
"rect": {
|
||||||
|
"anchorMin": [0.5, 0],
|
||||||
|
"anchorMax": [0.5, 0],
|
||||||
|
"pivot": [0.5, 0],
|
||||||
|
"anchoredPosition": [0, 36],
|
||||||
|
"sizeDelta": [920, 52]
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "TextMeshProUGUI",
|
||||||
|
"text": "A/D move S guard J light K heavy",
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontStyle": "Bold",
|
||||||
|
"alignment": "Center",
|
||||||
|
"color": "#F6F8FFFF",
|
||||||
|
"raycastTarget": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Outline",
|
||||||
|
"effectColor": "#102033CC",
|
||||||
|
"effectDistance": [1, -1],
|
||||||
|
"useGraphicAlpha": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bindings": [
|
||||||
|
"Txt_Title",
|
||||||
|
"Txt_Left",
|
||||||
|
"Txt_Right",
|
||||||
|
"Img_LeftHpFill",
|
||||||
|
"Img_RightHpFill",
|
||||||
|
"Txt_Help"
|
||||||
|
],
|
||||||
|
"events": []
|
||||||
|
}
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
using Fighter3D.Core;
|
||||||
|
using XWorld.Framework;
|
||||||
|
|
||||||
|
namespace XWorld.Server.Host.Tests
|
||||||
|
{
|
||||||
|
public class Fighter3DLogicTests
|
||||||
|
{
|
||||||
|
private static FighterState NewState()
|
||||||
|
{
|
||||||
|
return new FighterLogic().CreateInitial(
|
||||||
|
new RoomConfig { GameId = "fighter3d", Version = 1, Seed = 1, PlayerCount = 2 },
|
||||||
|
new DeterministicRandom(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InitialState_StartsTwoFightersMirrored()
|
||||||
|
{
|
||||||
|
var state = NewState();
|
||||||
|
|
||||||
|
Assert.Equal(FighterPhase.Running, state.Phase);
|
||||||
|
Assert.Equal(1000, state.Fighters[0].Hp);
|
||||||
|
Assert.Equal(1000, state.Fighters[1].Hp);
|
||||||
|
Assert.True(state.Fighters[0].X < state.Fighters[1].X);
|
||||||
|
Assert.Equal(1, state.Fighters[0].Facing);
|
||||||
|
Assert.Equal(-1, state.Fighters[1].Facing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Movement_ClampsToStageBounds()
|
||||||
|
{
|
||||||
|
var logic = new FighterLogic();
|
||||||
|
var state = NewState();
|
||||||
|
|
||||||
|
state = logic.Step(state, FighterInput.Controls(0, -1, FighterButtons.None), null).State;
|
||||||
|
for (int i = 0; i < 600; i++)
|
||||||
|
state = logic.Step(state, FighterInput.Tick(1f / FighterLogic.TickRate), null).State;
|
||||||
|
|
||||||
|
Assert.Equal(-FighterLogic.StageHalfWidth, state.Fighters[0].X, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LightAttack_HitsOpponentAndAppliesStun()
|
||||||
|
{
|
||||||
|
var logic = new FighterLogic();
|
||||||
|
var state = NewState();
|
||||||
|
state.Fighters[0].X = -0.4f;
|
||||||
|
state.Fighters[1].X = 0.4f;
|
||||||
|
|
||||||
|
state = logic.Step(state, FighterInput.Controls(0, 0, FighterButtons.Light), null).State;
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
state = logic.Step(state, FighterInput.Tick(1f / FighterLogic.TickRate), null).State;
|
||||||
|
|
||||||
|
Assert.True(state.Fighters[1].Hp < 1000);
|
||||||
|
Assert.Equal(FighterAction.HitStun, state.Fighters[1].Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HoldingAway_BlocksIncomingAttack()
|
||||||
|
{
|
||||||
|
var logic = new FighterLogic();
|
||||||
|
var state = NewState();
|
||||||
|
state.Fighters[0].X = -0.4f;
|
||||||
|
state.Fighters[1].X = 0.4f;
|
||||||
|
|
||||||
|
state = logic.Step(state, FighterInput.Controls(1, 1, FighterButtons.None), null).State;
|
||||||
|
state = logic.Step(state, FighterInput.Controls(0, 0, FighterButtons.Light), null).State;
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
state = logic.Step(state, FighterInput.Tick(1f / FighterLogic.TickRate), null).State;
|
||||||
|
|
||||||
|
Assert.Equal(1000, state.Fighters[1].Hp);
|
||||||
|
Assert.Equal(FighterAction.BlockStun, state.Fighters[1].Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Timeout_WinnerIsHigherHealthSeat()
|
||||||
|
{
|
||||||
|
var logic = new FighterLogic();
|
||||||
|
var state = NewState();
|
||||||
|
state.Fighters[1].Hp = 900;
|
||||||
|
|
||||||
|
state = logic.Step(state, FighterInput.Tick(FighterLogic.RoundSeconds), null).State;
|
||||||
|
|
||||||
|
Assert.Equal(FighterPhase.Finished, state.Phase);
|
||||||
|
Assert.Equal(0, state.WinnerSeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncodeDecode_RoundTripsSnapshot()
|
||||||
|
{
|
||||||
|
var logic = new FighterLogic();
|
||||||
|
var state = NewState();
|
||||||
|
state.Tick = 123;
|
||||||
|
state.ElapsedSeconds = 2.5f;
|
||||||
|
state.Fighters[0].Hp = 777;
|
||||||
|
state.Fighters[1].Action = FighterAction.BlockStun;
|
||||||
|
state.Fighters[1].ActionFrame = 6;
|
||||||
|
state.WinnerSeat = 1;
|
||||||
|
|
||||||
|
var back = logic.Decode(logic.Encode(state));
|
||||||
|
|
||||||
|
Assert.Equal(123, back.Tick);
|
||||||
|
Assert.Equal(2.5f, back.ElapsedSeconds, 3);
|
||||||
|
Assert.Equal(777, back.Fighters[0].Hp);
|
||||||
|
Assert.Equal(FighterAction.BlockStun, back.Fighters[1].Action);
|
||||||
|
Assert.Equal(6, back.Fighters[1].ActionFrame);
|
||||||
|
Assert.Equal(1, back.WinnerSeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Fighter3D.Core;
|
||||||
|
using Fighter3D.Server;
|
||||||
|
using XWorld.Framework;
|
||||||
|
using XWorld.Framework.Protocol;
|
||||||
|
using XWorld.Server.Host;
|
||||||
|
|
||||||
|
namespace XWorld.Server.Host.Tests
|
||||||
|
{
|
||||||
|
public class Fighter3DServerRoomTests
|
||||||
|
{
|
||||||
|
private sealed class FakeOutput : IRoomOutput
|
||||||
|
{
|
||||||
|
public readonly List<NetMessage> Broadcasts = new List<NetMessage>();
|
||||||
|
public void Broadcast(NetMessage message) => Broadcasts.Add(message);
|
||||||
|
public void SendTo(int playerId, NetMessage message) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<PlayerInfo> TwoHumans = new[]
|
||||||
|
{
|
||||||
|
new PlayerInfo { PlayerId = 10, Name = "A", IsAI = false },
|
||||||
|
new PlayerInfo { PlayerId = 20, Name = "B", IsAI = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (RoomCtx Ctx, FakeOutput Output, bool[] Ended, RoomEndResult[] Result) BuildCtx()
|
||||||
|
{
|
||||||
|
var output = new FakeOutput();
|
||||||
|
var ended = new bool[1];
|
||||||
|
var result = new RoomEndResult[1];
|
||||||
|
var ctx = new RoomCtx(
|
||||||
|
new DeterministicRandom(42),
|
||||||
|
new ManualClock(),
|
||||||
|
new ConsoleLogger(),
|
||||||
|
new InMemoryStorage(),
|
||||||
|
output,
|
||||||
|
r => { ended[0] = true; result[0] = r; });
|
||||||
|
|
||||||
|
return (ctx, output, ended, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NetMessage ControlsMsg(int axis, FighterButtons buttons)
|
||||||
|
{
|
||||||
|
var writer = new PacketWriter();
|
||||||
|
writer.WriteVarInt(axis);
|
||||||
|
writer.WriteByte((byte)buttons);
|
||||||
|
return new NetMessage(FighterServerRoom.InputOpcode, writer.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FighterState LastSnapshot(FakeOutput output)
|
||||||
|
{
|
||||||
|
var last = output.Broadcasts[output.Broadcasts.Count - 1];
|
||||||
|
Assert.Equal(FighterServerRoom.SnapshotOpcode, last.Opcode);
|
||||||
|
return new FighterLogic().Decode(last.Payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnRoomStart_BroadcastsInitialSnapshot()
|
||||||
|
{
|
||||||
|
var (ctx, output, _, _) = BuildCtx();
|
||||||
|
var room = new FighterServerRoom();
|
||||||
|
|
||||||
|
room.OnRoomStart(TwoHumans, ctx);
|
||||||
|
|
||||||
|
Assert.NotEmpty(output.Broadcasts);
|
||||||
|
var snapshot = LastSnapshot(output);
|
||||||
|
Assert.Equal(FighterPhase.Running, snapshot.Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatedHeavyAttacks_CanKoAndEndRoomWithWinnerPid()
|
||||||
|
{
|
||||||
|
var (ctx, output, ended, result) = BuildCtx();
|
||||||
|
var room = new FighterServerRoom();
|
||||||
|
room.OnRoomStart(TwoHumans, ctx);
|
||||||
|
|
||||||
|
room.OnMessage(10, ControlsMsg(0, FighterButtons.Heavy));
|
||||||
|
for (int i = 0; i < 800 && !ended[0]; i++)
|
||||||
|
room.OnTick(1f / 20f);
|
||||||
|
|
||||||
|
Assert.True(ended[0]);
|
||||||
|
Assert.NotNull(result[0]);
|
||||||
|
Assert.Equal(10, result[0].WinnerPlayerId);
|
||||||
|
Assert.Contains("Fighter3D", Encoding.UTF8.GetString(result[0].ResultBlob));
|
||||||
|
|
||||||
|
var snapshot = LastSnapshot(output);
|
||||||
|
Assert.Equal(FighterPhase.Finished, snapshot.Phase);
|
||||||
|
Assert.Equal(0, snapshot.WinnerSeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
|
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
|
||||||
<ProjectReference Include="..\games-src\RPS\RPS.Core\RPS.Core.csproj" />
|
<ProjectReference Include="..\games-src\RPS\RPS.Core\RPS.Core.csproj" />
|
||||||
<ProjectReference Include="..\games-src\RPS\RPS.Server\RPS.Server.csproj" />
|
<ProjectReference Include="..\games-src\RPS\RPS.Server\RPS.Server.csproj" />
|
||||||
|
<ProjectReference Include="..\games-src\Fighter3D\Fighter3D.Core\Fighter3D.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\games-src\Fighter3D\Fighter3D.Server\Fighter3D.Server.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<LangVersion>9.0</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<AssemblyName>Fighter3D.Core</AssemblyName>
|
||||||
|
<RootNamespace>Fighter3D.Core</RootNamespace>
|
||||||
|
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\..\..\..\Client\Assets\MiniGames\Fighter3D\scripts~\Core\**\*.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<LangVersion>9.0</LangVersion>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<AssemblyName>Fighter3D.Server</AssemblyName>
|
||||||
|
<RootNamespace>Fighter3D.Server</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
</ProjectReference>
|
||||||
|
<ProjectReference Include="..\Fighter3D.Core\Fighter3D.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using Fighter3D.Core;
|
||||||
|
using XWorld.Framework;
|
||||||
|
using XWorld.Framework.Protocol;
|
||||||
|
|
||||||
|
namespace Fighter3D.Server
|
||||||
|
{
|
||||||
|
public sealed class FighterServerRoom : IGameServerRoom
|
||||||
|
{
|
||||||
|
public const ushort InputOpcode = 1;
|
||||||
|
public const ushort SnapshotOpcode = 100;
|
||||||
|
|
||||||
|
private readonly FighterLogic _logic = new FighterLogic();
|
||||||
|
private readonly Dictionary<int, int> _seatOfPlayer = new Dictionary<int, int>();
|
||||||
|
private readonly int[] _playerOfSeat = new int[2];
|
||||||
|
private IRoomCtx _ctx;
|
||||||
|
private FighterState _state;
|
||||||
|
private bool _ended;
|
||||||
|
|
||||||
|
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx)
|
||||||
|
{
|
||||||
|
_ctx = ctx;
|
||||||
|
_state = _logic.CreateInitial(
|
||||||
|
new RoomConfig { GameId = "fighter3d", Version = 1, Seed = 1, PlayerCount = players.Count },
|
||||||
|
ctx.Random);
|
||||||
|
|
||||||
|
for (int i = 0; i < players.Count && i < 2; i++)
|
||||||
|
{
|
||||||
|
_seatOfPlayer[players[i].PlayerId] = i;
|
||||||
|
_playerOfSeat[i] = players[i].PlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMessage(int playerId, NetMessage message)
|
||||||
|
{
|
||||||
|
if (_ended || message.Opcode != InputOpcode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_seatOfPlayer.TryGetValue(playerId, out int seat))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reader = new PacketReader(message.Payload);
|
||||||
|
int axis = reader.ReadVarInt();
|
||||||
|
var buttons = (FighterButtons)reader.ReadByte();
|
||||||
|
_state = _logic.Step(_state, FighterInput.Controls(seat, axis, buttons), _ctx.Random).State;
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
_ctx.Logger.Error($"fighter3d input decode failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnTick(float deltaTime)
|
||||||
|
{
|
||||||
|
if (_ended)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = _logic.Step(_state, FighterInput.Tick(deltaTime), _ctx.Random);
|
||||||
|
_state = result.State;
|
||||||
|
Broadcast();
|
||||||
|
|
||||||
|
if (_state.Phase == FighterPhase.Finished)
|
||||||
|
EndRoomOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnRoomEnd()
|
||||||
|
{
|
||||||
|
_ctx?.Logger.Info("fighter3d room end");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Broadcast()
|
||||||
|
{
|
||||||
|
_ctx.Broadcast(new NetMessage(SnapshotOpcode, _logic.Encode(_state)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndRoomOnce()
|
||||||
|
{
|
||||||
|
if (_ended)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_ended = true;
|
||||||
|
int winnerPid = _state.WinnerSeat >= 0 && _state.WinnerSeat < 2
|
||||||
|
? _playerOfSeat[_state.WinnerSeat]
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
string text = _state.WinnerSeat == 2
|
||||||
|
? "Fighter3D draw"
|
||||||
|
: $"Fighter3D winner seat {_state.WinnerSeat}";
|
||||||
|
|
||||||
|
_ctx.EndRoom(new RoomEndResult
|
||||||
|
{
|
||||||
|
WinnerPlayerId = winnerPid,
|
||||||
|
ResultBlob = Encoding.UTF8.GetBytes(text)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
{'Fighter3D.Core.dll','7566025462c89bf1c540deb954ea3ac2',9728},
|
||||||
|
{'Fighter3D.Server.dll','454f24ecbdcacb6dabb8c29d4187f51e',6656},
|
||||||
|
{'game.json','8cfa51d2bdfbedc691e5b3cec106b88d',222},
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"gameId": "fighter3d",
|
||||||
|
"version": 6,
|
||||||
|
"serverAssembly": "Fighter3D.Server.dll",
|
||||||
|
"serverEntryType": "Fighter3D.Server.FighterServerRoom",
|
||||||
|
"minFrameworkVersion": 1,
|
||||||
|
"playerCount": 2,
|
||||||
|
"tickRateHz": 20
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Fighter3D Common Actor Presentation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace Fighter3D's private battle-stage actor presentation with the shared character loading, movement, and actor behavior presentation pipeline while keeping Fighter3D-specific behavior configs.
|
||||||
|
|
||||||
|
**Architecture:** Add a reusable mini-game actor presentation runner in the main Unity assembly. Fighter3D.Client feeds authoritative snapshots into that runner, which loads player character prefabs from `CharacterConfig.json`, moves common actor instances, and triggers common `BehaviorWorld`/`ActorBehaviorPresentation` events from Fighter3D-specific configs.
|
||||||
|
|
||||||
|
**Tech Stack:** Unity 2022.3.62f3, UGUI/TMP, XResLoader, CharacterConfigData, ActorBehaviorConfigLoader, BehaviorWorld, ActorBehaviorPresentation, Unity Pipeline tests.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Do not hand-edit Unity serialized assets or `.meta`; use Unity Pipeline for prefab/material/asset operations.
|
||||||
|
- Keep Fighter3D combat logic server authoritative; client presentation only consumes snapshots.
|
||||||
|
- Fighter3D skill config must live under `Assets/MiniGames/Fighter3D/res/Config/ActorBehavior`.
|
||||||
|
- Publish a new Fighter3D version after verification so clients download the new resource/code bundles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Regression Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Client/Assets/Script/Tests/EditMode/GameLobbyControllerLayoutTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: current `FighterGameClient.cs` source and Unity AssetDatabase.
|
||||||
|
- Produces: tests requiring `MiniGameActorPresentation`, Fighter3D-specific config paths, and removal of private stage prefab loading.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Add tests asserting `FighterGameClient.cs` contains `MiniGameActorPresentation`, does not contain `Fighter3D_BattleStage.prefab`, and that `fighter3d_skill.json`, `fighter3d_flyobj.json`, `fighter3d_buff.json` exist.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify failure**
|
||||||
|
|
||||||
|
Run: `unity command run_tests --project-path "D:\UD\AI\AIC#Project\Client" --mode editor --filter GameLobbyControllerLayoutTests.Fighter3D --filter_type testName`
|
||||||
|
Expected: FAIL before implementation because the old stage path remains and config files are missing.
|
||||||
|
|
||||||
|
### Task 2: Shared Mini-Game Actor Presentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Client/Assets/Script/xmain/MiniGame/MiniGameActorPresentation.cs`
|
||||||
|
- Modify: `Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorConfigLoader.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `XGame.MiniGame.MiniGameActorPresentation`, with `Render(FighterStateLike input)` represented by public `SetFighterSnapshot` overloads from Fighter3D.Client.
|
||||||
|
- Produces: `ActorBehaviorConfigLoader.LoadCatalogFromRoot(string type, string configRoot, Action<BehaviorConfigCatalog>)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement minimal shared runner**
|
||||||
|
|
||||||
|
Create a runner that loads character prefabs by figure, tracks actor transforms, lerps movement, registers actors with `ActorBehaviorPresentation`, loads behavior config from a supplied root, and can trigger light/heavy behavior IDs when actions enter attack states.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests**
|
||||||
|
|
||||||
|
Run the Fighter3D tests and fix compilation issues.
|
||||||
|
|
||||||
|
### Task 3: Fighter3D Integration And Config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Client/Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs`
|
||||||
|
- Modify: `Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj`
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_skill.json`
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_flyobj.json`
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_buff.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `MiniGameActorPresentation`.
|
||||||
|
- Produces: Fighter3D client using shared presentation, not private stage actor transforms.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `FighterStage` with `MiniGameActorPresentation`**
|
||||||
|
|
||||||
|
Instantiate the shared runner on enter, call it from snapshot/update, destroy it on exit, and map light/heavy actions to Fighter3D behavior IDs.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Fighter3D behavior config**
|
||||||
|
|
||||||
|
Provide mini-game-specific behavior config that can drive common animation/effect presentation without changing main `Assets/Game/Config/ActorBehavior/fighter_*`.
|
||||||
|
|
||||||
|
### Task 4: Verify And Publish
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Output: `CDN/minigame/fighter3d/<next>/pc/*`
|
||||||
|
- Output: `deploy/minigame/fighter3d/<next>/*`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all previous tasks.
|
||||||
|
- Produces: new published Fighter3D package.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify**
|
||||||
|
|
||||||
|
Run Unity EditMode tests, `dotnet build` for client/server, and Fighter3D server tests.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Publish**
|
||||||
|
|
||||||
|
Run `%TEMP%\PublishFighter3D.cs` with Unity Pipeline and inspect the generated manifest/assets.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Fighter3D Networked MiniGame Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a first playable server-authoritative Fighter3D networked mini game in the existing hot-update mini game framework.
|
||||||
|
|
||||||
|
**Architecture:** Follow the existing RockPaperScissors mini game layout. Put deterministic combat rules and snapshot codec in `Fighter3D.Core`, room authority in `Fighter3D.Server`, and Unity-only UI/input in `Fighter3D.Client`.
|
||||||
|
|
||||||
|
**Tech Stack:** C# 9, .NET `netstandard2.1` hot-update projects, xUnit server tests, Unity runtime APIs only inside the client hot-update assembly.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Do not hand-edit Unity `.meta`, `.prefab`, `.unity`, `.mat`, `.asset`, `.controller`, or `.anim` files.
|
||||||
|
- Core must not reference `UnityEngine`.
|
||||||
|
- Client must send gameplay messages only through `IGameClientCtx.Send`.
|
||||||
|
- Client assets must load through `ctx.Assets.Load`.
|
||||||
|
- Server is authoritative for damage, block, KO, winner, and settlement.
|
||||||
|
- First playable scope excludes jump, crouch, command specials, supers, polished 3D assets, and replay tooling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Core Combat Model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterTypes.cs`
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterLogic.cs`
|
||||||
|
- Create: `Server/games-src/Fighter3D/Fighter3D.Core/Fighter3D.Core.csproj`
|
||||||
|
- Modify: `Server/Server.Host.Tests/Server.Host.Tests.csproj`
|
||||||
|
- Test: `Server/Server.Host.Tests/Fighter3DLogicTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Fighter3D.Core.FighterLogic.CreateInitial(RoomConfig, IRandom) : FighterState`
|
||||||
|
- Produces: `Fighter3D.Core.FighterLogic.Step(FighterState, FighterInput, IRandom) : StepResult<FighterState, FighterEvent>`
|
||||||
|
- Produces: `Fighter3D.Core.FighterLogic.Encode(FighterState) : byte[]`
|
||||||
|
- Produces: `Fighter3D.Core.FighterLogic.Decode(byte[]) : FighterState`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing core tests**
|
||||||
|
|
||||||
|
Create tests that assert initial state, movement clamp, light hit damage/stun, guard block, timeout winner, and codec round-trip.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests and verify red**
|
||||||
|
|
||||||
|
Run: `dotnet test Server/Server.Host.Tests/Server.Host.Tests.csproj --filter Fighter3DLogicTests`
|
||||||
|
|
||||||
|
Expected: compile failure because `Fighter3D.Core` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal core**
|
||||||
|
|
||||||
|
Implement two fighters, fixed 60 Hz step accumulation, stage bounds, facing, horizontal movement, light/heavy attacks, attack active collision by range, guard-away block, hit/block stun, HP, KO, timeout, and packet codec.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests and verify green**
|
||||||
|
|
||||||
|
Run: `dotnet test Server/Server.Host.Tests/Server.Host.Tests.csproj --filter Fighter3DLogicTests`
|
||||||
|
|
||||||
|
Expected: all Fighter3D core tests pass.
|
||||||
|
|
||||||
|
### Task 2: Server Room Authority
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Server/games-src/Fighter3D/Fighter3D.Server/Fighter3D.Server.csproj`
|
||||||
|
- Create: `Server/games-src/Fighter3D/Fighter3D.Server/FighterServerRoom.cs`
|
||||||
|
- Modify: `Server/Server.Host.Tests/Server.Host.Tests.csproj`
|
||||||
|
- Test: `Server/Server.Host.Tests/Fighter3DServerRoomTests.cs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `FighterLogic`, `FighterInput`, `FighterState`
|
||||||
|
- Produces: `Fighter3D.Server.FighterServerRoom : IGameServerRoom`
|
||||||
|
- Produces: `FighterServerRoom.InputOpcode = 1`
|
||||||
|
- Produces: `FighterServerRoom.SnapshotOpcode = 100`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing room tests**
|
||||||
|
|
||||||
|
Create tests that start a two-player room, assert initial broadcast, send attack input from player 1, tick until KO, and assert `EndRoom` winner is player 1.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests and verify red**
|
||||||
|
|
||||||
|
Run: `dotnet test Server/Server.Host.Tests/Server.Host.Tests.csproj --filter Fighter3DServerRoomTests`
|
||||||
|
|
||||||
|
Expected: compile failure because `Fighter3D.Server` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal room**
|
||||||
|
|
||||||
|
Map `PlayerInfo.PlayerId` to seats, decode input messages, feed Core, broadcast snapshots each room tick, and call `EndRoom(RoomEndResult)` exactly once when Core is finished.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests and verify green**
|
||||||
|
|
||||||
|
Run: `dotnet test Server/Server.Host.Tests/Server.Host.Tests.csproj --filter Fighter3DServerRoomTests`
|
||||||
|
|
||||||
|
Expected: all Fighter3D server room tests pass.
|
||||||
|
|
||||||
|
### Task 3: Client Hot-Update Shell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs`
|
||||||
|
- Create: `Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `FighterLogic.Decode(byte[])`
|
||||||
|
- Produces: `Fighter3D.Client.FighterGameClient : IGameClient`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement client shell**
|
||||||
|
|
||||||
|
Load `Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab` through `ctx.Assets.Load`, fallback to a generated Canvas if it is missing, bind no-prefab UI labels, send input frames, and update HUD from snapshots.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build client project**
|
||||||
|
|
||||||
|
Run: `dotnet build Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj -c Release`
|
||||||
|
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
### Task 4: MiniGame Publish Metadata
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/README.md`
|
||||||
|
- Create: `Client/Assets/MiniGames/Fighter3D/publish.json`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `gameId = "fighter3d"`
|
||||||
|
- Produces: `clientEntryType = "Fighter3D.Client.FighterGameClient"`
|
||||||
|
- Produces: `serverEntryType = "Fighter3D.Server.FighterServerRoom"`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add publish metadata**
|
||||||
|
|
||||||
|
Create `publish.json` matching the existing RPS fields with `playerCount` 2 and `tickRateHz` 20.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build server and client projects**
|
||||||
|
|
||||||
|
Run: `dotnet build Server/games-src/Fighter3D/Fighter3D.Server/Fighter3D.Server.csproj -c Release`
|
||||||
|
|
||||||
|
Run: `dotnet build Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj -c Release`
|
||||||
|
|
||||||
|
Expected: both builds succeed.
|
||||||
|
|
||||||
|
### Task 5: Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No new files.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Verifies all earlier outputs.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused tests**
|
||||||
|
|
||||||
|
Run: `dotnet test Server/Server.Host.Tests/Server.Host.Tests.csproj --filter Fighter3D`
|
||||||
|
|
||||||
|
Expected: all Fighter3D tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full server host tests if focused tests pass**
|
||||||
|
|
||||||
|
Run: `dotnet test Server/Server.Host.Tests/Server.Host.Tests.csproj`
|
||||||
|
|
||||||
|
Expected: pass, or document unrelated existing failures.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check working tree**
|
||||||
|
|
||||||
|
Run: `git status --short`
|
||||||
|
|
||||||
|
Expected: only intended Fighter3D files plus pre-existing unrelated user changes.
|
||||||
Reference in New Issue
Block a user