diff --git a/CDN/minigame/fighter3d/6/pc/code_06f648bb8c10a1bb88489b166e1f22be.unity3d b/CDN/minigame/fighter3d/6/pc/code_06f648bb8c10a1bb88489b166e1f22be.unity3d new file mode 100644 index 00000000..c6af1774 Binary files /dev/null and b/CDN/minigame/fighter3d/6/pc/code_06f648bb8c10a1bb88489b166e1f22be.unity3d differ diff --git a/CDN/minigame/fighter3d/6/pc/files.txt b/CDN/minigame/fighter3d/6/pc/files.txt new file mode 100644 index 00000000..7ac44477 --- /dev/null +++ b/CDN/minigame/fighter3d/6/pc/files.txt @@ -0,0 +1,2 @@ +{'code.unity3d','06f648bb8c10a1bb88489b166e1f22be',13031}, +{'res.unity3d','4238a196bc5f53eb9db13d8064339ace',9111983}, diff --git a/CDN/minigame/fighter3d/6/pc/files.txt.sig b/CDN/minigame/fighter3d/6/pc/files.txt.sig new file mode 100644 index 00000000..e69de29b diff --git a/CDN/minigame/fighter3d/6/pc/game.json b/CDN/minigame/fighter3d/6/pc/game.json new file mode 100644 index 00000000..cc947a08 --- /dev/null +++ b/CDN/minigame/fighter3d/6/pc/game.json @@ -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" + ] +} \ No newline at end of file diff --git a/CDN/minigame/fighter3d/6/pc/res_4238a196bc5f53eb9db13d8064339ace.unity3d b/CDN/minigame/fighter3d/6/pc/res_4238a196bc5f53eb9db13d8064339ace.unity3d new file mode 100644 index 00000000..f2452bbb Binary files /dev/null and b/CDN/minigame/fighter3d/6/pc/res_4238a196bc5f53eb9db13d8064339ace.unity3d differ diff --git a/Client/Assets/MiniGames/Fighter3D.meta b/Client/Assets/MiniGames/Fighter3D.meta new file mode 100644 index 00000000..0bbbbd61 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 85085aa41f000aa4dbdb531fd2f5202b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/README.md b/Client/Assets/MiniGames/Fighter3D/README.md new file mode 100644 index 00000000..7b8947fd --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/README.md @@ -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. diff --git a/Client/Assets/MiniGames/Fighter3D/README.md.meta b/Client/Assets/MiniGames/Fighter3D/README.md.meta new file mode 100644 index 00000000..39154ea9 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 55544ae8081da7b48835dd846c66ff7d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/publish.json b/Client/Assets/MiniGames/Fighter3D/publish.json new file mode 100644 index 00000000..0793c885 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/publish.json @@ -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 +} diff --git a/Client/Assets/MiniGames/Fighter3D/publish.json.meta b/Client/Assets/MiniGames/Fighter3D/publish.json.meta new file mode 100644 index 00000000..2713d988 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/publish.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e0ac72934f4783e4f88a7564bac49bd6 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res.meta b/Client/Assets/MiniGames/Fighter3D/res.meta new file mode 100644 index 00000000..2f9b7015 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6eb52d097b913dd4a9bff20cb4b0247d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config.meta b/Client/Assets/MiniGames/Fighter3D/res/Config.meta new file mode 100644 index 00000000..11384fa2 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8338e8813a2215943a9d02c93539a5a8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior.meta b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior.meta new file mode 100644 index 00000000..e2346991 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f43bad7e65f7b9242a7dfcaa0aefe943 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_buff.json b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_buff.json new file mode 100644 index 00000000..167b6988 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_buff.json @@ -0,0 +1,5 @@ +{ + "type": "fighter3d", + "version": 1, + "buffs": [] +} diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_buff.json.meta b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_buff.json.meta new file mode 100644 index 00000000..c848be7f --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_buff.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: eed808c579abf7c46be3ece4492e802c +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_flyobj.json b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_flyobj.json new file mode 100644 index 00000000..9fc99e55 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_flyobj.json @@ -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": [] + } + ] +} diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_flyobj.json.meta b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_flyobj.json.meta new file mode 100644 index 00000000..8eb12d38 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_flyobj.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1223a987b29ef3b4a9dd4ec31da27697 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_skill.json b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_skill.json new file mode 100644 index 00000000..7441bdbe --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_skill.json @@ -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": [] + } + ] +} diff --git a/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_skill.json.meta b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_skill.json.meta new file mode 100644 index 00000000..adcf084a --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/Config/ActorBehavior/fighter3d_skill.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 24e933ae77efcdc48a59b0db7fcb1cf5 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/UI.meta b/Client/Assets/MiniGames/Fighter3D/res/UI.meta new file mode 100644 index 00000000..98c841e6 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8e30407f2677042458c0ab4703890606 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab.meta b/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab.meta new file mode 100644 index 00000000..e7f36c51 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 64b9b67b84ee6ae4c94dc2c93a97361d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab b/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab new file mode 100644 index 00000000..cad96944 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab @@ -0,0 +1,1300 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &51102214474356088 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1976172068032537797} + - component: {fileID: 4526170080281328642} + - component: {fileID: 7605124530416714575} + - component: {fileID: 4867026694869846497} + m_Layer: 5 + m_Name: Panel_Top + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1976172068032537797 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 51102214474356088} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 2128172862697638688} + m_Father: {fileID: 1508497659315335403} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 1} + m_AnchorMax: {x: 0.5, y: 1} + m_AnchoredPosition: {x: 0, y: -28} + m_SizeDelta: {x: 1120, y: 112} + m_Pivot: {x: 0.5, y: 1} +--- !u!222 &4526170080281328642 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 51102214474356088} + m_CullTransparentMesh: 1 +--- !u!114 &7605124530416714575 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 51102214474356088} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: e1a7d422fae56b1458da5c3511a60e75, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &4867026694869846497 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 51102214474356088} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: cfabb0440166ab443bba8876756fdfa9, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0.023529412, g: 0.0627451, b: 0.12156863, a: 0.4} + m_EffectDistance: {x: 0, y: -6} + m_UseGraphicAlpha: 1 +--- !u!1 &306627881581403455 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1430986329681613343} + - component: {fileID: 4409114434126178955} + - component: {fileID: 7541194499519549264} + - component: {fileID: 171210671049410247} + m_Layer: 5 + m_Name: Group_LeftHud + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1430986329681613343 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 306627881581403455} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 2686838804922902008} + - {fileID: 1781133769719869689} + m_Father: {fileID: 1508497659315335403} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 48, y: -132} + m_SizeDelta: {x: 720, y: 128} + m_Pivot: {x: 0, y: 1} +--- !u!222 &4409114434126178955 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 306627881581403455} + m_CullTransparentMesh: 1 +--- !u!114 &7541194499519549264 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 306627881581403455} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.11372549, g: 0.2, b: 0.3137255, a: 0.8} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 3eabb8bb82892d84096d49bf36765aca, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &171210671049410247 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 306627881581403455} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: cfabb0440166ab443bba8876756fdfa9, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0.023529412, g: 0.0627451, b: 0.12156863, a: 0.36078432} + m_EffectDistance: {x: 0, y: -5} + m_UseGraphicAlpha: 1 +--- !u!1 &1127248454138496787 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 942909062125044626} + - component: {fileID: 9127420069133790440} + - component: {fileID: 4241272191528483596} + m_Layer: 5 + m_Name: Img_RightHpFill + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &942909062125044626 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1127248454138496787} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 5192288202422596650} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &9127420069133790440 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1127248454138496787} + m_CullTransparentMesh: 1 +--- !u!114 &4241272191528483596 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1127248454138496787} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 928780476c664881bad3693b629f4681, type: 3} + m_Type: 3 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 0 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &1418789028211677782 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3246531855878771320} + - component: {fileID: 3669100588611779178} + - component: {fileID: 8021625159022977300} + - component: {fileID: 322376912954136599} + m_Layer: 5 + m_Name: Txt_Right + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &3246531855878771320 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1418789028211677782} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 213227074119471833} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: -16} + m_SizeDelta: {x: -48, y: 52} + m_Pivot: {x: 0.5, y: 1} +--- !u!222 &3669100588611779178 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1418789028211677782} + m_CullTransparentMesh: 1 +--- !u!114 &8021625159022977300 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1418789028211677782} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: P2 + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_sharedMaterial: {fileID: 1803920224224718538, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 0.9647059, g: 0.972549, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: 0 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 28 + m_fontSizeBase: 28 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 1 + m_HorizontalAlignment: 4 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_enableWordWrapping: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 1 + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &322376912954136599 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1418789028211677782} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e19747de3f5aca642ab2be37e372fb86, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0.0627451, g: 0.1254902, b: 0.2, a: 0.8} + m_EffectDistance: {x: 1, y: -1} + m_UseGraphicAlpha: 1 +--- !u!1 &2004090337350179303 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1781133769719869689} + - component: {fileID: 7862510810056589385} + - component: {fileID: 169999316844855536} + m_Layer: 5 + m_Name: Img_LeftHpBg + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1781133769719869689 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2004090337350179303} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 2152295177586974980} + m_Father: {fileID: 1430986329681613343} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 0} + m_AnchoredPosition: {x: 0, y: 24} + m_SizeDelta: {x: -48, y: 40} + m_Pivot: {x: 0.5, y: 0} +--- !u!222 &7862510810056589385 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2004090337350179303} + m_CullTransparentMesh: 1 +--- !u!114 &169999316844855536 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2004090337350179303} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 10df427f6e3148ddb4cfd634292a8aa0, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &2541570642421755854 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1508497659315335403} + - component: {fileID: 9221711933477496907} + - component: {fileID: 7024649310550494584} + - component: {fileID: 4938787491607927716} + - component: {fileID: 1089041580182185178} + m_Layer: 5 + m_Name: UI_Fighter3D + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1508497659315335403 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2541570642421755854} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 0, y: 0, z: 0} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1976172068032537797} + - {fileID: 1430986329681613343} + - {fileID: 213227074119471833} + - {fileID: 5367529178269420865} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0, y: 0} +--- !u!223 &9221711933477496907 +Canvas: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2541570642421755854} + m_Enabled: 1 + serializedVersion: 3 + m_RenderMode: 0 + m_Camera: {fileID: 0} + m_PlaneDistance: 100 + m_PixelPerfect: 0 + m_ReceivesEvents: 1 + m_OverrideSorting: 0 + m_OverridePixelPerfect: 0 + m_SortingBucketNormalizedSize: 0 + m_VertexColorAlwaysGammaSpace: 0 + m_AdditionalShaderChannelsFlag: 0 + m_UpdateRectTransformForStandalone: 0 + m_SortingLayerID: 0 + m_SortingOrder: 25000 + m_TargetDisplay: 0 +--- !u!114 &7024649310550494584 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2541570642421755854} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3} + m_Name: + m_EditorClassIdentifier: + m_UiScaleMode: 1 + m_ReferencePixelsPerUnit: 100 + m_ScaleFactor: 1 + m_ReferenceResolution: {x: 2048, y: 1024} + m_ScreenMatchMode: 0 + m_MatchWidthOrHeight: 0.5 + m_PhysicalUnit: 3 + m_FallbackScreenDPI: 96 + m_DefaultSpriteDPI: 96 + m_DynamicPixelsPerUnit: 1 + m_PresetInfoIsWorld: 0 +--- !u!114 &4938787491607927716 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2541570642421755854} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3} + m_Name: + m_EditorClassIdentifier: + m_IgnoreReversedGraphics: 1 + m_BlockingObjects: 0 + m_BlockingMask: + serializedVersion: 2 + m_Bits: 4294967295 +--- !u!225 &1089041580182185178 +CanvasGroup: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2541570642421755854} + m_Enabled: 1 + m_Alpha: 0.96 + m_Interactable: 0 + m_BlocksRaycasts: 0 + m_IgnoreParentGroups: 0 +--- !u!1 &3272813058521673316 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 213227074119471833} + - component: {fileID: 2165909922774537394} + - component: {fileID: 4964167119808783406} + - component: {fileID: 6726885911057904069} + m_Layer: 5 + m_Name: Group_RightHud + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &213227074119471833 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3272813058521673316} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 3246531855878771320} + - {fileID: 5192288202422596650} + m_Father: {fileID: 1508497659315335403} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 1, y: 1} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: -48, y: -132} + m_SizeDelta: {x: 720, y: 128} + m_Pivot: {x: 1, y: 1} +--- !u!222 &2165909922774537394 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3272813058521673316} + m_CullTransparentMesh: 1 +--- !u!114 &4964167119808783406 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3272813058521673316} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.3137255, g: 0.11372549, b: 0.18431373, a: 0.8} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 3eabb8bb82892d84096d49bf36765aca, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &6726885911057904069 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3272813058521673316} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: cfabb0440166ab443bba8876756fdfa9, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0.023529412, g: 0.0627451, b: 0.12156863, a: 0.36078432} + m_EffectDistance: {x: 0, y: -5} + m_UseGraphicAlpha: 1 +--- !u!1 &3718228895656223548 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2128172862697638688} + - component: {fileID: 1168651089540716416} + - component: {fileID: 4174306334115819624} + - component: {fileID: 4266583595747749213} + m_Layer: 5 + m_Name: Txt_Title + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &2128172862697638688 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3718228895656223548} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1976172068032537797} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0.5} + m_AnchorMax: {x: 0.5, y: 0.5} + m_AnchoredPosition: {x: 0, y: 10} + m_SizeDelta: {x: 760, y: 64} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &1168651089540716416 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3718228895656223548} + m_CullTransparentMesh: 1 +--- !u!114 &4174306334115819624 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3718228895656223548} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Fighter3D + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_sharedMaterial: {fileID: 1803920224224718538, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 0.9647059, g: 0.972549, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: 0 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 44 + m_fontSizeBase: 44 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 1 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_enableWordWrapping: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 1 + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &4266583595747749213 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3718228895656223548} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e19747de3f5aca642ab2be37e372fb86, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0.0627451, g: 0.1254902, b: 0.2, a: 0.8} + m_EffectDistance: {x: 2, y: -2} + m_UseGraphicAlpha: 1 +--- !u!1 &6376664759209795678 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2152295177586974980} + - component: {fileID: 3222483699598928704} + - component: {fileID: 2291354092821665556} + m_Layer: 5 + m_Name: Img_LeftHpFill + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &2152295177586974980 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6376664759209795678} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1781133769719869689} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &3222483699598928704 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6376664759209795678} + m_CullTransparentMesh: 1 +--- !u!114 &2291354092821665556 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6376664759209795678} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: c1157c30b301422ebdbe2dc08874f6f3, type: 3} + m_Type: 3 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 0 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &6885488326912630723 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5192288202422596650} + - component: {fileID: 1918663756241429973} + - component: {fileID: 8045684421898637921} + m_Layer: 5 + m_Name: Img_RightHpBg + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &5192288202422596650 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6885488326912630723} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 942909062125044626} + m_Father: {fileID: 213227074119471833} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 0} + m_AnchoredPosition: {x: 0, y: 24} + m_SizeDelta: {x: -48, y: 40} + m_Pivot: {x: 0.5, y: 0} +--- !u!222 &1918663756241429973 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6885488326912630723} + m_CullTransparentMesh: 1 +--- !u!114 &8045684421898637921 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6885488326912630723} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 10df427f6e3148ddb4cfd634292a8aa0, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &7287085677115541360 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5367529178269420865} + - component: {fileID: 785636196825678174} + - component: {fileID: 4826849225309246928} + - component: {fileID: 4730531079179169960} + m_Layer: 5 + m_Name: Txt_Help + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &5367529178269420865 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7287085677115541360} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1508497659315335403} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0} + m_AnchorMax: {x: 0.5, y: 0} + m_AnchoredPosition: {x: 0, y: 36} + m_SizeDelta: {x: 920, y: 52} + m_Pivot: {x: 0.5, y: 0} +--- !u!222 &785636196825678174 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7287085677115541360} + m_CullTransparentMesh: 1 +--- !u!114 &4826849225309246928 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7287085677115541360} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: A/D move S guard J light K heavy + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_sharedMaterial: {fileID: 1803920224224718538, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 0.9647059, g: 0.972549, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: 0 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 24 + m_fontSizeBase: 24 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 1 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_enableWordWrapping: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 1 + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &4730531079179169960 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7287085677115541360} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e19747de3f5aca642ab2be37e372fb86, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0.0627451, g: 0.1254902, b: 0.2, a: 0.8} + m_EffectDistance: {x: 1, y: -1} + m_UseGraphicAlpha: 1 +--- !u!1 &7528093605457571823 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2686838804922902008} + - component: {fileID: 8771832383466948764} + - component: {fileID: 3818426171443454173} + - component: {fileID: 1879605400686955729} + m_Layer: 5 + m_Name: Txt_Left + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &2686838804922902008 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7528093605457571823} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1430986329681613343} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: -16} + m_SizeDelta: {x: -48, y: 52} + m_Pivot: {x: 0.5, y: 1} +--- !u!222 &8771832383466948764 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7528093605457571823} + m_CullTransparentMesh: 1 +--- !u!114 &3818426171443454173 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7528093605457571823} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: P1 + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_sharedMaterial: {fileID: 1803920224224718538, guid: 023543f7caff42979bf03529f9868d06, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 0.9647059, g: 0.972549, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: 0 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 28 + m_fontSizeBase: 28 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 1 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_enableWordWrapping: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 1 + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &1879605400686955729 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7528093605457571823} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e19747de3f5aca642ab2be37e372fb86, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0.0627451, g: 0.1254902, b: 0.2, a: 0.8} + m_EffectDistance: {x: 1, y: -1} + m_UseGraphicAlpha: 1 diff --git a/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab.meta b/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab.meta new file mode 100644 index 00000000..d416d15b --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/res/UI/Prefab/UI_Fighter3D.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cff69aff2bb11fc4f8e323dac4b4231f +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs b/Client/Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs new file mode 100644 index 00000000..85bd621a --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/scripts~/Client/FighterGameClient.cs @@ -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("Img_LeftHpFill"); + _rightHp = Find("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.renderMode = RenderMode.ScreenSpaceOverlay; + root.AddComponent().referenceResolution = new Vector2(2048, 1024); + root.AddComponent(); + + 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(string name) where T : Component + { + Transform child = FindChild(_root.transform, name); + return child == null ? null : child.GetComponent(); + } + + private HudText FindText(string name) + { + Transform child = FindChild(_root.transform, name); + return child == null ? HudText.Empty : new HudText(child.GetComponent(), child.GetComponent()); + } + + 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(); + rect.sizeDelta = size; + rect.anchoredPosition = anchoredPosition; + + var text = go.AddComponent(); + text.font = Resources.GetBuiltinResource("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(); + bgRect.sizeDelta = new Vector2(700, 30); + bgRect.anchoredPosition = anchoredPosition; + var bgImage = bg.AddComponent(); + 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(); + fillRect.anchorMin = new Vector2(0, 0); + fillRect.anchorMax = new Vector2(1, 1); + fillRect.offsetMin = Vector2.zero; + fillRect.offsetMax = Vector2.zero; + var fillImage = fill.AddComponent(); + 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; + } + } + } +} diff --git a/Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterLogic.cs b/Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterLogic.cs new file mode 100644 index 00000000..baafcf3c --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterLogic.cs @@ -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 + { + 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 Step(FighterState state, FighterInput input, IRandom random) + { + var events = new List(); + 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(state, events); + } + + if (state.Phase == FighterPhase.Finished) + return new StepResult(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(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 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 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 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 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(); + } + } +} diff --git a/Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterTypes.cs b/Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterTypes.cs new file mode 100644 index 00000000..78996ab5 --- /dev/null +++ b/Client/Assets/MiniGames/Fighter3D/scripts~/Core/FighterTypes.cs @@ -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; + } +} diff --git a/Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs b/Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs index 29dcfbd7..8deae97b 100644 --- a/Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs +++ b/Client/Assets/Script/Editor/JsonUIPrefabBuilder.cs @@ -527,7 +527,23 @@ namespace XGame.EditorTools string spriteName = (string)comp["sprite"]; Sprite sprite = ResolveSprite(spriteName, fileName); img.sprite = sprite; - img.type = string.Equals((string)comp["imageType"], "Sliced", StringComparison.OrdinalIgnoreCase) ? Image.Type.Sliced : Image.Type.Simple; + string imageType = (string)comp["imageType"]; + if (string.Equals(imageType, "Sliced", StringComparison.OrdinalIgnoreCase)) + { + img.type = Image.Type.Sliced; + } + else if (string.Equals(imageType, "Filled", StringComparison.OrdinalIgnoreCase)) + { + img.type = Image.Type.Filled; + img.fillMethod = ParseFillMethod((string)comp["fillMethod"]); + img.fillOrigin = 0; + img.fillClockwise = true; + img.fillAmount = comp["fillAmount"] != null ? Mathf.Clamp01((float)comp["fillAmount"]) : 1f; + } + else + { + img.type = Image.Type.Simple; + } if (comp["preserveAspect"] != null) img.preserveAspect = (bool)comp["preserveAspect"]; img.raycastTarget = comp["raycastTarget"] == null || (bool)comp["raycastTarget"]; img.color = ParseColor((string)comp["color"], Color.white); @@ -932,6 +948,20 @@ namespace XGame.EditorTools } } + private static Image.FillMethod ParseFillMethod(string method) + { + switch (method) + { + case "Vertical": return Image.FillMethod.Vertical; + case "Radial90": return Image.FillMethod.Radial90; + case "Radial180": return Image.FillMethod.Radial180; + case "Radial360": return Image.FillMethod.Radial360; + case "Horizontal": + default: + return Image.FillMethod.Horizontal; + } + } + private static TMP_InputField.ContentType ParseInputContentType(string contentType) { switch (contentType) diff --git a/Client/Assets/Script/Tests/EditMode/ActorBehaviorAnimatorControllerPathTests.cs b/Client/Assets/Script/Tests/EditMode/ActorBehaviorAnimatorControllerPathTests.cs index 4ccf952f..1bcdb2c0 100644 --- a/Client/Assets/Script/Tests/EditMode/ActorBehaviorAnimatorControllerPathTests.cs +++ b/Client/Assets/Script/Tests/EditMode/ActorBehaviorAnimatorControllerPathTests.cs @@ -11,6 +11,14 @@ public sealed class ActorBehaviorAnimatorControllerPathTests Is.EqualTo("Assets/Game/Art/Actor/Controller/fighter.controller")); } + [Test] + public void GetControllerPath_Fighter3D_ReturnsGeneratedFighterController() + { + Assert.That( + ActorBehaviorAnimatorControllerPaths.GetControllerPath("fighter3d"), + Is.EqualTo("Assets/Game/Art/Actor/Controller/fighter.controller")); + } + [Test] public void GetControllerPath_UnknownType_ReturnsNull() { diff --git a/Client/Assets/Script/Tests/EditMode/GameLobbyControllerLayoutTests.cs b/Client/Assets/Script/Tests/EditMode/GameLobbyControllerLayoutTests.cs new file mode 100644 index 00000000..424432eb --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/GameLobbyControllerLayoutTests.cs @@ -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(); + 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(); + 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(Fighter3DPrefabPath); + + Assert.That(prefab, Is.Not.Null); + Assert.That(FindComponent(prefab.transform, "Txt_Title"), Is.Not.Null); + Assert.That(FindComponent(prefab.transform, "Txt_Left"), Is.Not.Null); + Assert.That(FindComponent(prefab.transform, "Txt_Right"), Is.Not.Null); + Image leftHp = FindComponent(prefab.transform, "Img_LeftHpFill"); + Image rightHp = FindComponent(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(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(); + 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(Transform root, string name) where T : Component + { + Transform child = root.FindTransform(name); + return child == null ? null : child.GetComponent(); + } + + private static string ReadAssetText(string assetPath) + { + TextAsset asset = AssetDatabase.LoadAssetAtPath(assetPath); + Assert.That(asset, Is.Not.Null, assetPath); + return asset.text; + } +} diff --git a/Client/Assets/Script/Tests/EditMode/GameLobbyControllerLayoutTests.cs.meta b/Client/Assets/Script/Tests/EditMode/GameLobbyControllerLayoutTests.cs.meta new file mode 100644 index 00000000..92e92cd0 --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/GameLobbyControllerLayoutTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e7a70267c2bce264fb04b0eb9dd0db99 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorAnimatorControllerLoader.cs b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorAnimatorControllerLoader.cs index 12e96695..e0882dc6 100644 --- a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorAnimatorControllerLoader.cs +++ b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorAnimatorControllerLoader.cs @@ -14,6 +14,7 @@ namespace XGame.ActorBehavior public static string GetControllerPath(string catalogType) { return string.Equals(catalogType, LobbyActorBehaviorRuntime.DefaultCatalogType, StringComparison.OrdinalIgnoreCase) + || string.Equals(catalogType, "fighter3d", StringComparison.OrdinalIgnoreCase) ? "Assets/Game/Art/Actor/Controller/fighter.controller" : null; } diff --git a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorConfigLoader.cs b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorConfigLoader.cs index f3121057..9ee7ec38 100644 --- a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorConfigLoader.cs +++ b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorConfigLoader.cs @@ -16,18 +16,23 @@ namespace XGame.ActorBehavior public const string ConfigRoot = "Assets/Game/Config/ActorBehavior"; public static IEnumerator LoadCatalog(string type, Action onLoaded) + { + yield return LoadCatalogFromRoot(type, ConfigRoot, onLoaded); + } + + public static IEnumerator LoadCatalogFromRoot(string type, string configRoot, Action onLoaded) { string skillJson = null; string flyObjectJson = null; string buffJson = null; - yield return LoadText(GetSkillPath(type), text => skillJson = text); - yield return LoadText(GetFlyObjectPath(type), text => flyObjectJson = text); - yield return LoadText(GetBuffPath(type), text => buffJson = text); + yield return LoadText(GetSkillPath(type, configRoot), text => skillJson = text); + yield return LoadText(GetFlyObjectPath(type, configRoot), text => flyObjectJson = text); + yield return LoadText(GetBuffPath(type, configRoot), text => buffJson = text); if (string.IsNullOrEmpty(skillJson) || string.IsNullOrEmpty(flyObjectJson) || string.IsNullOrEmpty(buffJson)) { - Debug.LogError("[ActorBehaviorConfigLoader] missing behavior config for type=" + type); + Debug.LogError("[ActorBehaviorConfigLoader] missing behavior config for type=" + type + ", root=" + configRoot); onLoaded?.Invoke(null); yield break; } @@ -48,16 +53,38 @@ namespace XGame.ActorBehavior return ConfigRoot + "/" + type + "_skill.json"; } + public static string GetSkillPath(string type, string configRoot) + { + return NormalizeRoot(configRoot) + "/" + type + "_skill.json"; + } + public static string GetFlyObjectPath(string type) { return ConfigRoot + "/" + type + "_flyobj.json"; } + public static string GetFlyObjectPath(string type, string configRoot) + { + return NormalizeRoot(configRoot) + "/" + type + "_flyobj.json"; + } + public static string GetBuffPath(string type) { return ConfigRoot + "/" + type + "_buff.json"; } + public static string GetBuffPath(string type, string configRoot) + { + return NormalizeRoot(configRoot) + "/" + type + "_buff.json"; + } + + private static string NormalizeRoot(string configRoot) + { + return string.IsNullOrEmpty(configRoot) + ? ConfigRoot + : configRoot.TrimEnd('/', '\\'); + } + private static IEnumerator LoadText(string assetPath, Action onLoaded) { #if UNITY_EDITOR diff --git a/Client/Assets/Script/xmain/Client/CSharpClientApp.cs b/Client/Assets/Script/xmain/Client/CSharpClientApp.cs index cd7cecc6..0e77fa48 100644 --- a/Client/Assets/Script/xmain/Client/CSharpClientApp.cs +++ b/Client/Assets/Script/xmain/Client/CSharpClientApp.cs @@ -26,6 +26,7 @@ namespace XGame private const string LoginPlayerIdKey = "LoginPlayerId"; private const string LocalClientIdKey = "LocalClientId"; private const double TokenValidSeconds = 7 * 24 * 60 * 60; + private const float LobbyGameListRefreshIntervalSeconds = 3f; public static CSharpClientApp Instance { get; private set; } @@ -55,6 +56,7 @@ namespace XGame private string matchMessage = string.Empty; private string pendingGameId = string.Empty; private int pendingGameVersion; + private float nextGameListRefreshTime; private float nextLobbyMoveFrameLogTime; private int m_pid;//当前已鉴权的玩家 id(来自登录/注册返回的 token) @@ -113,6 +115,11 @@ namespace XGame if (lobbyActive) { lobbyNet?.Pump(); + float now = Time.unscaledTime; + if (ShouldPollGameList(lobbyActive, lobbyNet != null && lobbyNet.IsConnected, matchRequestActive, now, nextGameListRefreshTime)) + { + RequestGameList(); + } } } @@ -435,9 +442,13 @@ namespace XGame RefreshLobby(); return; } + nextGameListRefreshTime = Time.unscaledTime + LobbyGameListRefreshIntervalSeconds; lobbyNet.SendFramework(FrameworkOpcode.GameListRequest, Array.Empty()); } + private static bool ShouldPollGameList(bool isLobbyActive, bool hasConnectedLobbyNet, bool isMatchRequestActive, float now, float nextPollTime) + => isLobbyActive && hasConnectedLobbyNet && !isMatchRequestActive && now >= nextPollTime; + private void RefreshLobby() { if (lobbyController == null) diff --git a/Client/Assets/Script/xmain/MiniGame/MiniGameActorPresentation.cs b/Client/Assets/Script/xmain/MiniGame/MiniGameActorPresentation.cs new file mode 100644 index 00000000..210e96d4 --- /dev/null +++ b/Client/Assets/Script/xmain/MiniGame/MiniGameActorPresentation.cs @@ -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 _avatars = new Dictionary(); + private readonly Dictionary _prefabCache = new Dictionary(); + private readonly HashSet _prefabLoading = new HashSet(); + 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(); + _behaviorPresentation = _root.AddComponent(); + _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() : 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(); + 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 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(CharacterConfigResourcePath); + if (asset == null) + { + Debug.LogError("[MiniGameActorPresentation] missing character config resource: " + CharacterConfigResourcePath); + return false; + } + + _characterConfig = JsonUtility.FromJson(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.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.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(); + 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(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); + } + } + } +} diff --git a/Client/Assets/Script/xmain/MiniGame/MiniGameActorPresentation.cs.meta b/Client/Assets/Script/xmain/MiniGame/MiniGameActorPresentation.cs.meta new file mode 100644 index 00000000..bc7bb8b0 --- /dev/null +++ b/Client/Assets/Script/xmain/MiniGame/MiniGameActorPresentation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 61b12bbed7cddf446a576ec50f6a915c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/UI/GameLobbyController.cs b/Client/Assets/Script/xmain/UI/GameLobbyController.cs index 9e3d1f16..ed5d5b5d 100644 --- a/Client/Assets/Script/xmain/UI/GameLobbyController.cs +++ b/Client/Assets/Script/xmain/UI/GameLobbyController.cs @@ -30,7 +30,7 @@ namespace XGame private const float LobbyPanelWidth = 520f; private const float LobbyPanelHeight = 296f; private const float LobbyPanelLeft = 24f; - private const float LobbyPanelTop = -24f; + private const float LobbyPanelMiddleY = 0f; private const float CollapsedPanelWidth = 56f; private void Awake() @@ -208,10 +208,10 @@ namespace XGame buttonGo.transform.SetParent(parent, false); RectTransform rt = buttonGo.GetComponent(); - rt.anchorMin = new Vector2(0f, 1f); - rt.anchorMax = new Vector2(0f, 1f); - rt.pivot = new Vector2(0f, 1f); - rt.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelTop); + rt.anchorMin = new Vector2(0f, 0.5f); + rt.anchorMax = new Vector2(0f, 0.5f); + rt.pivot = new Vector2(0f, 0.5f); + rt.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelMiddleY); rt.sizeDelta = new Vector2(48f, 72f); Image img = buttonGo.GetComponent(); @@ -254,18 +254,18 @@ namespace XGame if (panelLobby != null) { - panelLobby.anchorMin = new Vector2(0f, 1f); - panelLobby.anchorMax = new Vector2(0f, 1f); - panelLobby.pivot = new Vector2(0f, 1f); - panelLobby.anchoredPosition = new Vector2(LobbyPanelLeft, LobbyPanelTop); + panelLobby.anchorMin = new Vector2(0f, 0.5f); + panelLobby.anchorMax = new Vector2(0f, 0.5f); + panelLobby.pivot = new Vector2(0f, 0.5f); + panelLobby.anchoredPosition = new Vector2(LobbyPanelLeft, LobbyPanelMiddleY); panelLobby.sizeDelta = new Vector2(LobbyPanelWidth, LobbyPanelHeight); } if (collapseButtonRect != null) { - collapseButtonRect.anchorMin = new Vector2(0f, 1f); - collapseButtonRect.anchorMax = new Vector2(0f, 1f); - collapseButtonRect.pivot = new Vector2(0f, 1f); - collapseButtonRect.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelTop); + collapseButtonRect.anchorMin = new Vector2(0f, 0.5f); + collapseButtonRect.anchorMax = new Vector2(0f, 0.5f); + collapseButtonRect.pivot = new Vector2(0f, 0.5f); + collapseButtonRect.anchoredPosition = new Vector2(LobbyPanelLeft + LobbyPanelWidth, LobbyPanelMiddleY); collapseButtonRect.sizeDelta = new Vector2(48f, 72f); } diff --git a/Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj b/Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj new file mode 100644 index 00000000..0d416fe0 --- /dev/null +++ b/Client/HotUpdateGames/Fighter3D.Client/Fighter3D.Client.csproj @@ -0,0 +1,63 @@ + + + + netstandard2.1 + 9.0 + disable + disable + Fighter3D.Client + Fighter3D.Client + false + D:\Program Files\Unity 2022.3.62f3\Editor + $(MSBuildProjectDirectory)\..\..\Library\ScriptAssemblies + + + + + + + + + false + + + false + + + + + + $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.dll + false + + + $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.CoreModule.dll + false + + + $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.InputLegacyModule.dll + false + + + $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.TextRenderingModule.dll + false + + + $(UnityEditorPath)\Data\Managed\UnityEngine\UnityEngine.UIModule.dll + false + + + $(ScriptAssemblies)\UnityEngine.UI.dll + false + + + $(ScriptAssemblies)\Unity.TextMeshPro.dll + false + + + $(ScriptAssemblies)\XWorld.Link.dll + false + + + + diff --git a/Doc/UIPrefabCreater/UI_Fighter3D.json b/Doc/UIPrefabCreater/UI_Fighter3D.json new file mode 100644 index 00000000..69dd62a9 --- /dev/null +++ b/Doc/UIPrefabCreater/UI_Fighter3D.json @@ -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": [] +} diff --git a/Doc/minigame/横版3D格斗小游戏_开发设计文档.docx b/Doc/minigame/横版3D格斗小游戏_开发设计文档.docx new file mode 100644 index 00000000..6f5abc57 Binary files /dev/null and b/Doc/minigame/横版3D格斗小游戏_开发设计文档.docx differ diff --git a/Doc/minigame/横版3D格斗小游戏_开发设计文档.md b/Doc/minigame/横版3D格斗小游戏_开发设计文档.md new file mode 100644 index 00000000..6ceb81fe --- /dev/null +++ b/Doc/minigame/横版3D格斗小游戏_开发设计文档.md @@ -0,0 +1,1276 @@ +# 横版 3D 格斗小游戏开发设计文档 + +**2.5D · 1v1 · 帧数据驱动 · 单机原型基线** + +| **版本** | v1.0 | +|--------------|------------------------------------| +| **日期** | 2026-08-10 | +| **技术基线** | 引擎无关;示例采用 Unity / C# 命名 | + +> 目标:用最小但正确的战斗架构,实现行走、跳跃、格挡、拳脚、必杀技、超必杀技、命中反馈与完整回合流程;同时为后续角色扩展、训练模式和回滚联机保留清晰边界。 + +| **核心战斗** | **数据驱动** | **可验证** | +| --- | --- | --- | +| 输入缓冲 · 状态机 · 命中结算 | 招式帧表 · 判定轨迹 · 资源规则 | 帧步进 · Hitbox 调试 · 验收用例 | + +**DOCUMENT CONTROL** + +# 文档控制与阅读说明 + +本文档是面向小型团队或个人开发者的可执行设计基线。它同时覆盖玩法规则、核心战斗技术方案、数据结构、调试工具、开发计划与验收标准。所有数值均为首轮灰盒原型建议值,应通过手感测试迭代。 + +| **字段** | **内容** | +|------------------|----------------------------------------------------| +| **项目类型** | 横版 3D(2.5D)1v1 对战格斗小游戏 | +| **首发平台** | PC;键盘与手柄;本地双人或玩家对 AI | +| **画面实现** | 3D 角色与场景,战斗逻辑约束在水平轴与垂直轴 | +| **战斗基线** | 固定 60 Tick;帧数据驱动;运动学角色控制器 | +| **参考代码风格** | Unity / C# 命名;可映射至 Unreal、Godot 或自研引擎 | +| **目标读者** | 程序、策划、动画、特效、测试与制作人 | + +> **关键边界** +> +> MVP 不做联网、投技、复杂防御资源、角色养成与剧情。先确保“输入可靠、状态可控、命中可解释、帧数据可调”,再增加内容。 + +## 术语约定 + +| **术语** | **定义** | +|--------------------------------|-------------------------------------------------------------------------------| +| **战斗 Tick** | 战斗逻辑更新单位;固定为每秒 60 次,与渲染帧率解耦。 | +| **起手 / 持续 / 收招** | Startup / Active / Recovery;攻击从输入到判定生效,再到恢复可行动的三个阶段。 | +| **HitStop** | 命中瞬间双方短暂停顿,用于强化重量感;不等同于受击硬直。 | +| **HitStun / BlockStun** | 命中硬直 / 格挡硬直;期间角色不能自由行动,但可受特定规则影响。 | +| **HitBox / HurtBox / PushBox** | 攻击判定 / 可受击判定 / 角色实体推挤判定。 | +| **取消窗口** | 允许当前动作提前转入指定动作的帧区间。 | +| **指令宽容** | 方向输入允许的总时间、相邻输入最大间隔、斜方向替代等容错规则。 | + +## 目录 + +| **01 项目定位与设计目标** | **11 动画、特效、音效与镜头** | +|-----------------------------|-------------------------------| +| 02 MVP 范围与内容规格 | 12 技术架构与更新顺序 | +| 03 比赛规则与战斗空间 | 13 数据配置与资源规范 | +| 04 输入、缓冲与指令识别 | 14 UI、回合流程与设置 | +| 05 角色状态机与转移优先级 | 15 AI 与训练木桩 | +| **06 行走、跳跃与角色运动** | **16 性能、回放与联机预留** | +| 07 拳脚攻击与招式时间轴 | 17 调试工具与可观测性 | +| 08 格挡、防御与受击规则 | 18 开发里程碑与任务拆分 | +| 09 HitBox 与命中结算管线 | 19 测试方案与验收标准 | +| 10 必杀技、超必杀技与能量 | 20 风险、原创边界与附录 | + +**PRODUCT VISION** + +# 01 项目定位与设计目标 + +本项目是一款使用 3D 角色和场景、但将对战规则限制在二维战斗平面的 1v1 格斗小游戏。玩家通过走位、跳跃、格挡、拳脚、必杀技与超必杀技争夺回合胜利。重点不是堆叠招式数量,而是建立清晰、稳定、可调试的战斗内核。 + +## 1.1 玩家体验目标 + +- 输入响应明确:按键、方向与指令不会因为渲染帧率或动画事件而随机失效。 + +- 攻防结果可理解:为什么命中、为什么被格挡、为什么发生交易命中,都能通过调试数据解释。 + +- 打击有重量:命中停顿、受击动作、音效、火花、震屏与击退在同一事件上同步。 + +- 学习成本可控:MVP 只提供少量高辨识度招式,先形成“近身、对空、飞行道具、爆发技”的完整循环。 + +- 内容可扩展:新增角色主要通过配置数据、动画和判定轨迹完成,避免复制并修改大量角色逻辑。 + +## 1.2 核心循环 + +| **阶段** | **玩家行为** | **系统反馈** | +|-----------------|------------------------------|--------------------------------| +| **中立博弈** | 前后移动、试探、跳跃、预判 | 距离、站位、能量与时间形成压力 | +| **进攻确认** | 拳脚命中或迫使对手格挡 | HitStop、硬直、推远、命中特效 | +| **连段 / 取消** | 用轻攻击确认,再取消到必杀技 | 连击数、伤害缩放、资源增长 | +| **防守反击** | 格挡、后退、等待收招空隙 | 格挡硬直、削血、反击提示 | +| **资源爆发** | 积满能量后使用超必杀技 | 屏幕冻结、镜头强化、多段命中 | +| **回合结算** | KO 或时间结束 | 慢镜、胜负 UI、重开或下一回合 | + +## 1.3 设计原则 + +> **1.** 战斗逻辑先于表现:战斗结果由数据与状态决定,动画、特效和镜头只消费结果。 +> +> **2.** 整数帧优先:所有招式时序、无敌、取消与硬直均用战斗 Tick 表达。 +> +> **3.** 单一结算入口:所有近战、飞行道具和超必杀命中都进入同一 HitResolver。 +> +> **4.** 配置优先于分支:角色差异放入 CharacterData、MoveData 与 HitboxTrack,而不是散落在代码中的 if/else。 +> +> **5.** 调试能力属于产品功能:从第一周就提供帧步进、输入历史和判定框显示。 + +> **成功标准** +> +> 两个相同灰盒角色也能玩出完整回合:走位可控、跳跃轨迹稳定、拳脚可命中/格挡、必杀与超杀可识别、KO 能结算;全流程无随机漏判和状态卡死。 + +**SCOPE** + +# 02 MVP 范围与内容规格 + +## 2.1 首个可玩版本 + +| **优先级** | **功能** | **MVP 规格** | +|--------------|----------|----------------------------------------------------------| +| **P0 必须** | 角色移动 | 前走、后走、转身、蹲下、前跳、后跳、垂直跳、落地硬直 | +| **P0 必须** | 基础攻击 | 站立轻拳/重拳/轻脚/重脚,蹲轻脚,跳攻击;至少 6 个普通技 | +| **P0 必须** | 防御 | 按住背向方向格挡;站防、蹲防;格挡硬直、推远与削血规则 | +| **P0 必须** | 命中 | HitBox/HurtBox、HitStop、HitStun、击退、浮空、击倒、KO | +| **P0 必须** | 特殊招式 | 每角色 3 个必杀技:飞行道具、对空技、突进技 | +| **P0 必须** | 爆发资源 | 0–100 超必杀能量;每角色 1 个超必杀技 | +| **P0 必须** | 比赛流程 | 倒计时、生命、能量、局数、KO、时间结束、重开 | +| **P1 建议** | 对战对象 | 本地双人 + 简单 AI;训练木桩 | +| **P1 建议** | 调试 | 输入历史、帧计数、判定框、命中日志、帧步进 | +| **暂不实现** | 复杂系统 | 投技、拆投、空防、弹反、防御槽、角色养成、联网匹配 | + +## 2.2 内容量建议 + +| **内容项** | **原型下限** | **首个完整 Demo** | +|--------------|------------------|---------------------------| +| **角色** | 1 个角色镜像对战 | 2 个原创角色 | +| **场景** | 1 个灰盒场景 | 1 个完整场景 + 1 个训练场 | +| **普通技** | 每角色 6 个 | 每角色 10–12 个 | +| **必杀技** | 每角色 2 个 | 每角色 3 个 | +| **超必杀技** | 每角色 1 个 | 每角色 1 个 | +| **AI 难度** | 1 档 | 3 档 | +| **回合规则** | 一局定胜负 | 三局两胜、99 秒 | + +> **范围控制建议** +> +> 先用胶囊体、基础动作和纯色特效完成“第一可玩”;在手感通过前,不投入角色建模、复杂镜头和大量动作。任何新增功能都必须说明它改善了哪一种攻防决策。 + +## 2.3 非功能目标 + +- 标准 PC 在 1080p 下稳定 60 FPS;战斗 Tick 永远保持 60 Hz。 + +- 单次对战可从输入流重放,重放结果在同版本、同平台上保持一致。 + +- 角色招式数据修改后无需改核心代码即可生效。 + +- 战斗关键路径不产生持续 GC;飞行道具、命中特效和飘字使用对象池。 + +- 控制支持手柄与键盘重绑定,并提供震屏、闪光强度与输入显示开关。 + +**RULES & SPACE** + +# 03 比赛规则与战斗空间 + +## 3.1 默认比赛规则 + +| **参数** | **默认值** | **说明** | +|----------------|------------------|------------------------------------| +| **局制** | 三局两胜 | 原型阶段可切换为一局定胜负 | +| **回合时间** | 99 秒 | 使用战斗时间;暂停与回合演出不计时 | +| **最大生命** | 1000 | 便于用整数表达伤害与缩放 | +| **超必杀能量** | 0–100 | 命中、格挡与受击均可按招式配置增长 | +| **时间结束** | 生命百分比高者胜 | 相同则判平局并重开该回合 | +| **KO 判定** | 生命值 ≤ 0 | 本 Tick 结算完成后进入 KO 状态 | + +## 3.2 坐标系与战斗平面 + +建议将 X 轴定义为左右移动,Y 轴定义为高度,Z 轴为场景深度。角色逻辑位置始终投影到固定的战斗平面 Z = 0;3D 模型可在表现层有轻微深度偏移,但 PushBox、HurtBox 与 HitBox 均在同一逻辑平面计算。 + +- 舞台边界:X ∈ \[−StageHalfWidth, +StageHalfWidth\],角色中心不可越界。 + +- PushBox:两个角色不可穿透;先求期望位移,再按质量和边界分摊重叠量。 + +- 面对方向:每个 Tick 末根据双方 X 位置决定;攻击不可转身的帧区间保持原朝向。 + +- 交叉攻击:双方交换位置后,下一 Tick 更新面对方向;当前 Tick 的方向输入按 Tick 开始时朝向解释。 + +- 镜头:始终看向两人中点,并用距离和舞台边界控制缩放与平移。 + +## 3.3 战斗时间模型 + +> **固定战斗 Tick** +> +> 渲染可以是 60、120 或更高 FPS,但输入采样、状态推进、移动、碰撞与命中结算都以固定 1/60 秒为单位。动画播放速度由当前招式帧映射,不反过来驱动战斗结果。 + +| **时间域** | **是否受 HitStop 影响** | **典型内容** | +|----------------------|-------------------------|---------------------------------------| +| **BattleTime** | 是 | 招式帧、移动、飞行道具、受击/格挡硬直 | +| **PresentationTime** | 可选 | 粒子、镜头震动、音效尾音、UI 动画 | +| **RealTime** | 否 | 暂停菜单、设备重连、加载与调试界面 | + +**INPUT** + +# 04 输入、缓冲与指令识别 + +## 4.1 默认操作映射 + +| **动作** | **键盘示例** | **手柄示例** | **规则** | +|-------------|---------------|-----------------|------------------------------------| +| **方向** | WASD / 方向键 | 十字键 / 左摇杆 | 8 方向归一化;按角色朝向转换为前后 | +| **轻拳 LP** | J | X / □ | 快速、短距离、低伤害 | +| **重拳 HP** | K | Y / △ | 较慢、高伤害、可对空 | +| **轻脚 LK** | U | A / × | 中短距离、可做低段 | +| **重脚 HK** | I | B / ○ | 长距离、较大收招 | +| **暂停** | Esc | Menu / Options | 仅比赛允许时打开 | + +*格挡默认不占独立按键:按住“远离对手”的方向进入格挡意图。可在辅助模式中增加 Guard 键。* + +## 4.2 输入帧结构 + +每个渲染帧读取设备输入,将结果写入 InputBuffer;每个战斗 Tick 消费一个归一化输入快照。缓冲区建议保存最近 30–45 Tick,用于指令识别、输入显示、回放与问题复现。 + +| **字段** | **类型** | **用途** | +|--------------------|--------------|------------------------------------------| +| **Tick** | int | 输入发生的战斗 Tick | +| **Direction** | enum 1–9 | 数字小键盘方向;5 为中立 | +| **PressedMask** | bitmask | 本 Tick 新按下的攻击键 | +| **HeldMask** | bitmask | 本 Tick 持续按住的攻击键 | +| **ReleasedMask** | bitmask | 本 Tick 释放的攻击键;可用于负边输入扩展 | +| **FacingSnapshot** | Left / Right | 用于将世界方向转换为相对方向 | +| **ConsumedFlags** | bitmask | 避免同一次按键被多个动作重复消费 | + +## 4.3 指令识别规则 + +- 方向按“相对朝向”解释:6 始终代表前,4 始终代表后。 + +- 从攻击键按下 Tick 向前回溯方向序列,而不是从最早输入向后搜索。 + +- 每条指令配置总窗口、相邻步骤最大间隔、是否允许斜方向替代、是否允许跳过中立。 + +- 同 Tick 多条指令成立时,按“超必杀 \> 必杀技 \> 特殊普通技 \> 普通技”排序。 + +- 动作进入后消费相关按键与方向片段,避免一个输入同时触发两个动作。 + +- 推荐首版宽容:必杀总窗口 12–16 Tick,超必杀 20–24 Tick,按键缓冲 4–6 Tick。 + +| **招式类型** | **输入示例** | **匹配序列** | **宽容建议** | +|--------------|-----------------|--------------|-------------------------------------| +| **飞行道具** | ↓ ↘ → + P | 2, 3, 6 + P | 允许 2,6 或 2,3,6;总窗口 14 Tick | +| **对空技** | → ↓ ↘ + P | 6, 2, 3 + P | 允许首个 6 来自前走;总窗口 12 Tick | +| **突进技** | ↓ ↙ ← + K | 2, 1, 4 + K | 斜方向必须出现;总窗口 14 Tick | +| **超必杀** | ↓ ↘ → ↓ ↘ → + P | 236236 + P | 总窗口 24 Tick;能量必须为 100 | + +**指令识别伪代码(方向相对角色朝向)** + +```csharp +bool TryMatch(Command cmd, InputBuffer buffer, int nowTick, Facing facing) { + int cursor = nowTick; + for (int i = cmd.steps.Count - 1; i >= 0; --i) { + cursor = buffer.FindPreviousMatch( + cmd.steps[i], cursor, cmd.maxGapTicks, facing); + if (cursor < 0) return false; + } + return nowTick - cursor <= cmd.totalWindowTicks + && buffer.HasButtonPress(cmd.buttonMask, nowTick, cmd.buttonBufferTicks); +} +``` + +> **输入可靠性验收** +> +> 在固定录制的 1000 组输入序列中,识别结果必须完全一致;切换渲染帧率、开启慢动作或发生 HitStop 时,不得改变指令成立结果。 + +**STATE MACHINE** + +# 05 角色状态机与转移优先级 + +角色使用分层状态机:顶层区分 Ground、Air、Attack、Reaction、Down 与 KO,子状态负责行走、跳跃、攻击阶段和受击类型。状态机只处理“角色当前允许做什么”,招式帧表负责“某个动作在第几帧发生什么”。 + +```mermaid +flowchart LR + subgraph A["自然动作链"] + G["Ground
Idle / Walk / Crouch / Turn"] -->|跳跃输入| JS["JumpStart"] + JS -->|起跳完成| AIR["Air
Jump / Fall"] + AIR -->|接地| LAND["Landing"] + LAND -->|落地硬直结束| G + end + + subgraph B["主动与防御分支"] + READY["Ground / Air
当前可行动"] -->|攻击输入| ATK["Attack
普通技 / 空中攻击"] + READY -->|指令成立| SP["Special / Super
指令技 / 资源技"] + READY -->|有效格挡姿态| BLK["Block / BlockStun
格挡意图 / 格挡硬直"] + ATK -->|动作结束| BACK["返回 Ground / Air"] + SP -->|动作结束| BACK + BLK -->|硬直结束| BACK + end + + subgraph C["强制中断链(优先级高于普通输入)"] + NONKO["任意非 KO 状态"] -->|合法命中| HIT["HitStun / Launch
站立受击 / 浮空"] + HIT -->|击倒| DOWN["Knockdown / Wakeup
倒地 / 起身"] + DOWN -->|站立恢复或起身完成| GROUND["Ground"] + NONKO -->|生命归零| KO["KO / RoundEnd"] + HIT -->|受击阶段生命归零| KO + end +``` + +图 1 角色核心状态与主要转移 + +## 5.1 状态清单 + +| **状态组** | **子状态** | **关键职责** | +|--------------|-----------------------------------------------|----------------------------------------------------| +| **Ground** | Idle / Walk / Crouch / Turn | 读取移动与攻击输入;处理面对方向与地面摩擦 | +| **Air** | JumpStart / Jump / Fall / AirAttack / Landing | 处理起跳锁定、空中速度、接地与落地硬直 | +| **Attack** | Startup / Active / Recovery | 推进 MoveTimeline;开放 HitBox、取消窗口与位移曲线 | +| **Special** | Special / Super / Cinematic | 资源校验、无敌、飞行道具、屏幕冻结和多段命中 | +| **Defense** | Block / BlockStun | 格挡姿态、高低段校验、格挡硬直与推远 | +| **Reaction** | HitStun / Launch / WallBounce | 受击、浮空、击退、连段计数与恢复 | +| **Down** | Knockdown / Wakeup | 倒地时间、起身无敌与恢复站立 | +| **Terminal** | KO / RoundEnd | 禁用输入,播放结算动作与回合流程 | + +## 5.2 转移优先级 + +同一 Tick 可能同时出现 KO、受击、落地、取消与新输入。为避免结果依赖代码调用顺序,所有转移先生成 TransitionRequest,再由统一优先级选择。建议顺序如下: + +> **1.** KO / RoundEnd:生命归零或回合结束,覆盖所有普通转移。 +> +> **2.** 强制受击:命中、击倒、浮空;无敌或装甲可在 HitResolver 阶段拦截。 +> +> **3.** 脚本强制状态:超必杀演出、抓取演出(未来)、出生与回合开场。 +> +> **4.** 取消转移:命中取消、格挡取消、特殊取消、超必杀取消。 +> +> **5.** 接地 / 离地:落地、起跳帧结束、被击飞离地。 +> +> **6.** 自然结束:收招、硬直、起身、转身结束。 +> +> **7.** 玩家输入:走、蹲、跳、普通攻击、必杀技。 + +## 5.3 状态接口 + +**状态机接口建议** + +```csharp +interface IFighterState { + void Enter(FighterContext ctx, StateEnterArgs args); + void PreTick(FighterContext ctx, in InputFrame input); + void Tick(FighterContext ctx); // 推进逻辑帧,不直接播放特效 + void PostTick(FighterContext ctx); // 生成转移请求与表现事件 + void Exit(FighterContext ctx); +} + +struct TransitionRequest { + FighterStateId target; + int priority; + TransitionReason reason; + object payload; +} +``` + +> **避免的实现** +> +> 不要让 Animator StateMachineBehaviour 直接修改生命、开启判定或决定状态转移。动画事件可能因过渡、掉帧、速度变化或跳帧而漏发;它们最多用于非关键音效与装饰。 + +**MOVEMENT** + +# 06 行走、跳跃与角色运动 + +## 6.1 运动学角色控制器 + +角色移动使用自定义 Kinematic FighterMotor,而不是依赖动态刚体。每个 Tick 计算期望速度与位移,执行舞台边界、地面检测和 PushBox 修正,再写回逻辑位置。这样能获得稳定、可重放且便于联网预留的结果。 + +| **参数** | **原型建议值** | **说明** | +|------------------|----------------|----------------------------------------| +| **前走速度** | 4.2 m/s | 接近对手时略快,鼓励主动走位 | +| **后走速度** | 3.4 m/s | 后退不应成为无成本脱离手段 | +| **蹲姿移动** | 0 m/s | MVP 中蹲下固定;后续可增加缓慢移动 | +| **跳跃初速度** | 8.5 m/s | 通过重力与初速度共同调整弧线 | +| **重力** | −28 m/s² | 建议自定义重力,不使用项目全局物理重力 | +| **空中水平速度** | 3.2 m/s | 起跳时锁定方向,允许少量空中修正可配置 | +| **落地硬直** | 4 Tick | 普通跳落地;空中攻击可配置更长硬直 | +| **PushBox 宽度** | 0.75 m | 站立基线;蹲姿可略小 | + +*上述数值只用于灰盒验证;最终手感应以画面比例、角色身高和攻击距离统一调整。* + +## 6.2 行走规则 + +- 方向输入为 6 时使用前走速度,4 时使用后走速度;“前/后”按角色面对方向解释。 + +- 攻击、格挡硬直、受击、倒地和大多数必杀技期间不读取普通行走输入。 + +- 进入 Turn 状态时可锁定 1–2 Tick,防止模型瞬间镜像造成脚步穿帮;逻辑朝向仍在规则允许时更新。 + +- 两个 PushBox 重叠时,先尝试各自退让一半;一方贴舞台边界时由另一方承担剩余位移。 + +- 不得用 CharacterController 的自动 StepOffset 造成 Z 轴或高度漂移;地面只允许设计好的坡度或平面。 + +## 6.3 跳跃规则 + +- 跳跃分为 JumpStart、Airborne、Landing。JumpStart 建议 3–4 Tick,期间可被命中。 + +- 起跳方向在 JumpStart 结束时锁定;垂直跳 X 速度为 0,前后跳使用各自速度。 + +- 空中不允许再次跳跃;是否允许空中格挡、空中必杀应由角色与招式数据显式配置。 + +- 接地判定使用脚底 Probe 与上一 Tick 高度,避免高速下落穿透地面。 + +- 被击飞时使用 ReactionVelocity,不复用普通跳跃状态,以便独立控制重力、弹地和受身。 + +**角色运动 Tick 伪代码** + +```csharp +void SimulateMotor(Fighter f) { + Vec2 desired = f.State.BuildVelocity(); + Vec2 delta = desired * BattleClock.FixedDelta; + delta = Stage.ClampToBounds(f.Position, delta, f.PushBox); + f.Position += delta; + PushSolver.ResolvePair(f, f.Opponent); + GroundProbe.ResolveLanding(f); +} +``` + +> **手感调试顺序** +> +> 先固定角色身高与舞台比例,再调走速和跳跃弧线;随后调攻击距离;最后才调镜头。镜头不能掩盖角色运动本身的问题。 + +**MOVE SYSTEM** + +# 07 拳脚攻击与招式时间轴 + +## 7.1 普通攻击规格 + +每个普通技由 MoveData 描述,并由 MoveController 按 Tick 推进。MoveData 不仅记录起手/持续/收招,还包含位移、HitBox 轨迹、伤害、命中效果、取消窗口、资源变化和表现事件。 + +| **招式** | **定位** | **建议特征** | +|-----------------|---------------|--------------------------------------------------| +| **站轻拳 LP** | 近距离确认 | 4 Tick 起手;短收招;命中可连自身或取消必杀 | +| **站重拳 HP** | 高伤害 / 对空 | 8–10 Tick 起手;大 HitStop;可设置上半身对空判定 | +| **站轻脚 LK** | 中距离牵制 | 5 Tick 起手;较长脚部 HitBox;低伤害 | +| **站重脚 HK** | 远距离重击 | 10–12 Tick 起手;长收招;可造成击退或击倒 | +| **蹲轻脚 2LK** | 低段确认 | 低段;伤害低;命中后可接轻拳或必杀 | +| **跳重脚 j.HK** | 跳入攻击 | 空中可用;落地有硬直;根据落点决定正反向 | + +## 7.2 招式阶段 + +| **阶段** | **逻辑** | **可发生事件** | +|--------------|--------------------------|------------------------------------------| +| **Startup** | 输入已接受,尚无攻击判定 | 前摇位移、无敌/装甲、取消来源检查 | +| **Active** | 一个或多个 HitBox 有效 | 命中、格挡、交易命中、多段间隔 | +| **Recovery** | HitBox 关闭,等待恢复 | 可被反击;部分招式开放命中取消 | +| **End** | 招式完成 | 返回 Idle / Air / Landing 或链入后续动作 | + +## 7.3 取消规则 + +- Whiff Cancel:挥空时允许取消;MVP 默认关闭,避免无限安全试探。 + +- Hit Cancel:命中后允许普通技取消到指定必杀技。 + +- Block Cancel:被格挡时也可取消;通常比命中取消列表更严格。 + +- Chain Cancel:轻攻击可在命中后连到另一轻攻击或指定中攻击。 + +- Super Cancel:特定必杀技在命中/格挡时可取消为超必杀技。 + +- 取消只在 MoveData 的帧区间内有效,并且来源、目标、命中状态和资源条件全部满足。 + +## 7.4 连段与伤害缩放 + +| **规则** | **MVP 建议** | +|------------------|-----------------------------------------------------------| +| **连击成立** | 上一击造成的 HitStun 尚未结束,下一击在恢复前命中 | +| **伤害缩放** | 第 1 击 100%,第 2 击 90%,之后每击 −10%,最低 50% | +| **最低伤害** | 超必杀最后一击可设 20% 最低保障,避免长连段完全失去终结感 | +| **HitStun 衰减** | 连击达到 5 段后每段额外减少 1 Tick,避免无限连 | +| **重复招式限制** | 同一浮空连段中关键弹墙/浮空属性只触发一次 | +| **连击重置** | 受击者回到可行动状态、倒地结束或回合结算时重置 | + +> **帧优势的基本检查** +> +> 命中优势 ≈ HitStun − 攻击者剩余收招;格挡优势 ≈ BlockStun − 剩余收招。MVP 不要求复杂数值,但每个招式都应能从帧表推导“命中后谁先动、格挡后是否可反击”。 + +**DEFENSE** + +# 08 格挡、防御与受击规则 + +## 8.1 格挡意图 + +格挡不是一个可以随时强制进入的普通动作,而是角色在可防御状态下、检测到“远离对手”的方向输入后产生的 GuardIntent。HitResolver 在命中 Tick 使用该意图、姿态和攻击段位决定是否格挡成功。 + +| **攻击段位** | **站立格挡** | **蹲下格挡** | **空中格挡** | +|------------------------|--------------|--------------|--------------| +| **High 高段** | 可 | 不可 | MVP 不支持 | +| **Mid 中段** | 可 | 可 | MVP 不支持 | +| **Low 低段** | 不可 | 可 | MVP 不支持 | +| **Unblockable 不可防** | 不可 | 不可 | 不可 | + +## 8.2 格挡成立条件 + +- 受击者当前状态允许格挡:Idle、Walk、Crouch 或可取消到格挡的恢复状态。 + +- 在命中 Tick 开始时存在有效 GuardIntent;方向按该 Tick 的 FacingSnapshot 解释。 + +- 攻击段位与受击者姿态匹配;不可防或投技类攻击直接绕过。 + +- 受击者未处于 GuardBreak、KO、不可防演出或明确禁止格挡的动作中。 + +- 命中来自身后时,MVP 仍按双方逻辑朝向判定;下一 Tick 才更新转身,避免同 Tick 双重解释。 + +## 8.3 格挡结果 + +| **项目** | **建议规则** | +|--------------|---------------------------------------------------------------------| +| **格挡硬直** | 由攻击 MoveData 给出;轻攻击 6–9 Tick,重攻击 12–18 Tick | +| **格挡推远** | 攻击者与防守者按比例分摊;角落时攻击者承担更多反作用位移 | +| **削血** | 普通技 0;必杀技 5%–15% 基础伤害;削血不能直接 KO(可配置) | +| **能量增长** | 攻击者命中格挡获得少量能量,防守者也获得更少能量 | +| **取消规则** | 攻击者可按 MoveData 在格挡后取消;防守者不可在 BlockStun 中普通行动 | +| **连续格挡** | BlockStun 期间若新攻击到达,重新计算段位并刷新硬直 | + +## 8.4 受击类型 + +| **类型** | **用途** | **反应** | +|----------------|----------------------|----------------------------------| +| **LightHit** | 轻拳、轻脚 | 短 HitStop、轻微后仰、短硬直 | +| **HeavyHit** | 重拳、重脚、必杀 | 较长 HitStop、明显击退与受击动作 | +| **Launch** | 对空或浮空技 | 写入空中速度,进入 Launch 状态 | +| **Knockdown** | 扫腿、重击、超杀终结 | 落地后进入倒地与起身流程 | +| **Crumple** | 可选的重度受击 | 长站立硬直,可用于超杀前置 | +| **CounterHit** | 在对手攻击起手被命中 | 额外伤害或 HitStun,并显示提示 | + +> **简化建议** +> +> MVP 先不做完美格挡、弹反、防御槽和受身。防御系统越复杂,越需要额外动画、UI、AI与平衡成本;先把高/中/低段与格挡硬直做正确。 + +**HIT DETECTION** + +# 09 HitBox 与命中结算管线 + +## 9.1 判定体分类 + +| **判定体** | **归属** | **用途** | **实现建议** | +|------------------|----------------|--------------------------|--------------------------------------| +| **PushBox** | 角色 | 角色间实体推挤与角落挤压 | 固定矩形/胶囊;每 Tick 处理一次 | +| **HurtBox** | 角色骨骼或姿态 | 可被攻击命中的区域 | 头、躯干、腿等多个盒;随姿态轨迹变化 | +| **HitBox** | 招式或飞行道具 | 主动攻击判定 | 仅 Active 帧开启;携带 HitDefinition | +| **ProximityBox** | 可选 | 靠近时触发近身普通技 | 只用于选招,不直接造成命中 | +| **StageBox** | 场景 | 地面、边界与特殊区域 | 逻辑用简化体,视觉网格不参与战斗碰撞 | + +## 9.2 判定轨迹 + +HitBox 与 HurtBox 不依赖运行时骨骼物理碰撞,而是由可编辑的 BoxTrack 描述。每个 Tick 根据招式帧或姿态帧读取局部位置、尺寸、旋转与标签,再转换到角色逻辑坐标。这样可以稳定调节判定,并在动画替换后保持规则可控。 + +- 优先使用矩形或胶囊;避免 MeshCollider 和复杂 Convex 碰撞。 + +- 每个 HitBox 携带 MoveInstanceId + HitGroupId,用于控制同一招式对同一目标的命中次数。 + +- 多段招式通过不同 HitGroup 或 RehitInterval 配置,不通过重复开关碰撞器猜测。 + +- 飞行道具使用独立 OwnerId、TeamId 与 ProjectileInstanceId,仍进入统一 HitResolver。 + +- 调试模式显示红色 HitBox、绿色 HurtBox、蓝色 PushBox,并标注 Tick 与招式 ID。 + +```mermaid +flowchart TD + S1["1. 收集同 Tick 的 HitBox × HurtBox 候选"] --> + S2["2. 过滤:阵营、自身、重复命中、无敌 / 装甲"] --> + S3["3. 判定格挡、高低段、交叉攻击与反击命中"] --> + S4["4. 处理同帧冲突:交易命中 / 显式优先级"] --> + S5["5. 计算伤害、连段缩放、削血与资源变化"] --> + S6["6. 写入 HitStop、HitStun / BlockStun、击退与浮空"] --> + S7["7. 派发视觉事件:特效、音效、震屏、慢镜头"] --> + S8["8. 记录 CombatLog 与回放输入,进入下一个 Tick"] +``` + +图 2 单个战斗 Tick 内的命中结算顺序 + +## 9.3 同帧冲突规则 + +| **场景** | **规则** | +|------------------------------|-----------------------------------------------------------------------| +| **双方普通攻击同 Tick 命中** | 默认交易命中:两边都应用结果;除非招式显式配置优先级或无敌 | +| **攻击命中飞行道具** | MVP 默认不相互抵消;后续可给飞行道具增加耐久与碰撞层 | +| **同一攻击命中多个 HurtBox** | 只生成一个 HitCandidate,使用命中点优先级最高的 HurtBox | +| **多段攻击同 Tick 重叠** | 按 HitGroupId 和 RehitInterval 去重;同目标每组最多结算一次 | +| **命中与 KO 同 Tick** | 先完成该 Tick 全部合法命中,再按结果进入 KO;表现上可选择超杀终结慢镜 | +| **格挡与被绕后同 Tick** | 按 Tick 开始时 FacingSnapshot 判断;下一 Tick 才转身 | + +## 9.4 HitResult 数据 + +**统一命中结果对象** + +```csharp +struct HitResult { + EntityId attacker; + EntityId defender; + MoveInstanceId moveInstance; + HitOutcome outcome; // Hit / Block / Armor / Invulnerable + int damage; + int chipDamage; + int hitStopAttacker; + int hitStopDefender; + int stunTicks; + Vec2 pushback; + Vec2 launchVelocity; + bool counterHit; + bool knockdown; + Vec2 contactPoint; +} +``` + +> **实现原则** +> +> 碰撞系统只回答“哪些判定体发生重叠”;HitResolver 才回答“这次重叠是否有效、是否格挡、造成多少伤害、进入什么状态”。不要把游戏规则写进 OnTriggerEnter。 + +**SPECIALS & SUPER** + +# 10 必杀技、超必杀技与能量 + +## 10.1 必杀技组合建议 + +| **招式类型** | **战术作用** | **关键实现点** | +|--------------|----------------------------|---------------------------------------| +| **能量弹** | 远距离牵制、迫使跳跃或格挡 | 对象池、速度、寿命、命中组、场外回收 | +| **对空升击** | 惩罚跳跃、近身反击 | 起手无敌窗口、向上 HitBox、落地收招 | +| **突进踢** | 中距离接近、连段收尾 | 位移曲线、墙边推挤、命中/格挡不同收招 | + +## 10.2 能量系统 + +| **事件** | **攻击者获得** | **防守者获得** | +|------------------|----------------|----------------| +| **普通技命中** | 2–5 | 1–3 | +| **普通技被格挡** | 1–3 | 1–2 | +| **必杀技命中** | 5–10 | 3–5 | +| **必杀技被格挡** | 3–6 | 2–4 | +| **受到重击** | — | 5–8 | +| **使用超必杀** | 消耗 100 | — | + +*能量增长应全部来自 MoveData,不写死在攻击类型分支中。* + +## 10.3 超必杀执行流程 + +> **1.** CommandRecognizer 检测到超必杀指令,并确认当前状态允许、能量为 100。 +> +> **2.** 立即预占资源并创建 SuperMoveInstance,防止同 Tick 重复触发。 +> +> **3.** 进入 8–15 Tick 的 SuperFreeze:对手、普通飞行道具与比赛计时暂停;施放者按配置推进或保持。 +> +> **4.** 播放镜头拉近、角色闪光和蓄力音效;逻辑仍由 MoveTimeline 决定无敌与判定开启。 +> +> **5.** 按多段 HitGroup 依次命中;每段有独立 HitStop、伤害与击退。 +> +> **6.** 终结段应用 Knockdown 或特殊 KO 镜头;超杀结束后恢复正常镜头与 BattleTime。 + +| **规则项** | **建议** | +|--------------|-------------------------------------------------------| +| **超杀输入** | 236236 + 任意拳;总窗口 24 Tick,按键缓冲 5 Tick | +| **资源检查** | 必须在动作开始前满足;资源在 Enter 时扣除 | +| **起手无敌** | 示例 1–8 Tick;只作为招式数据,不由“超杀”类型自动赋予 | +| **画面冻结** | 逻辑冻结与表现冻结分离;UI、音效尾音可继续 | +| **连段缩放** | 遵循通用缩放,但终结段可有最低伤害保障 | +| **失败处理** | 资源不足时回退到普通拳;不可因指令部分匹配吞掉输入 | + +> **原创性要求** +> +> 可以借鉴“飞行道具、对空、突进、超杀冻结”等类型设计,但角色造型、动作轮廓、招式名称、特效、音效、UI 与数值组合应原创,避免直接复刻现有作品。 + +**PRESENTATION** + +# 11 动画、特效、音效与镜头 + +## 11.1 动画驱动策略 + +| **内容** | **权威来源** | **说明** | +|------------------|-----------------------------|--------------------------------| +| **当前动作与帧** | FighterState + MoveTimeline | 动画根据逻辑动作与 Tick 采样 | +| **HitBox 开关** | MoveData / HitboxTrack | 不依赖 Animation Event | +| **角色位移** | FighterMotor / MotionCurve | 普通战斗禁用自由 Root Motion | +| **受击与格挡** | HitResult | 结果确定后触发对应表现 | +| **音效与特效** | PresentationEvent | 允许丢帧补播,但不影响战斗结果 | +| **镜头** | CameraDirector | 消费角色位置与命中事件 | + +## 11.2 动画状态建议 + +- 基础层:Idle、WalkF、WalkB、Crouch、Turn、JumpStart、JumpUp、Fall、Landing。 + +- 攻击层:每个普通技、必杀技与超必杀独立 Clip;按 MoveId 显式切换。 + +- 受击层:轻/重受击、上段/下段格挡、浮空、倒地、起身、KO。 + +- 面部或上半身叠加层仅用于非关键表现,不改变 HurtBox 和战斗姿态。 + +- 动画过渡时间应很短且可预测;攻击动作通常使用 0 或 1 Tick 的逻辑切换。 + +## 11.3 打击反馈事件包 + +| **反馈** | **轻击** | **重击 / 必杀** | **超必杀终结** | +|-------------|--------------|------------------------|--------------------------| +| **HitStop** | 4–6 Tick | 7–11 Tick | 10–16 Tick | +| **火花** | 小型、短寿命 | 较大、方向性强 | 多层特效 + 屏幕闪光 | +| **音效** | 清脆 | 低频冲击 + 武器/元素层 | 专属终结音与低频冲击 | +| **震屏** | 0–2 px | 3–7 px | 8–14 px;可在设置中减弱 | +| **镜头** | 通常不变 | 轻微推近或冲击抖动 | 拉近、冻结、跟随终结动作 | +| **飘字** | 可选 | Counter / Punish 提示 | Super / KO 强调 | + +## 11.4 镜头规则 + +- 基础镜头朝向战斗平面,目标点为两角色中点;距离随角色间距变化。 + +- 确保两人屏幕边缘留有安全边距,且舞台边界不会暴露未制作区域。 + +- 跳跃时相机只跟随一定比例的 Y 高度,避免频繁上下晃动。 + +- HitStop 期间可添加短促 Impulse,但相机位置插值需使用 PresentationTime。 + +- 超杀镜头必须有超时回退;角色被 KO、演出被中断或对象销毁时强制恢复基础镜头。 + +> **无障碍与舒适度** +> +> 设置中提供“震屏强度、闪光强度、手柄震动、动态镜头”开关。关闭这些效果不应改变 HitStop、输入、判定和比赛结果。 + +**ARCHITECTURE** + +# 12 技术架构与更新顺序 + +```mermaid +flowchart TB + subgraph DATA["输入与数据"] + CD["Character / Move / Stage Data"] + IB["InputBuffer"] --> CR["CommandRecognizer"] + end + + subgraph COMBAT["战斗域"] + BC["BattleClock
固定 60 Tick"] + FC["FighterController
StateMachine + Motor"] + MF["MatchFlow"] + MC["MoveController
招式时间轴"] + CW["CollisionWorld
Push / Hurt / Hit Box"] + HR["HitResolver
统一命中结算"] + PS["ProjectileSystem"] + end + + subgraph PRESENT["表现层"] + VA["VFX / Audio / 动画"] + CAM["CameraDirector"] + HUD["HUD / 菜单"] + end + + CD --> FC + CD --> MC + CD --> PS + CR --> FC + BC --> FC + BC --> MF + FC --> MC + FC --> CW + MC --> CW + CW --> HR + HR --> FC + HR --> PS + PS --> CW + + MF -.-> HUD + FC -.-> VA + FC -.-> CAM + FC -.-> HUD + HR -.-> VA + HR -.-> CAM +``` + +图 3 推荐的战斗域模块划分 + +## 12.1 核心模块 + +| **模块** | **职责** | **禁止事项** | +|-----------------------|------------------------------------------|--------------------------------| +| **BattleClock** | 固定 Tick、暂停、HitStop 域与帧步进 | 不读取玩家输入,不直接控制动画 | +| **InputService** | 设备采样、重绑定、写入 InputBuffer | 不决定招式,不改变角色状态 | +| **CommandRecognizer** | 匹配指令并生成动作请求 | 不扣资源,不直接播放动画 | +| **FighterController** | 聚合状态机、运动、生命、能量与动作实例 | 不直接搜索场景对象 | +| **MoveController** | 按 Tick 推进招式事件与取消窗口 | 不直接结算伤害 | +| **CollisionWorld** | 构建判定体、收集重叠候选 | 不处理格挡、伤害或 KO | +| **HitResolver** | 过滤、格挡、伤害、硬直、击退、资源与日志 | 不依赖特效对象是否存在 | +| **ProjectileSystem** | 生成、推进、回收飞行道具 | 不绕过 HitResolver | +| **MatchFlow** | 开场、计时、KO、回合与胜负 | 不持有角色招式细节 | +| **Presenter** | 动画、VFX、音效、镜头、HUD | 不反向决定战斗逻辑 | + +## 12.2 每 Tick 更新顺序 + +> **1.** InputService 将设备状态汇总为 InputFrame,并写入每个玩家的 InputBuffer。 +> +> **2.** BattleClock 判断全局暂停、超杀冻结与各实体 HitStop,确定本 Tick 谁可以推进。 +> +> **3.** Fighter PreTick:识别指令、生成转移请求、处理已到期硬直。 +> +> **4.** 状态机提交最高优先级转移;MoveController 推进当前招式帧并生成判定轨迹。 +> +> **5.** FighterMotor 与 ProjectileSystem 计算位移;Stage/PushSolver 修正边界和角色重叠。 +> +> **6.** CollisionWorld 重建本 Tick 的 Push/Hurt/Hit Box 并收集 HitCandidate。 +> +> **7.** HitResolver 对全部候选排序和结算,写入生命、能量、硬直、击退与新状态请求。 +> +> **8.** MatchFlow 检查 KO、时间结束与回合转换。 +> +> **9.** Presenter 消费本 Tick 的只读快照与 PresentationEvent,更新动画、特效、音效、镜头和 HUD。 +> +> **10.** CombatLog 记录输入摘要、状态摘要与命中结果,用于回放和问题复现。 + +**战斗主循环伪代码** + +```csharp +void BattleTick() { + inputService.CaptureTickInputs(); + battleClock.BeginTick(); + foreach (var fighter in fighters) fighter.PreTick(); + foreach (var fighter in fighters) fighter.CommitTransition(); + foreach (var fighter in fighters) fighter.SimulateMoveAndMotor(); + projectileSystem.Simulate(); + collisionWorld.BuildSnapshot(fighters, projectiles); + hitResolver.Resolve(collisionWorld.CollectHitCandidates()); + foreach (var fighter in fighters) fighter.PostTick(); + matchFlow.EvaluateRound(); + combatLog.RecordSnapshot(); + presenter.PublishFrame(); +} +``` + +## 12.3 事件分类 + +| **事件类型** | **是否可丢失** | **例子** | +|------------------------|-----------------|----------------------------------------| +| **Domain Command** | 不可 | 开始招式、扣能量、应用 HitResult | +| **Domain Event** | 不可 | FighterHit、RoundKO、ProjectileSpawned | +| **Presentation Event** | 可补播 / 可降级 | HitSpark、CameraShake、PlaySfx | +| **Telemetry Event** | 可批量 | 输入统计、帧耗时、招式使用率 | + +**DATA** + +# 13 数据配置与资源规范 + +## 13.1 CharacterData + +| **字段组** | **示例字段** | +|--------------|----------------------------------------------------------| +| **身份** | CharacterId、显示名、阵营、默认皮肤 | +| **体型** | 身高、PushBox、站立/蹲姿 HurtBox 轨迹 | +| **移动** | 前走/后走速度、跳跃初速度、重力、落地硬直 | +| **资源** | 最大生命、最大能量、初始能量、受击能量倍率 | +| **动作映射** | Idle、Walk、Jump、Block、Hit、KO 与 MoveId 列表 | +| **表现** | AnimatorProfile、VFXProfile、AudioProfile、CameraProfile | + +## 13.2 MoveData 核心字段 + +| **字段组** | **字段** | **说明** | +|------------|---------------------------------------|-------------------------------------------------------| +| **标识** | MoveId / DisplayName / Tags | 唯一 ID、编辑器名称与 Normal/Special/Super/Air 等标签 | +| **输入** | CommandId / Button / AllowedStates | 指令、攻击键、允许起招状态 | +| **时序** | Startup / ActiveSegments / Recovery | 全部使用整数 Tick | +| **命中** | Damage / HitLevel / HitStop / HitStun | 伤害、高低段、停顿与硬直 | +| **位移** | MotionCurve / Pushback / Launch | 攻击者位移、受击推远与浮空速度 | +| **判定** | HitboxTrack / HurtboxOverride | 逐 Tick 判定轨迹与姿态覆盖 | +| **防御** | BlockStun / Chip / GuardPush | 被格挡后的效果 | +| **资源** | MeterGain / MeterCost | 命中、格挡、挥空与使用成本 | +| **取消** | CancelWindows / CancelTargets | 时间区间、命中条件和目标标签 | +| **特殊** | Invuln / Armor / CounterHitBonus | 无敌、装甲、反击奖励 | +| **表现** | AnimationId / VFX / SFX / CameraCue | 只读消费的表现事件 | + +**Unity 风格的数据对象示例** + +```csharp +[CreateAssetMenu(menuName = "Fighting/Move Data")] +public sealed class MoveData : ScriptableObject { + public string moveId; + public MoveTag tags; + public CommandData command; + public int startupTicks; + public ActiveSegment[] activeSegments; + public int recoveryTicks; + public HitDefinition hit; + public HitboxTrack hitboxTrack; + public MotionTrack motionTrack; + public CancelWindow[] cancelWindows; + public InvulnerabilityWindow[] invulnerability; + public PresentationCue[] presentation; +} +``` + +## 13.3 资源目录建议 + +**建议的目录与归属** + +```text +Assets/Game/ +├── Battle/ +│ ├── Core/ # Clock、状态机、命中、运动、回放 +│ ├── Data/ # CharacterData、MoveData、StageData +│ ├── Editor/ # 帧表、判定轨迹与预览工具 +│ └── Debug/ # Hitbox、输入、日志、帧步进 +├── Characters// +│ ├── Animations/ +│ ├── Moves/ +│ ├── HitboxTracks/ +│ ├── VFX/ +│ ├── Audio/ +│ └── Prefabs/ +├── Stages// +├── UI/ +└── Tests/ + ├── Unit/ + ├── Simulation/ + └── PlayMode/ +``` + +## 13.4 命名与版本 + +- 稳定 ID 使用小写蛇形:hero_01、st_lp、sp_projectile、su_finisher;显示名可本地化。 + +- 逻辑数据与表现资源分离:MoveData 不直接引用场景对象,只引用可加载资源 ID。 + +- 数据增加 schemaVersion;重大字段变更提供迁移脚本,而不是手工逐个修复。 + +- 所有平衡数值变更进入版本控制,并在提交说明中写明“旧值 → 新值 → 目的”。 + +- 角色 Prefab 只挂通用组件,不在 Prefab 上复制核心战斗脚本。 + +> **编辑器工具优先级** +> +> 最值得做的自定义工具是“招式时间轴 + HitBox/HurtBox 轨迹预览”。它应支持拖动 Tick、复制帧、镜像判定、显示动画参考姿态并即时运行单招测试。 + +**MATCH & UI** + +# 14 UI、回合流程与设置 + +## 14.1 MatchFlow 状态 + +| **状态** | **持续 / 退出条件** | **允许内容** | +|-----------------|-------------------------|--------------------------------| +| **Loading** | 资源准备完成 | 不推进战斗 | +| **Intro** | 角色入场动画结束 | 镜头演出、不可操作 | +| **RoundReady** | “Round / Fight”演出结束 | 角色锁定、计时不动 | +| **Fighting** | KO 或时间归零 | 完整战斗逻辑 | +| **KOFreeze** | 短暂停顿结束 | 命中表现继续,禁止新输入 | +| **RoundResult** | 胜负演出完成 | 更新局数、播放胜利动作 | +| **MatchResult** | 玩家选择重开或退出 | 结果 UI | +| **Paused** | 关闭暂停 | BattleTime 停止,RealTime 继续 | + +## 14.2 战斗 HUD + +- 双方生命条:显示当前值、延迟损失值(白血)与 KO 状态。 + +- 双方能量条:0–100;满能量时有明确但不过度闪烁的提示。 + +- 回合标记:最多 2 个胜利点;当前回合和剩余时间居中。 + +- 连击与提示:Combo、Counter、Punish、Block;由 Domain Event 驱动。 + +- 训练/调试叠层:当前状态、MoveId、招式帧、帧优势、输入历史和判定框开关。 + +## 14.3 设置与可访问性 + +| **类别** | **设置项** | +|----------|---------------------------------------------------| +| **控制** | 键盘/手柄重绑定、摇杆死区、输入显示、简易指令开关 | +| **画面** | 分辨率、窗口模式、画质、动态模糊关闭、闪光强度 | +| **镜头** | 震屏强度、超杀镜头、镜头跟随强度 | +| **音频** | 总音量、音乐、音效、语音、手柄震动 | +| **游戏** | 回合数、时间、AI 难度、训练模式生命恢复 | +| **辅助** | 色盲友好 HUD、字幕、慢速训练、按键提示 | + +> **UI 数据边界** +> +> HUD 只读取 BattleSnapshot,不直接持有 FighterController。这样暂停、回放、观战与未来联网时都可以复用同一套展示逻辑。 + +**AI & TRAINING** + +# 15 AI 与训练木桩 + +## 15.1 MVP AI 结构 + +采用“感知 + 战术状态 + 效用评分”的轻量 AI。AI 不需要直接读取未来输入或命中结果;它只使用当前距离、双方状态、资源、角落位置和近期行为。决策间隔建议 4–8 Tick,并加入反应延迟。 + +| **战术状态** | **进入条件** | **候选行为** | +|--------------|-------------------------|--------------------------------------| +| **Neutral** | 双方可行动、距离中远 | 走位、能量弹、试探重脚、等待 | +| **Approach** | 距离过远或时间落后 | 前走、前跳、突进技 | +| **Pressure** | 对手在 BlockStun / 角落 | 轻攻击、延迟重击、必杀取消、后退诱骗 | +| **Defense** | 对手起攻或自身低血量 | 格挡、后退、对空、无敌技 | +| **Punish** | 检测到对手大收招 | 选择起手足够快且距离可达的招式 | +| **Combo** | 己方攻击命中确认 | 按预设连段脚本执行后续输入 | + +## 15.2 难度差异 + +| **维度** | **简单** | **普通** | **困难** | +|--------------|----------------|------------------|------------------------| +| **反应延迟** | 18–24 Tick | 10–16 Tick | 6–10 Tick | +| **指令失误** | 较高 | 中等 | 低 | +| **格挡倾向** | 只格挡明显攻击 | 按距离与动作判断 | 能识别常见高低段 | +| **连段长度** | 1–2 击 | 3–4 击 | 4–6 击并使用资源 | +| **反制知识** | 无 | 有限对空/惩罚 | 基于帧差和距离选择惩罚 | +| **随机性** | 高 | 中 | 低但仍保留风格权重 | + +## 15.3 训练木桩 + +- 站立、蹲下、跳跃、第一击后格挡、随机格挡。 + +- 受击后恢复生命、能量固定、位置重置。 + +- 录制 5–10 秒输入并循环播放,用于测试防守与惩罚。 + +- 显示连击伤害、起始伤害、缩放、帧优势和可行动 Tick。 + +- 一键重置到舞台中央或角落。 + +> **禁止“读心”** +> +> 困难 AI 可以更快、更准,但不应读取玩家尚未生效的输入,也不应在 0 Tick 内完美反应。可接受的优势来自更短反应延迟、更好的距离判断与更完整的连段脚本。 + +**PERFORMANCE & FUTURE** + +# 16 性能、回放与联机预留 + +## 16.1 性能预算 + +| **项目** | **目标** | +|----------------|----------------------------------------------------------| +| **战斗 Tick** | 平均 \< 1.5 ms,99 分位 \< 3 ms(双角色 + 常规飞行道具) | +| **碰撞判定体** | 每角色 HurtBox 4–8 个;单招 HitBox 1–4 个 | +| **运行时分配** | 战斗关键路径 0 B / Tick;列表和事件缓冲预分配 | +| **飞行道具** | 常规同时存在 ≤ 16;使用对象池 | +| **VFX / 飘字** | 对象池 + 最大并发;超限时降级而非阻塞 | +| **动画** | 避免每 Tick 字符串查找;缓存哈希与状态 ID | + +## 16.2 回放 + +首版回放只需保存初始随机种子、角色/场景数据版本与每 Tick 的 InputFrame。回放时重新运行战斗逻辑,并周期性记录状态哈希。若哈希不一致,立即输出首个分歧 Tick、角色状态、位置、生命与当前招式。 + +- 回放文件不保存动画、特效或音频状态。 + +- 每 60 Tick 记录一次轻量状态哈希,用于自动检测非确定性。 + +- 版本不兼容时明确拒绝播放,不尝试静默修复。 + +- Bug 报告可自动附带最近 10 秒输入与 CombatLog。 + +## 16.3 回滚联机预留 + +MVP 不实现联网,但核心域应避免阻断未来回滚:战斗逻辑固定 Tick、输入驱动、状态可序列化、表现层可重建。真正接入网络时,需进一步处理浮点确定性、快照大小、回滚重演性能与输入延迟。 + +| **现在就做** | **以后再做** | +|---------------------------------------|------------------------------| +| 固定 Tick、输入帧、战斗快照、状态哈希 | 网络传输、预测输入、回滚窗口 | +| 战斗域不依赖场景查找与动画事件 | 跨平台确定性与浮点策略 | +| 表现事件带唯一 ID,允许回滚撤销/去重 | 观战、断线重连、匹配与反作弊 | +| 对象池和可重建飞行道具 | 网络带宽压缩与同步协议 | + +> **现实边界** +> +> 不要为了“以后可能联网”过早引入复杂框架。只要保持固定 Tick、输入驱动、数据可序列化和表现解耦,单机原型就已经保留了最有价值的联机基础。 + +**DEBUGGING** + +# 17 调试工具与可观测性 + +## 17.1 必做工具 + +| **工具** | **功能** | **验收标准** | +|-------------------|--------------------------------------|--------------------------------| +| **帧步进** | 暂停后每次推进 1 Tick / 10 Tick | 状态、动画采样和判定体同步推进 | +| **输入历史** | 显示最近 30–60 Tick 的方向和按键 | 标出已消费输入与指令匹配区间 | +| **判定框显示** | Hit/Hurt/Push Box 颜色区分 | 镜像、缩放和移动后仍准确 | +| **招式面板** | MoveId、当前帧、阶段、取消窗口 | 能看到有效 HitBox 与无敌窗口 | +| **战斗日志** | 命中候选、过滤原因、HitResult | 可按角色、招式与 Tick 筛选 | +| **状态快照** | 位置、速度、状态、生命、能量、Facing | 一键复制为文本用于 Bug 单 | +| **输入录制/回放** | 录制并重复执行短序列 | 多次播放结果哈希一致 | + +## 17.2 CombatLog 示例 + +**命中日志应能回答“为什么发生这个结果”** + +```text +T=1842 P1 move=st_hp frame=8 phase=Active +candidate: hitbox=body_0 target=P2 hurtbox=torso +guardIntent=true stance=Standing hitLevel=Mid => BLOCK +result: blockStun=14 hitStop=8 guardPush=(0.42,0) +meter: P1 +4, P2 +2 +transition: P2 -> BlockStun(14), P1 stays st_hp frame=8 +``` + +## 17.3 自动监控断言 + +- 角色生命、能量与状态枚举始终在合法范围。 + +- 同一 Fighter 同 Tick 只能提交一个最终状态转移。 + +- MoveController 帧不得超出总帧数;已结束招式必须释放 MoveInstance。 + +- 同一 HitGroup 不得在禁止重击间隔内重复命中同一目标。 + +- KO 后不得接受玩家输入或生成新的攻击 HitBox。 + +- 角色逻辑位置不得离开舞台边界,Z 逻辑坐标必须为 0。 + +- 回放状态哈希不一致时自动中止并导出差异。 + +> **调试不是“开发者模式装饰”** +> +> 格斗游戏的大多数问题都发生在特定 Tick 和特定状态组合。没有输入历史、帧步进和判定框,团队会把时间花在猜测而不是定位。 + +**DELIVERY PLAN** + +# 18 开发里程碑与任务拆分 + +以下计划以 1 名程序 + 1 名通才美术/动画的原型团队为参考。若是个人开发,可按同一顺序推进,但不要并行制作大量内容。每个阶段必须通过验收门再进入下一阶段。 + +| **阶段** | **主要交付** | **验收门** | +|-------------------|-------------------------------------------------|--------------------------------------------------| +| **M0 工程骨架** | BattleClock、输入帧、基础数据、空场景、调试面板 | 帧步进与输入历史可用;固定 Tick 不受渲染帧率影响 | +| **M1 运动原型** | 双角色生成、行走、转身、跳跃、PushBox、镜头 | 无穿透、无越界;30/60/120 FPS 手感一致 | +| **M2 命中闭环** | 普通技、Hit/Hurt Box、HitStop、受击、格挡、KO | 镜像角色能完成一局;结果可由 CombatLog 解释 | +| **M3 招式系统** | 指令识别、取消、飞行道具、对空、突进、超杀 | 录制输入可稳定触发;资源和多段命中正确 | +| **M4 比赛产品化** | HUD、回合流程、暂停、设置、本地双人、训练木桩 | 从启动到重开无阻断;关键设置可保存 | +| **M5 AI 与打磨** | AI、VFX/SFX、镜头、两角色内容、性能优化 | 完整 Demo 可连续游玩 30 分钟无卡死或明显漏判 | +| **M6 发布准备** | 测试回归、手柄覆盖、打包、日志与崩溃信息 | 验收清单全绿;已知问题有明确说明 | + +## 18.1 工作量粗估 + +| **模块** | **人日范围** | **备注** | +|--------------------------|--------------|-----------------------------------------------------| +| **战斗核心与调试** | 8–12 | 输入、时钟、状态机、运动、判定、命中与工具 | +| **普通技 / 必杀 / 超杀** | 5–8 | 不含高质量动画制作 | +| **比赛流程与 UI** | 3–5 | 基础 HUD、设置、回合与暂停 | +| **AI 与训练模式** | 3–5 | 简单效用 AI + 训练木桩 | +| **表现整合** | 5–8 | 动画接入、特效、音效、镜头与反馈 | +| **测试与优化** | 4–7 | 自动测试、性能、手柄、打包与回归 | +| **合计** | 28–45 | 占位素材的功能型 Demo;个人开发按技能和素材情况浮动 | + +## 18.2 推荐任务拆分 + +- 每个功能任务都包含:数据、运行时、调试显示、测试场景与验收用例。 + +- 角色动作与判定由同一 MoveId 串联,避免动画名、招式名和代码枚举不一致。 + +- 每新增一个招式,必须同时提交帧表、HitboxTrack、命中/格挡结果和训练模式验证步骤。 + +- 每次调整核心结算顺序,都运行固定回放集并比较状态哈希。 + +- 表现任务在战斗事件稳定后接入,不直接修改战斗核心以“配合动画”。 + +> **第一可玩顺序** +> +> Idle → Walk → Jump → 一个普通技 → HitBox/HurtBox → HitStop/HitStun → Block → KO。完成这条链路前,不做超杀镜头、AI 或复杂 UI。 + +**QA** + +# 19 测试方案与验收标准 + +## 19.1 自动测试层级 + +| **层级** | **覆盖内容** | **示例** | +|-------------------|-----------------|-----------------------------------------------| +| **Unit** | 纯算法与数据 | 指令匹配、伤害缩放、格挡段位、优先级排序 | +| **Simulation** | 无渲染战斗 Tick | 固定输入运行 600 Tick,检查状态哈希与最终生命 | +| **PlayMode** | 场景与组件集成 | 跳跃接地、舞台边界、飞行道具回收、HUD 更新 | +| **Golden Replay** | 回归回放集 | 10–30 条已知战斗录像在每次构建中重放 | +| **Manual** | 手感与设备 | 键盘/多种手柄、帧率、震屏、视觉可读性 | + +## 19.2 核心验收用例 + +| **ID** | **场景** | **通过标准** | +|------------|-------------------------------|------------------------------------------| +| **MOV-01** | 前后行走并互相推挤 | 不穿透、不抖动、不越界;角落分摊正确 | +| **MOV-02** | 30/60/120 FPS 下连续跳跃 | 起跳高度、滞空 Tick 与落点一致 | +| **INP-01** | 快速输入 236+P | 在宽容窗口内稳定触发,超窗不触发 | +| **INP-02** | 236236+P 且能量不足 | 不吞键;按优先级回退到合法动作 | +| **ATK-01** | 轻拳命中站立对手 | 伤害、HitStop、HitStun、推远与特效一致 | +| **BLK-01** | 站防高/中/低段 | 高/中成功,低段失败并进入受击 | +| **BLK-02** | 蹲防高/中/低段 | 中/低成功,高段失败并进入受击 | +| **HIT-01** | 双方同 Tick 普通攻击 | 按交易规则双方受击,日志顺序稳定 | +| **HIT-02** | 多 HurtBox 与一个 HitBox 重叠 | 只结算一次,命中点选择一致 | +| **SP-01** | 飞行道具命中/格挡/出界 | 三种路径均正确回收且无重复命中 | +| **SP-02** | 对空技无敌窗口 | 窗口内忽略指定攻击,窗口外正常受击 | +| **SU-01** | 满能量超杀 | 只扣一次资源;冻结、镜头、多段与终结正确 | +| **KO-01** | 同 Tick 生命归零与多段后续 | 只结算合法段;进入 KO 后无新攻击 | +| **REP-01** | 同输入回放 20 次 | 最终状态与中间哈希完全一致 | + +## 19.3 功能完成定义(Definition of Done) + +- 设计数据已填写且通过合法性校验,没有魔法数字散落在运行时代码。 + +- 功能可在 Debug HUD 中观察关键状态与 Tick。 + +- 至少有 1 个自动测试或固定回放覆盖主要逻辑。 + +- 键盘与至少一种手柄可用,重绑定后仍正常。 + +- 30/60/120 FPS 下结果一致;暂停、慢动作与 HitStop 不影响输入匹配。 + +- 不存在已知的状态卡死、重复命中、越界、资源重复扣除或 KO 后继续行动。 + +- 表现层缺失时战斗逻辑仍可完整运行;表现对象销毁不会破坏比赛。 + +> **发布门槛** +> +> 完整对战连续运行 30 分钟,无崩溃、无永久卡死、无明显输入丢失;核心回放集全部通过;关键路径战斗 Tick 在目标机器上满足性能预算。 + +**RISKS & APPENDIX** + +# 20 风险、原创边界与附录 + +## 20.1 主要风险与缓解 + +| **风险** | **表现** | **缓解措施** | +|------------------|------------------------------------------|------------------------------------------| +| **动画驱动逻辑** | 判定漏发、过渡不同步、改动画导致规则变化 | 逻辑帧表权威;动画只采样与展示 | +| **范围持续膨胀** | 投技、防反、联网、角色内容同时开工 | 严格按 M0–M6 验收门推进 | +| **输入手感差** | 指令过严或误触发,玩家无法稳定出招 | 输入录制、匹配日志、可配置宽容窗口 | +| **判定难调** | 动画好看但判定不稳定或不可读 | 独立 HitboxTrack 编辑器与帧步进 | +| **碰撞不确定** | 动态刚体、回调顺序导致同帧结果变化 | 运动学控制 + 收集后统一结算 | +| **表现反向侵入** | 镜头/特效脚本改生命或状态 | Domain / Presentation 事件边界与代码审查 | +| **内容制作成本** | 每个招式需要动作、判定、特效、音效 | 先做少量高辨识度招式,复用事件模板 | +| **知识产权风险** | 造型、动作、招式名和 UI 过度相似 | 建立原创设定与视觉语言;保留素材来源记录 | + +## 20.2 原创边界检查 + +- 不要直接使用现有格斗游戏的角色姓名、服装轮廓、招式名称、语音、UI、标志或特效图样。 + +- 通用机制如走、跳、格挡、飞行道具、对空、能量槽和超杀冻结可以采用,但表达方式应原创。 + +- 动作捕捉、音效、字体、模型与特效包需确认商业许可范围,并保存许可证与购买凭证。 + +- 宣传文案使用“2.5D 对战格斗”描述,不以他人商标作为产品名称或核心卖点。 + +## 附录 A:首个灰盒角色帧表示例 + +| **招式** | **输入** | **起/持/收** | **伤害** | **硬直 H/B** | **用途** | +|--------------|----------|------------------|----------|--------------|----------------------------| +| **站轻拳** | LP | 4 / 2 / 8 | 40 | 10 / 7 | 近身确认;命中可取消必杀 | +| **站重拳** | HP | 9 / 3 / 18 | 90 | 16 / 12 | 重击;部分帧可对空 | +| **站轻脚** | LK | 5 / 3 / 10 | 45 | 11 / 8 | 中距离试探 | +| **站重脚** | HK | 11 / 3 / 22 | 105 | 18 / 14 | 长距离;反击风险高 | +| **蹲轻脚** | 2+LK | 5 / 2 / 9 | 35 | 9 / 7 | 低段;可链轻拳 | +| **跳重脚** | 空中 HK | 7 / 5 / 着地6 | 80 | 14 / 11 | 跳入攻击 | +| **能量弹** | 236+P | 12 / 投射物 / 22 | 80 | 15 / 12 | 远程牵制;削血 8 | +| **对空升击** | 623+P | 5 / 6 / 28 | 110 | 击倒 / 15 | 1–6 Tick 上半身无敌 | +| **突进踢** | 214+K | 10 / 5 / 20 | 100 | 17 / 13 | 接近与连段收尾 | +| **超必杀** | 236236+P | 7 / 多段 / 35 | 300 | 击倒 / 20 | 消耗 100;起手冻结 10 Tick | + +*H/B = HitStun / BlockStun。所有数值仅为原型起点,不代表最终平衡。* + +## 附录 B:首轮实现清单 + +| **完成** | **任务** | **完成** | **任务** | +|----------|----------------------------------------|----------|----------------------------------------------| +| □ | BattleClock 与 60 Tick 主循环 | □ | InputBuffer 与方向相对化 | +| □ | CommandRecognizer 与输入日志 | □ | FighterStateMachine 与转移优先级 | +| □ | FighterMotor、跳跃、舞台边界与 PushBox | □ | MoveData、MoveTimeline 与取消窗口 | +| □ | HitBox/HurtBox 轨迹与调试显示 | □ | HitResolver、格挡、伤害、HitStop、硬直与击退 | +| □ | 飞行道具、对空技、突进技与超必杀 | □ | MatchFlow、HUD、KO、暂停与重开 | +| □ | 训练木桩、AI、输入回放与状态哈希 | □ | 动画、VFX、SFX、镜头与可访问性设置 | + +## 附录 C:项目启动时的 10 个决定 + +> **1.** 确认战斗是严格 2D 平面,还是允许 Z 轴闪避;本方案默认严格 2D。 +> +> **2.** 确认普通技数量与按钮数;本方案默认 4 攻击键。 +> +> **3.** 确认格挡采用背向方向,是否提供独立 Guard 辅助键。 +> +> **4.** 确认是否支持蹲姿与高低段;本方案建议首版支持。 +> +> **5.** 确认每角色必杀技与超杀数量;本方案为 3 + 1。 +> +> **6.** 确认是否有动态刚体需求;战斗角色默认不使用动态刚体。 +> +> **7.** 确认首版仅单机/本地对战,不在 MVP 中实现联网。 +> +> **8.** 确认动画与模型来源、许可和可用动作数量。 +> +> **9.** 确认调试工具必须随核心功能同步交付。 +> +> **10.** 确认第一可玩验收后再投入完整美术与角色内容。 + +> **最终建议** +> +> 先做一个角色镜像对战,完整打通“一次输入 → 一个动作 → 一个判定 → 一次命中/格挡 → 一次状态恢复 → 一次 KO”。这条链路稳定后,新增角色与招式才会变成可控的内容生产,而不是继续堆技术债。 diff --git a/Server/Server.Host.Tests/Fighter3DLogicTests.cs b/Server/Server.Host.Tests/Fighter3DLogicTests.cs new file mode 100644 index 00000000..5a08cca6 --- /dev/null +++ b/Server/Server.Host.Tests/Fighter3DLogicTests.cs @@ -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); + } + } +} diff --git a/Server/Server.Host.Tests/Fighter3DServerRoomTests.cs b/Server/Server.Host.Tests/Fighter3DServerRoomTests.cs new file mode 100644 index 00000000..a277cdab --- /dev/null +++ b/Server/Server.Host.Tests/Fighter3DServerRoomTests.cs @@ -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 Broadcasts = new List(); + public void Broadcast(NetMessage message) => Broadcasts.Add(message); + public void SendTo(int playerId, NetMessage message) { } + } + + private static readonly IReadOnlyList 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); + } + } +} diff --git a/Server/Server.Host.Tests/Server.Host.Tests.csproj b/Server/Server.Host.Tests/Server.Host.Tests.csproj index d5ed8262..476fe305 100644 --- a/Server/Server.Host.Tests/Server.Host.Tests.csproj +++ b/Server/Server.Host.Tests/Server.Host.Tests.csproj @@ -23,6 +23,8 @@ + + @@ -33,4 +35,4 @@ - \ No newline at end of file + diff --git a/Server/games-src/Fighter3D/Fighter3D.Core/Fighter3D.Core.csproj b/Server/games-src/Fighter3D/Fighter3D.Core/Fighter3D.Core.csproj new file mode 100644 index 00000000..6a089d81 --- /dev/null +++ b/Server/games-src/Fighter3D/Fighter3D.Core/Fighter3D.Core.csproj @@ -0,0 +1,18 @@ + + + netstandard2.1 + 9.0 + disable + Fighter3D.Core + Fighter3D.Core + false + + + + + + + false + + + diff --git a/Server/games-src/Fighter3D/Fighter3D.Server/Fighter3D.Server.csproj b/Server/games-src/Fighter3D/Fighter3D.Server/Fighter3D.Server.csproj new file mode 100644 index 00000000..1a1a84fe --- /dev/null +++ b/Server/games-src/Fighter3D/Fighter3D.Server/Fighter3D.Server.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + 9.0 + disable + Fighter3D.Server + Fighter3D.Server + + + + false + + + + diff --git a/Server/games-src/Fighter3D/Fighter3D.Server/FighterServerRoom.cs b/Server/games-src/Fighter3D/Fighter3D.Server/FighterServerRoom.cs new file mode 100644 index 00000000..a00b9f19 --- /dev/null +++ b/Server/games-src/Fighter3D/Fighter3D.Server/FighterServerRoom.cs @@ -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 _seatOfPlayer = new Dictionary(); + private readonly int[] _playerOfSeat = new int[2]; + private IRoomCtx _ctx; + private FighterState _state; + private bool _ended; + + public void OnRoomStart(IReadOnlyList 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) + }); + } + } +} diff --git a/deploy/minigame/fighter3d/6/Fighter3D.Core.dll b/deploy/minigame/fighter3d/6/Fighter3D.Core.dll new file mode 100644 index 00000000..6ea9ce63 Binary files /dev/null and b/deploy/minigame/fighter3d/6/Fighter3D.Core.dll differ diff --git a/deploy/minigame/fighter3d/6/Fighter3D.Server.dll b/deploy/minigame/fighter3d/6/Fighter3D.Server.dll new file mode 100644 index 00000000..b388458c Binary files /dev/null and b/deploy/minigame/fighter3d/6/Fighter3D.Server.dll differ diff --git a/deploy/minigame/fighter3d/6/files.txt b/deploy/minigame/fighter3d/6/files.txt new file mode 100644 index 00000000..4671058f --- /dev/null +++ b/deploy/minigame/fighter3d/6/files.txt @@ -0,0 +1,3 @@ +{'Fighter3D.Core.dll','7566025462c89bf1c540deb954ea3ac2',9728}, +{'Fighter3D.Server.dll','454f24ecbdcacb6dabb8c29d4187f51e',6656}, +{'game.json','8cfa51d2bdfbedc691e5b3cec106b88d',222}, diff --git a/deploy/minigame/fighter3d/6/files.txt.sig b/deploy/minigame/fighter3d/6/files.txt.sig new file mode 100644 index 00000000..e69de29b diff --git a/deploy/minigame/fighter3d/6/game.json b/deploy/minigame/fighter3d/6/game.json new file mode 100644 index 00000000..f2f70347 --- /dev/null +++ b/deploy/minigame/fighter3d/6/game.json @@ -0,0 +1,9 @@ +{ + "gameId": "fighter3d", + "version": 6, + "serverAssembly": "Fighter3D.Server.dll", + "serverEntryType": "Fighter3D.Server.FighterServerRoom", + "minFrameworkVersion": 1, + "playerCount": 2, + "tickRateHz": 20 +} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-10-fighter3d-common-actor-presentation.md b/docs/superpowers/plans/2026-08-10-fighter3d-common-actor-presentation.md new file mode 100644 index 00000000..c10d6bfa --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-fighter3d-common-actor-presentation.md @@ -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)`. + +- [ ] **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//pc/*` +- Output: `deploy/minigame/fighter3d//*` + +**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. diff --git a/docs/superpowers/plans/2026-08-10-fighter3d-networked-minigame.md b/docs/superpowers/plans/2026-08-10-fighter3d-networked-minigame.md new file mode 100644 index 00000000..cffe9fc0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-fighter3d-networked-minigame.md @@ -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` +- 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.