diff --git a/Assets/AddressableAssetsData/link.xml b/Assets/AddressableAssetsData/link.xml deleted file mode 100644 index 62ce7303..00000000 --- a/Assets/AddressableAssetsData/link.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Assets/AddressableAssetsData/link.xml.meta b/Assets/AddressableAssetsData/link.xml.meta deleted file mode 100644 index 02c2edd0..00000000 --- a/Assets/AddressableAssetsData/link.xml.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ac4733926d6c92346b70eaaaaac75d7c -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Resources/Achievements/20101_combo.asset b/Assets/Resources/Achievements/20101_combo.asset index 43538c5b..b5a76ec4 100644 --- a/Assets/Resources/Achievements/20101_combo.asset +++ b/Assets/Resources/Achievements/20101_combo.asset @@ -19,36 +19,36 @@ MonoBehaviour: levels: - levelName: "\u7B80\u5355\u5341\u8FDE" triggerValue: 10 - rarity: 0 description: "\u8FBE\u5230\u81F3\u5C11\u4E00\u6B2110\u8FDE\u51FB" icon: {fileID: 0} - - levelName: "\u7518\u4E94\u8BD5\u70BC" + rarity: 0 + - levelName: "\u5EFF\u4E94\u8BD5\u70BC" triggerValue: 25 - rarity: 1 description: "\u8FBE\u5230\u81F3\u5C11\u4E00\u6B2125\u8FDE\u51FB" icon: {fileID: 0} + rarity: 1 - levelName: "\u4E94\u5341\u5DF2\u5230" triggerValue: 50 - rarity: 2 description: "\u8FBE\u5230\u81F3\u5C11\u4E00\u6B2150\u8FDE\u51FB" icon: {fileID: 0} + rarity: 2 - levelName: "\u4E5D\u4E5D\u5F52\u4E00" triggerValue: 99 - rarity: 3 description: "\u8FBE\u5230\u81F3\u5C11\u4E00\u6B2199\u8FDE\u51FB" icon: {fileID: 0} + rarity: 3 - levelName: "\u53CC\u767E\u4E50\u7AE0" triggerValue: 200 - rarity: 4 description: "\u8FBE\u5230\u81F3\u5C11\u4E00\u6B21200\u8FDE\u51FB" icon: {fileID: 0} + rarity: 4 - levelName: "\u534A\u6B65\u5343\u673A" triggerValue: 500 - rarity: 5 description: "\u8FBE\u5230\u81F3\u5C11\u4E00\u6B21500\u8FDE\u51FB" icon: {fileID: 0} + rarity: 5 - levelName: "\u5343\u8FDE\u4E07\u82B1" triggerValue: 1000 - rarity: 6 description: "\u8FBE\u5230\u81F3\u5C11\u4E00\u6B211000\u8FDE\u51FB" icon: {fileID: 0} + rarity: 6 diff --git a/Assets/Resources/song_songIndex/1002002_arcstar.asset b/Assets/Resources/song_songIndex/1002002_arcstar.asset index cc096257..1421815e 100644 --- a/Assets/Resources/song_songIndex/1002002_arcstar.asset +++ b/Assets/Resources/song_songIndex/1002002_arcstar.asset @@ -108,6 +108,6 @@ MonoBehaviour: current_levelProgress: 0 _if_level_is_EASE: 0 max_comboRecord: 0 - thisLevel_selectedDifficultyID: 2 + thisLevel_selectedDifficultyID: 1 thisLevel_addDateString: 2025-12-28 thisLevel_overallDescription: "\u7D22\u5C3C\u5A05\u8BF4\uFF0C\u4E3A\u5979\u627E\u5230\u8BC1\u660E\u8FC7\u53BB\u771F\u5B9E\u5B58\u5728\u7684\u8BC1\u636E\uFF0C\u5979\u4FBF\u80FD\u8FD8\u539F\u4E00\u4E2A\u6765\u81EA\u8FC7\u53BB\u6240\u53D1\u751F\u8FC7\u7684\u6545\u4E8B\uFF0C\u53EF\u6211\u76EE\u524D\u8FD8\u672A\u80FD\u627E\u5230\u8FD9\u6240\u8C13\u7684\u201C\u8BC1\u636E\u201D......\u5B83\u771F\u7684\u5B58\u5728\u5417\uFF1F" diff --git a/Assets/Resources/song_songIndex/1002003_spectrum.asset b/Assets/Resources/song_songIndex/1002003_spectrum.asset index 5966a06b..a7a10101 100644 --- a/Assets/Resources/song_songIndex/1002003_spectrum.asset +++ b/Assets/Resources/song_songIndex/1002003_spectrum.asset @@ -108,6 +108,6 @@ MonoBehaviour: current_levelProgress: 0 _if_level_is_EASE: 0 max_comboRecord: 0 - thisLevel_selectedDifficultyID: 2 + thisLevel_selectedDifficultyID: 1 thisLevel_addDateString: 2026-01-01 thisLevel_overallDescription: "\u201C\u54D7\u5566\u5566......\u201D\n\u7834\u788E\u7684\u955C\u9762\u6620\u51FA\u5979\u7684\u53CC\u76EE\uFF0C\u5728\u90A3\u4E00\u523B\uFF0C\u89C6\u91CE\u91CC\u51FA\u73B0\u4E86\u591A\u4E2A\u4EE5\u5979\u4E3A\u540D\u7684\u5076\u50CF......" diff --git a/Assets/Resources/song_songIndex/1002004_smile.asset b/Assets/Resources/song_songIndex/1002004_smile.asset index c99fd0fa..174e09ea 100644 --- a/Assets/Resources/song_songIndex/1002004_smile.asset +++ b/Assets/Resources/song_songIndex/1002004_smile.asset @@ -108,6 +108,6 @@ MonoBehaviour: current_levelProgress: 0 _if_level_is_EASE: 0 max_comboRecord: 0 - thisLevel_selectedDifficultyID: 2 + thisLevel_selectedDifficultyID: 1 thisLevel_addDateString: thisLevel_overallDescription: "\u201C\u53F2\u8BD7\u8FDC\u5F81\u201D\u8D5B\u4E8B\u7684\u5F00\u573A\u5E18\u5E55\u88AB\u7F13\u7F13\u62C9\u5F00\uFF0C\u5973\u5B69\u5750\u5728\u8282\u8282\u76F8\u6263\u7684\u9F7F\u8F6E\u8FB9\u4E0A\u3002\n\u800C\u5728\u5979\u7684\u773C\u4E0B\uFF0C\u4FBF\u662F\u65E9\u5DF2\u6253\u5F97\u70ED\u706B\u671D\u5929\u7684\u8D5B\u573A\u3002" diff --git a/Assets/Resources/song_songIndex/1002005_BaboonBabbler.asset b/Assets/Resources/song_songIndex/1002005_BaboonBabbler.asset index 32bd380c..737d7091 100644 --- a/Assets/Resources/song_songIndex/1002005_BaboonBabbler.asset +++ b/Assets/Resources/song_songIndex/1002005_BaboonBabbler.asset @@ -108,6 +108,6 @@ MonoBehaviour: current_levelProgress: 0 _if_level_is_EASE: 0 max_comboRecord: 0 - thisLevel_selectedDifficultyID: 2 + thisLevel_selectedDifficultyID: 1 thisLevel_addDateString: thisLevel_overallDescription: "\u201C\u653E\u773C\u6574\u4E2A\u57CE\u5357\uFF0C\u5C31\u53EA\u6709\u8FD9\u5EA7\u9152\u9986\u7684\u5473\u513F\u6B63\uFF01\u8BA9\u6211\u60F3\u8D77\u4E86\u5BB6\u4E61\u90A3\u526F......\u201D\n\u201C\u6765\u8FD9\u53EF\u522B\u5149\u60E6\u8BB0\u7740\u9152\uFF01\u8FD8\u6709\u6F14\u51FA\u80FD\u770B\u5462\uFF01\u201D\n\u201C\u4EC0\u4E48\u6F14\u51FA\uFF1F\u201D" diff --git a/Assets/Resources/song_songIndex/1002006.asset b/Assets/Resources/song_songIndex/1002006.asset index ebb3daa3..6787770a 100644 --- a/Assets/Resources/song_songIndex/1002006.asset +++ b/Assets/Resources/song_songIndex/1002006.asset @@ -108,6 +108,6 @@ MonoBehaviour: current_levelProgress: 0 _if_level_is_EASE: 0 max_comboRecord: 0 - thisLevel_selectedDifficultyID: 2 + thisLevel_selectedDifficultyID: 1 thisLevel_addDateString: 2026-01-01 thisLevel_overallDescription: "\u201C\u4E3A\u4E86\u62A5\u7B54\u4F60\u521A\u521A\u544A\u8BC9\u6211\u8FD9\u4E48\u4E00\u4E2A\u5C0F\u9053\u6D88\u606F\uFF0C\u6211\u53EF\u4EE5\u6EE1\u8DB3\u4F60\u4E00\u4E2A\u613F\u671B\u54E6......\u201D\n\u4ED6\u773C\u7741\u7741\u5730\u770B\u7740\u8FD9\u4E2A\u5973\u4EBA\u5468\u56F4\u7684\u73AF\u5883\u53D8\u5F97\u9EEF\u6DE1\u8D77\u6765\uFF0C\u56E0\u4E3A\u8FD9\u662F\u5728\u573A\u5730\u5185\uFF0C\u6240\u4EE5\u8FD9\u79CD\u73B0\u8C61\u4E0D\u8DB3\u4E3A\u5947\u3002\n\u201C\u771F\u7684\uFF1F\u4EC0\u4E48\u90FD\u53EF\u4EE5\u5417\uFF1F\u201D\n\u201C\u55EF\u54FC\uFF1F\u201D\n\u201C\u628A\u4F60\u5403\u4E86\u8FD9\u987F\u996D\u7684\u996D\u94B1\u4ED8\u4E86\uFF0C\u7136\u540E\u6536\u62FE\u5B8C\u4F60\u7684\u4E1C\u897F\u8D76\u7D27\u8D70\u4EBA\uFF01\u201D" diff --git a/Assets/Scenes/Songs_Select.unity b/Assets/Scenes/Songs_Select.unity index 9c8540af..ef55fb6a 100644 --- a/Assets/Scenes/Songs_Select.unity +++ b/Assets/Scenes/Songs_Select.unity @@ -1516,7 +1516,7 @@ RectTransform: m_Father: {fileID: 1819748244} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} - m_AnchorMax: {x: 1, y: 0.500001} + m_AnchorMax: {x: 1, y: 1} m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 20, y: 20} m_Pivot: {x: 0.5, y: 0.5} @@ -9508,6 +9508,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: targetAspect: 1.7777778 + lockAspect: 0 --- !u!1 &786652190 GameObject: m_ObjectHideFlags: 0 @@ -11993,8 +11994,8 @@ MonoBehaviour: m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3} - m_Name: - m_EditorClassIdentifier: + m_Name: + m_EditorClassIdentifier: m_UiScaleMode: 1 m_ReferencePixelsPerUnit: 100 m_ScaleFactor: 1 @@ -12392,7 +12393,7 @@ MonoBehaviour: m_HandleRect: {fileID: 104269912} m_Direction: 2 m_Value: 0 - m_Size: 0.500001 + m_Size: 1 m_NumberOfSteps: 0 m_OnValueChanged: m_PersistentCalls: diff --git a/Assets/Scenes/gamePlay_gamePlay.unity b/Assets/Scenes/gamePlay_gamePlay.unity index ba3561d2..84ece285 100644 --- a/Assets/Scenes/gamePlay_gamePlay.unity +++ b/Assets/Scenes/gamePlay_gamePlay.unity @@ -785,7 +785,7 @@ BoxCollider2D: drawMode: 0 adaptiveTiling: 0 m_AutoTiling: 0 - m_Size: {x: 2.3, y: 1.2} + m_Size: {x: 2.3, y: 2} m_EdgeRadius: 0 --- !u!212 &16261627 SpriteRenderer: @@ -4009,7 +4009,7 @@ BoxCollider2D: drawMode: 0 adaptiveTiling: 0 m_AutoTiling: 0 - m_Size: {x: 2.3, y: 1.2} + m_Size: {x: 2.3, y: 2} m_EdgeRadius: 0 --- !u!212 &165168690 SpriteRenderer: @@ -9988,7 +9988,7 @@ RectTransform: m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} m_AnchoredPosition: {x: 497, y: -617.9} - m_SizeDelta: {x: 2250, y: 2250} + m_SizeDelta: {x: 3000, y: 3000} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &205333302 CanvasRenderer: @@ -15816,11 +15816,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 96.525 + value: 95.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 95.85 + value: 96.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -15836,7 +15836,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -15936,11 +15936,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 8.1 + value: 8 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -16392,7 +16392,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!114 &359521024 MonoBehaviour: m_ObjectHideFlags: 0 @@ -16508,7 +16508,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 740.2, y: -37.799988} + m_AnchoredPosition: {x: 740.19995, y: -37.80005} m_SizeDelta: {x: 200, y: 50} m_Pivot: {x: 0.5, y: 0.5} --- !u!1 &360640317 @@ -18217,6 +18217,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -18412,7 +18413,8 @@ MonoBehaviour: - {fileID: 359012520} - {fileID: 1102374847} spawnOffset: 0 - static_value_add_to_spawnoffset: 0 + static_value_add_to_spawnoffset: 0.0325 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 1 syncNotePrefab: {fileID: 1500140235766093525, guid: fda9079aef7bc2d4385761036bedce3b, type: 3} @@ -18744,6 +18746,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -23403,7 +23406,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!114 &594748193 MonoBehaviour: m_ObjectHideFlags: 0 @@ -24680,7 +24683,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 0 + m_IsActive: 1 --- !u!4 &639796450 Transform: m_ObjectHideFlags: 0 @@ -31600,7 +31603,7 @@ RectTransform: 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: 918, y: -560} + m_AnchoredPosition: {x: 918, y: -907.6} m_SizeDelta: {x: 6363.9, y: 2641.989} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &701909119 @@ -32296,11 +32299,11 @@ MonoBehaviour: m_Content: {fileID: 1181265788} m_Horizontal: 0 m_Vertical: 1 - m_MovementType: 1 + m_MovementType: 2 m_Elasticity: 0.1 m_Inertia: 1 m_DecelerationRate: 0.2 - m_ScrollSensitivity: 25 + m_ScrollSensitivity: 10 m_Viewport: {fileID: 1658081544} m_HorizontalScrollbar: {fileID: 985131518} m_VerticalScrollbar: {fileID: 370051276} @@ -32367,11 +32370,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 96.525 + value: 95.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 95.85 + value: 96.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -32387,7 +32390,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -32487,11 +32490,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 8.1 + value: 8 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -34383,7 +34386,7 @@ BoxCollider2D: drawMode: 0 adaptiveTiling: 0 m_AutoTiling: 0 - m_Size: {x: 2.3, y: 1.2} + m_Size: {x: 2.3, y: 2} m_EdgeRadius: 0 --- !u!212 &801049738 SpriteRenderer: @@ -37315,7 +37318,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!114 &831755757 MonoBehaviour: m_ObjectHideFlags: 0 @@ -38870,6 +38873,7 @@ MonoBehaviour: earlyHitCount_Text: {fileID: 0} lateHitCount_Text: {fileID: 0} avgOffset_Text: {fileID: 0} + offsetSuggestion_Text: {fileID: 0} settlementAudioSource: {fileID: 1454903927} oriGameMusicSource: {fileID: 845021791} gameMusicMixer: {fileID: 24100000, guid: 1ec6b149cf654494d8d97065d8f2ccac, type: 2} @@ -39975,12 +39979,12 @@ MonoBehaviour: - {fileID: 1938112561} - {fileID: 1090508766} poolSize: 12 - maxPoolSize: 100 - holdSegmentPoolMultiplier: 6 + maxPoolSize: 280 + holdSegmentPoolMultiplier: 22 holdEndPoolMultiplier: 6 verboseLogging: 0 prewarmOnStart: 1 - prewarmPerFrame: 5 + prewarmPerFrame: 60 --- !u!4 &924692021 Transform: m_ObjectHideFlags: 0 @@ -41490,8 +41494,8 @@ MonoBehaviour: m_TargetGraphic: {fileID: 1501988772} m_HandleRect: {fileID: 1501988771} m_Direction: 0 - m_Value: -0.11386139 - m_Size: 0.9999692 + m_Value: -0.109836064 + m_Size: 0.99996895 m_NumberOfSteps: 0 m_OnValueChanged: m_PersistentCalls: @@ -44928,6 +44932,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -47506,11 +47511,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 96.525 + value: 95.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 95.85 + value: 96.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -47526,7 +47531,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -47626,11 +47631,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 8.1 + value: 8 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -53756,7 +53761,7 @@ BoxCollider2D: drawMode: 0 adaptiveTiling: 0 m_AutoTiling: 0 - m_Size: {x: 2.3, y: 1.2} + m_Size: {x: 2.3, y: 2} m_EdgeRadius: 0 --- !u!212 &1369160199 SpriteRenderer: @@ -53908,6 +53913,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -56277,7 +56283,7 @@ MonoBehaviour: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1460982758} - m_Enabled: 1 + m_Enabled: 0 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} m_Name: @@ -56808,7 +56814,7 @@ BoxCollider2D: drawMode: 0 adaptiveTiling: 0 m_AutoTiling: 0 - m_Size: {x: 2.3, y: 1.2} + m_Size: {x: 2.3, y: 2} m_EdgeRadius: 0 --- !u!212 &1475663032 SpriteRenderer: @@ -57201,6 +57207,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -59534,11 +59541,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 96.525 + value: 95.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 95.85 + value: 96.5 objectReference: {fileID: 0} - target: {fileID: 682261594771255014, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -59554,7 +59561,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 4266895856353985475, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -59654,11 +59661,11 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_SizeDelta.x - value: 8.1 + value: 8 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.x - value: 87.75 + value: 88.5 objectReference: {fileID: 0} - target: {fileID: 6759631360171910247, guid: 7e80d4de8ab2ff04a9bf1c54e5a064b1, type: 3} propertyPath: m_AnchoredPosition.y @@ -59773,6 +59780,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -61812,7 +61820,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!114 &1646580874 MonoBehaviour: m_ObjectHideFlags: 0 @@ -64403,8 +64411,8 @@ RectTransform: 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: -69.2, y: 88.5} - m_SizeDelta: {x: 148.87, y: 30} + m_AnchoredPosition: {x: 43.282, y: 88.5} + m_SizeDelta: {x: 373.832, y: 30} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &1752589410 MonoBehaviour: @@ -69237,6 +69245,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -72146,7 +72155,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!114 &2055240846 MonoBehaviour: m_ObjectHideFlags: 0 @@ -72831,6 +72840,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -73102,7 +73112,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!114 &2085829911 MonoBehaviour: m_ObjectHideFlags: 0 @@ -74215,6 +74225,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -75678,6 +75689,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -76679,6 +76691,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -77491,6 +77504,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -78129,6 +78143,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -78400,6 +78415,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} @@ -80844,6 +80860,7 @@ MonoBehaviour: holdNoteEndPrefabs: [] spawnOffset: 0.1 static_value_add_to_spawnoffset: 0 + visualPreSpawnTime: 0 globalHitDelay: 0 enableSyncNotePrefab: 0 syncNotePrefab: {fileID: 0} diff --git a/Assets/__UI_NEW/pregameLook/ui_pbr_characterdetails_1.png.meta b/Assets/__UI_NEW/pregameLook/ui_pbr_characterdetails_1.png.meta index 89b3ff06..a2ab7f46 100644 --- a/Assets/__UI_NEW/pregameLook/ui_pbr_characterdetails_1.png.meta +++ b/Assets/__UI_NEW/pregameLook/ui_pbr_characterdetails_1.png.meta @@ -52,7 +52,7 @@ TextureImporter: alignment: 0 spritePivot: {x: 0.5, y: 0.5} spritePixelsToUnits: 100 - spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteBorder: {x: 10, y: 4, z: 10, w: 3} spriteGenerateFallbackPhysicsShape: 1 alphaUsage: 1 alphaIsTransparency: 1 @@ -121,7 +121,7 @@ TextureImporter: width: 225 height: 23 alignment: 0 - pivot: {x: 0, y: 0} + pivot: {x: 0.5, y: 0.5} border: {x: 0, y: 0, z: 0, w: 0} customData: outline: [] @@ -139,7 +139,7 @@ TextureImporter: physicsShape: [] bones: [] spriteID: 5e97eb03825dee720800000000000000 - internalID: 0 + internalID: 1537655665 vertices: [] indices: edges: [] diff --git a/Assets/_eqpmtSys/EquipmentSkillGroupLibrary.cs b/Assets/_eqpmtSys/EquipmentSkillGroupLibrary.cs index 6cbe54a7..251ffd7b 100644 --- a/Assets/_eqpmtSys/EquipmentSkillGroupLibrary.cs +++ b/Assets/_eqpmtSys/EquipmentSkillGroupLibrary.cs @@ -65,6 +65,31 @@ public static class EquipmentSkillGroupLibrary return asset != null ? asset.skillGroup : null; } + // --- Runtime cache ------------------------------------------------------- + // Equipment skill-group assets are static config loaded from Resources and never + // mutated at runtime, so both the per-category asset list and the id->asset resolution + // are memoized during play mode. This turns a per-note/per-trigger Resources.LoadAll + + // collection-allocation storm into O(1) dictionary lookups. The editor (!isPlaying) + // path stays uncached so asset edits are always reflected; the cache is cleared on + // play-mode start via RuntimeInitializeOnLoadMethod so a fresh session never sees stale + // entries from a prior play session (domain-reload-disabled projects). + private static readonly Dictionary> s_assetsByCategory = + new Dictionary>(); + // Keyed by (category, skillGroupId); caches both hits and misses (null value = confirmed miss). + private static readonly Dictionary s_findAssetCache = new Dictionary(); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetRuntimeCache() + { + s_assetsByCategory.Clear(); + s_findAssetCache.Clear(); + } + + private static long MakeFindKey(int skillGroupId, EquipmentSkillGroupSO.EquipmentSkillCategory category) + { + return ((long)category << 32) | (uint)skillGroupId; + } + public static EquipmentSkillGroupSO FindAsset(int skillGroupId, EquipmentSkillGroupSO.EquipmentSkillCategory category) { if (skillGroupId <= 0) @@ -72,7 +97,19 @@ public static class EquipmentSkillGroupLibrary return null; } + bool useCache = Application.isPlaying; + long findKey = 0; + if (useCache) + { + findKey = MakeFindKey(skillGroupId, category); + if (s_findAssetCache.TryGetValue(findKey, out EquipmentSkillGroupSO cached)) + { + return cached; + } + } + var assets = LoadAssets(category); + EquipmentSkillGroupSO resolved = null; for (int i = 0; i < assets.Count; i++) { EquipmentSkillGroupSO asset = assets[i]; @@ -83,15 +120,28 @@ public static class EquipmentSkillGroupLibrary if (asset.skillGroup.skillGroupID == skillGroupId) { - return asset; + resolved = asset; + break; } } - return null; + if (useCache) + { + // Cache hits AND misses (null) so repeated unmatched ids don't re-scan. + s_findAssetCache[findKey] = resolved; + } + + return resolved; } public static List LoadAssets(EquipmentSkillGroupSO.EquipmentSkillCategory category) { + // Play mode: build the per-category list once (from Resources) and reuse it. + if (Application.isPlaying && s_assetsByCategory.TryGetValue(category, out var cachedList)) + { + return cachedList; + } + var results = new List(); #if UNITY_EDITOR @@ -146,6 +196,11 @@ public static class EquipmentSkillGroupLibrary } } + if (Application.isPlaying) + { + s_assetsByCategory[category] = results; + } + return results; } } diff --git a/Assets/_eqpmtSys/equipSystems/eIllusion/equipIllusion.cs b/Assets/_eqpmtSys/equipSystems/eIllusion/equipIllusion.cs index e92afe03..60d55e39 100644 --- a/Assets/_eqpmtSys/equipSystems/eIllusion/equipIllusion.cs +++ b/Assets/_eqpmtSys/equipSystems/eIllusion/equipIllusion.cs @@ -465,6 +465,9 @@ public class equipIllusion : MonoBehaviour ApplyTourEffect(); eqp_p_SO.level = Mathf.Clamp(eqp_p_SO.level + 1, 0, 20); PersistEquipment(eqp_p_SO); + // ApplyTourEffect 可能原地写入 ia_skillID(装备 SO 引用不变), + // 若该装备正被某英雄装备,其有效技能组缓存需失效重建。 + AllyHero_SO.InvalidateEquipmentDerivedCaches(); RefreshAllEquipBags(); RefreshAll(); } diff --git a/Assets/_eqpmtSys/equipSystems/eTransfer/equipTransfer.cs b/Assets/_eqpmtSys/equipSystems/eTransfer/equipTransfer.cs index 3fb309ba..def2476e 100644 --- a/Assets/_eqpmtSys/equipSystems/eTransfer/equipTransfer.cs +++ b/Assets/_eqpmtSys/equipSystems/eTransfer/equipTransfer.cs @@ -566,6 +566,10 @@ public class equipTransfer : MonoBehaviour eqp_p_SO.ia_skillID = Mathf.Max(0, eqp_need_SO.ia_skillID); } + // 原地修改了装备的 sa_skillID/ia_skillID(装备 SO 引用不变), + // 若该装备正被某英雄装备,其有效技能组缓存需失效重建。 + AllyHero_SO.InvalidateEquipmentDerivedCaches(); + PersistEquipment(eqp_p_SO); DeleteEquipment(eqp_need_SO); eqp_need_SO = null; diff --git a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_blue.prefab b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_blue.prefab index 72b92bcc..517f87c9 100644 --- a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_blue.prefab +++ b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_blue.prefab @@ -112,10 +112,10 @@ SpriteRenderer: m_Color: {r: 0.4669811, g: 0.7509945, b: 1, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 @@ -200,10 +200,10 @@ SpriteRenderer: m_Color: {r: 1, g: 1, b: 1, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 diff --git a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_green.prefab b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_green.prefab index 5fe65da8..2ce287d6 100644 --- a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_green.prefab +++ b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_green.prefab @@ -81,10 +81,10 @@ SpriteRenderer: m_Color: {r: 1, g: 1, b: 1, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 @@ -200,10 +200,10 @@ SpriteRenderer: m_Color: {r: 0.30793875, g: 0.8301887, b: 0.19188325, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 diff --git a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_purple.prefab b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_purple.prefab index 45a03c6d..65809e46 100644 --- a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_purple.prefab +++ b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_purple.prefab @@ -81,10 +81,10 @@ SpriteRenderer: m_Color: {r: 1, g: 1, b: 1, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 @@ -200,10 +200,10 @@ SpriteRenderer: m_Color: {r: 1, g: 0.6650944, b: 0.8679015, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 diff --git a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_red.prefab b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_red.prefab index 1eff26f0..6fdd7bcf 100644 --- a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_red.prefab +++ b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_red.prefab @@ -81,10 +81,10 @@ SpriteRenderer: m_Color: {r: 1, g: 1, b: 1, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 @@ -168,10 +168,10 @@ SpriteRenderer: m_Color: {r: 1, g: 0.6650944, b: 0.6650944, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 diff --git a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_yellow.prefab b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_yellow.prefab index 72785eb4..1e2ef2e4 100644 --- a/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_yellow.prefab +++ b/Assets/artworks/gamePlay_gamePlay/notes/emptyNotes/note_empty_yellow.prefab @@ -80,10 +80,10 @@ SpriteRenderer: m_Color: {r: 1, g: 0.8679245, b: 0.504717, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 @@ -200,10 +200,10 @@ SpriteRenderer: m_Color: {r: 1, g: 1, b: 1, a: 1} m_FlipX: 0 m_FlipY: 0 - m_DrawMode: 0 + m_DrawMode: 1 m_Size: {x: 2.07, y: 0.9} m_AdaptiveModeThreshold: 0.5 - m_SpriteTileMode: 0 + m_SpriteTileMode: 1 m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 diff --git a/Assets/artworks/selectYourSongFirst/heroes_level_image/loadSkillsSelect.cs b/Assets/artworks/selectYourSongFirst/heroes_level_image/loadSkillsSelect.cs index 3fe0edaa..8ee8a3fd 100644 --- a/Assets/artworks/selectYourSongFirst/heroes_level_image/loadSkillsSelect.cs +++ b/Assets/artworks/selectYourSongFirst/heroes_level_image/loadSkillsSelect.cs @@ -119,6 +119,8 @@ public class loadSkillsSelect : MonoBehaviour return; } + hero.ValidateEquippedSkillSelections(); + List groupedSkills = hero.skillGroups == null ? new List() : hero.skillGroups.Where(group => group != null).ToList(); @@ -439,6 +441,8 @@ public class loadSkillsSelect : MonoBehaviour return; } + currentHeroSO.ValidateEquippedSkillSelections(); + HashSet equippedIds = new HashSet((currentHeroSO.equippedSkillGroupIDs ?? new int[0]).Where(id => id != 0)); for (int i = 0; i < spawnedSlots.Count; i++) { diff --git a/Assets/artworks/selectYourSongFirst/heroes_level_image/load_skill_detail_prefab_inHeroDetailPrefab.cs b/Assets/artworks/selectYourSongFirst/heroes_level_image/load_skill_detail_prefab_inHeroDetailPrefab.cs index 4ebac0be..c0cd2221 100644 --- a/Assets/artworks/selectYourSongFirst/heroes_level_image/load_skill_detail_prefab_inHeroDetailPrefab.cs +++ b/Assets/artworks/selectYourSongFirst/heroes_level_image/load_skill_detail_prefab_inHeroDetailPrefab.cs @@ -105,9 +105,10 @@ public class load_skill_detail_prefab_inHeroDetailPrefab : MonoBehaviour return; } + found.ValidateEquippedSkillSelections(); + var equippedIds = (found.equippedSkillGroupIDs ?? new int[0]).Where(id => id != 0).ToList(); - var levelInfo = AllyHeroLevelRuntimeResolver.GetBaseLevelInfo(found); - int currentMaxSlots = levelInfo?.skill_slot_limited ?? 0; + int currentMaxSlots = AllyHeroLevelRuntimeResolver.GetEffectiveSkillSlotLimit(found); int maxSlotsOverall = 0; if (found.levelStats != null && found.levelStats.Count > 0) diff --git a/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_heroSlots.cs b/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_heroSlots.cs index 8d9cdd43..241c3f06 100644 --- a/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_heroSlots.cs +++ b/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_heroSlots.cs @@ -724,7 +724,15 @@ public class slots_heroSlots : MonoBehaviour, IDropHandler, IBeginDragHandler, I // Find the AllyHero_SO AllyHero_SO hero = FindHeroById(heroSlot_heroID); - if (hero == null || hero.equippedSkillGroupIDs == null) + if (hero == null) + { + RestoreLayoutState(layout, hadLayout); + return; + } + + hero.ValidateEquippedSkillSelections(); + + if (hero.equippedSkillGroupIDs == null) { RestoreLayoutState(layout, hadLayout); return; diff --git a/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_skillSlots.cs b/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_skillSlots.cs index 824c7a2b..a9b24507 100644 --- a/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_skillSlots.cs +++ b/Assets/artworks/selectYourSongFirst/heroes_level_image/slots_skillSlots.cs @@ -157,13 +157,13 @@ public class slots_skillSlots : MonoBehaviour, IPointerClickHandler, IPointerEnt SkillGroup skillGroup = so.GetSkillGroupByID(skillCard_skillID); var levelInfo = AllyHeroLevelRuntimeResolver.GetBaseLevelInfo(so); int currentLevel = levelInfo?.levelID ?? 0; - int requiredLevel = skillGroup?.thisSkill_levelLimit ?? 1; + int requiredLevel = Mathf.Clamp(skillGroup?.thisSkill_levelLimit ?? 1, 1, 4); if (currentLevel < requiredLevel) { return SkillVisualState.Locked; } - int maxSlots = levelInfo?.skill_slot_limited ?? int.MaxValue; + int maxSlots = so != null ? AllyHeroLevelRuntimeResolver.GetEffectiveSkillSlotLimit(so) : int.MaxValue; int currentEquipped = so.equippedSkillGroupIDs != null ? so.equippedSkillGroupIDs.Where(id => id != 0).Count() : 0; if (currentEquipped >= maxSlots) { @@ -192,8 +192,10 @@ public class slots_skillSlots : MonoBehaviour, IPointerClickHandler, IPointerEnt { // Documentation text normalized. var levelInfo = AllyHeroLevelRuntimeResolver.GetBaseLevelInfo(so); - int maxSlots = levelInfo?.skill_slot_limited ?? int.MaxValue; // Documentation text normalized. - int currentEquipped = so?.equippedSkillGroupIDs.Where(id => id != 0).Count() ?? 0; + int maxSlots = so != null ? AllyHeroLevelRuntimeResolver.GetEffectiveSkillSlotLimit(so) : int.MaxValue; // Documentation text normalized. + int currentEquipped = so != null + ? (so.equippedSkillGroupIDs ?? new int[0]).Where(id => id != 0).Count() + : 0; if (currentEquipped >= maxSlots) { @@ -203,7 +205,7 @@ public class slots_skillSlots : MonoBehaviour, IPointerClickHandler, IPointerEnt // Documentation text normalized. var skillGroup = so?.GetSkillGroupByID(skillCard_skillID); - int requiredLevel = skillGroup?.thisSkill_levelLimit ?? 1; + int requiredLevel = Mathf.Clamp(skillGroup?.thisSkill_levelLimit ?? 1, 1, 4); int currentLevel = levelInfo?.levelID ?? 0; if (currentLevel < requiredLevel) @@ -221,7 +223,7 @@ public class slots_skillSlots : MonoBehaviour, IPointerClickHandler, IPointerEnt if (so != null) { - var list = new System.Collections.Generic.List(so.equippedSkillGroupIDs); + var list = new System.Collections.Generic.List(so.equippedSkillGroupIDs ?? new int[0]); if (isSelected) { if (!list.Contains(skillCard_skillID)) @@ -308,9 +310,9 @@ public class slots_skillSlots : MonoBehaviour, IPointerClickHandler, IPointerEnt // Documentation text normalized. string status; var levelInfo = AllyHeroLevelRuntimeResolver.GetBaseLevelInfo(hero); - int maxSlots = levelInfo?.skill_slot_limited ?? int.MaxValue; - int currentEquipped = hero.equippedSkillGroupIDs.Where(id => id != 0).Count(); - int requiredLevel = group.thisSkill_levelLimit; + int maxSlots = AllyHeroLevelRuntimeResolver.GetEffectiveSkillSlotLimit(hero); + int currentEquipped = (hero.equippedSkillGroupIDs ?? new int[0]).Where(id => id != 0).Count(); + int requiredLevel = Mathf.Clamp(group.thisSkill_levelLimit, 1, 4); int currentLevel = levelInfo?.levelID ?? 0; if (currentLevel < requiredLevel) diff --git a/Assets/delayTapperPrefab/delayTapperPrefab.cs b/Assets/delayTapperPrefab/delayTapperPrefab.cs index b6618b92..4f59cc3c 100644 --- a/Assets/delayTapperPrefab/delayTapperPrefab.cs +++ b/Assets/delayTapperPrefab/delayTapperPrefab.cs @@ -2,9 +2,11 @@ using UnityEngine; using UnityEngine.UI; using System.Collections; using System.Collections.Generic; +using System.Linq; using TMPro; using Bansonic; using UnityEngine.EventSystems; +using UnityEngine.InputSystem; public class delayTapperPrefab : MonoBehaviour { @@ -43,8 +45,14 @@ public class delayTapperPrefab : MonoBehaviour [Header("Tapper Stats")] [SerializeField] public List tapValueOffsets = new List(); - private float totalOffsetSum = 0f; // 用于计算平均值的累计偏移 - private int tapCount = 0; // 累计点击次数 + // 每次点击相对最近拍点的原始偏移(秒)。用于最终的中位数 + 去异常值统计, + // 取代旧版"累计平均"(平均会被漏拍/误触严重拉偏且会随点击次数稀释)。 + private readonly List rawOffsetSamples = new List(); + + [Header("Audio Offset UI (device latency knob)")] + [Tooltip("音频偏移输入框(ms):只移动音乐播放时刻补偿设备/蓝牙延迟,绝不改判定。与输入校准偏移独立。")] + public TMP_InputField audioOffsetInput; + public Button audioOffsetSaveButton; [SerializeField] private int BPM = 120; @@ -54,10 +62,18 @@ public class delayTapperPrefab : MonoBehaviour private bool isAudioReady = false; private bool isPaused = false; private bool isRunning = false; + + // dspTime-based clock. currentElapsed is derived from AudioSettings.dspTime relative to + // the cycle's start dsp, NOT accumulated Time.deltaTime — this matches the game's + // GameplayClock and the audio thread exactly, removing frame-rate drift. + private double cycleStartDsp = 0d; // dspTime at which the current beat cycle began + private double pausedAtDsp = 0d; // dspTime captured when paused + private double accumulatedPauseDsp = 0d; // total paused duration within the current cycle private float currentElapsed = 0f; private Dictionary originalMuteStates = new Dictionary(); private const string DELAY_PREFS_KEY = "UserGlobalDelaySeconds"; + private const string AUDIO_OFFSET_PREFS_KEY = "UserAudioOffsetSeconds"; // = GameManager.AudioOffsetPrefsKey private void Awake() { @@ -146,6 +162,8 @@ public class delayTapperPrefab : MonoBehaviour toggle_extra_ui.onClick.AddListener(OnToggleExtraUIClicked); } + SetupAudioOffsetUI(); + // 开始加载音频 StartCoroutine(PrepareAudioAndEnableButton()); @@ -153,6 +171,76 @@ public class delayTapperPrefab : MonoBehaviour MuteOtherAudioSources(); } + /// + /// 音频偏移(设备延迟补偿)独立于输入校准偏移:它只移动音乐播放时刻(GameManager 读取 + /// UserAudioOffsetSeconds),绝不改判定。此处提供一个独立输入框+保存按钮,读写该 PlayerPrefs。 + /// + private void SetupAudioOffsetUI() + { + if (audioOffsetInput != null) + { + float savedAudioOffset = PlayerPrefs.GetFloat(AUDIO_OFFSET_PREFS_KEY, 0f); + isInternalSetting = true; + audioOffsetInput.text = Mathf.RoundToInt(savedAudioOffset * 1000f).ToString(); + isInternalSetting = false; + + audioOffsetInput.onEndEdit.RemoveListener(OnAudioOffsetEndEdit); + audioOffsetInput.onEndEdit.AddListener(OnAudioOffsetEndEdit); + } + + if (audioOffsetSaveButton != null) + { + audioOffsetSaveButton.onClick.RemoveListener(OnAudioOffsetSaveClicked); + audioOffsetSaveButton.onClick.AddListener(OnAudioOffsetSaveClicked); + } + } + + private void OnAudioOffsetEndEdit(string input) + { + if (isInternalSetting) return; + + if (int.TryParse(input, out int value)) + { + if (value < -1000 || value > 1000) + { + int clamped = Mathf.Clamp(value, -1000, 1000); + gNotice.warning.display($"音频偏移超出范围 (-1000~1000),已重置为 {clamped}"); + isInternalSetting = true; + audioOffsetInput.text = clamped.ToString(); + isInternalSetting = false; + } + } + else + { + gNotice.warning.display("非法的音频偏移输入,已重置为 0"); + isInternalSetting = true; + audioOffsetInput.text = "0"; + isInternalSetting = false; + } + } + + private void OnAudioOffsetSaveClicked() + { + if (audioOffsetInput != null && int.TryParse(audioOffsetInput.text, out int msValue)) + { + int clamped = Mathf.Clamp(msValue, -1000, 1000); + float seconds = clamped / 1000f; + PlayerPrefs.SetFloat(AUDIO_OFFSET_PREFS_KEY, seconds); + PlayerPrefs.Save(); + gNotice.warning.display($"已保存音频偏移: {clamped}ms ({seconds:F3}s)"); + Debug.Log($"[delayTapperPrefab] Saved audio offset to PlayerPrefs: {seconds}s"); + } + else + { + gNotice.warning.display("音频偏移无效,未保存"); + } + + if (EventSystem.current != null) + { + EventSystem.current.SetSelectedGameObject(null); + } + } + private void OnDestroy() { // 销毁时恢复音频源状态 @@ -208,6 +296,8 @@ public class delayTapperPrefab : MonoBehaviour isRunning = false; isPaused = false; currentElapsed = 0f; + accumulatedPauseDsp = 0d; + pausedAtDsp = 0d; UpdateStartButtonText(); // 2. 清空 Prefab 和采样数据 @@ -336,13 +426,15 @@ public class delayTapperPrefab : MonoBehaviour isPaused = !isPaused; if (isPaused) { - // 暂停 + // 暂停:记录暂停时刻的 dspTime,冻结 cycle 时钟。 + pausedAtDsp = AudioSettings.dspTime; if (audioSource != null && audioSource.isPlaying) audioSource.Pause(); Debug.Log("[delayTapperPrefab] 暂停测试"); } else { - // 继续 + // 继续:把暂停期间流逝的 dspTime 累加进 pause 补偿,使 cycle 时钟不计入暂停时长。 + accumulatedPauseDsp += AudioSettings.dspTime - pausedAtDsp; if (audioSource != null) audioSource.UnPause(); Debug.Log("[delayTapperPrefab] 继续测试"); } @@ -515,33 +607,43 @@ public class delayTapperPrefab : MonoBehaviour // 如果暂停,不执行任何 Update 逻辑 if (isPaused) return; - // 改进监听逻辑:只要 Slider 正在滚动,就允许记录空格 - if (sliderCoroutine != null && Input.GetKeyDown(KeyCode.Space)) + // 改进监听逻辑:只要 Slider 正在滚动,就允许记录空格。 + // 用 Input System 的 Keyboard.current 捕获,并在按下的同一帧读取 AudioSettings.dspTime, + // 与游戏内判定同源(dspTime),消除 Time.deltaTime 帧量化误差。 + if (sliderCoroutine != null && Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame) { - SpawnTapperHandle(); + SpawnTapperHandle(AudioSettings.dspTime); } } - private void SpawnTapperHandle() + // 由 dspTime 推导当前 cycle 内已流逝时间(秒),与游戏时钟一致,无帧率漂移。 + private float ElapsedFromDsp() + { + double reference = isPaused ? pausedAtDsp : AudioSettings.dspTime; + double elapsed = reference - cycleStartDsp - accumulatedPauseDsp; + return Mathf.Max(0f, (float)elapsed); + } + + private void SpawnTapperHandle(double pressDsp) { Debug.Log("[delayTapperPrefab] SpawnTapperHandle 被调用"); if (runSlider == null) { Debug.LogError("runSlider 为空"); return; } - // 1. 直接基于当前时间(currentElapsed)计算偏移,不再依赖 Slider 的百分比,提高精度 + // 1. 用按键时刻的 dspTime 推导 cycle 内流逝时间,再算相对最近拍点的偏移,精度到音频缓冲级。 + double reference = pressDsp - cycleStartDsp - accumulatedPauseDsp; + float elapsedAtPress = Mathf.Max(0f, (float)reference); float beatDuration = 60f / Mathf.Max(BPM, 1); // 每拍时长 (120BPM 为 0.5s) - float nearestBeatTime = Mathf.Round(currentElapsed / beatDuration) * beatDuration; - float offsetSeconds = currentElapsed - nearestBeatTime; - - // 2. 记录差值到 List (Inspector 显示用,保留 ms 字符串格式) + float nearestBeatTime = Mathf.Round(elapsedAtPress / beatDuration) * beatDuration; + float offsetSeconds = elapsedAtPress - nearestBeatTime; + + // 2. 记录原始样本(用于中位数/去异常),同时保留 ms 字符串到 Inspector List 便于查看。 + rawOffsetSamples.Add(offsetSeconds); string offsetStr = (offsetSeconds * 1000f).ToString("F2") + "ms"; tapValueOffsets.Add(offsetStr); - - // 3. 计算平均偏移逻辑 - tapCount++; - totalOffsetSum += offsetSeconds; - float averageOffsetSeconds = totalOffsetSum / tapCount; - int finalDelayMs = Mathf.RoundToInt(averageOffsetSeconds * 1000f); - + + // 3. 用中位数 + 去异常值(MAD)计算稳健延迟,取代旧的累计平均(易被漏拍/误触拉偏、随次数稀释)。 + int finalDelayMs = Mathf.RoundToInt(ComputeRobustDelaySeconds() * 1000f); + // 4. 输出到 delayAmountNow if (delayAmountNow != null) { @@ -550,7 +652,7 @@ public class delayTapperPrefab : MonoBehaviour isInternalSetting = false; } - Debug.Log($"[delayTapperPrefab] 模式={currentMode}, 点击#{tapCount}: Time={currentElapsed:F3}s, NearestBeat={nearestBeatTime:F3}s, Offset={offsetSeconds*1000f:F2}ms, AvgDelay={finalDelayMs}ms"); + Debug.Log($"[delayTapperPrefab] 模式={currentMode}, 点击#{rawOffsetSamples.Count}: Time={elapsedAtPress:F3}s, NearestBeat={nearestBeatTime:F3}s, Offset={offsetSeconds*1000f:F2}ms, RobustDelay={finalDelayMs}ms"); if (tapperHandlePrefab == null || tapperHandlePrefab_toput == null) return; @@ -572,6 +674,53 @@ public class delayTapperPrefab : MonoBehaviour } } + /// + /// 稳健地估计玩家的输入偏移(秒):先取样本中位数,再用 MAD(绝对中位差)剔除离群点, + /// 最后对保留样本取均值。相比累计平均,能抵抗漏拍、误触和偶发大偏差, + /// 且不会随点击次数增多而被历史值稀释。样本不足时直接返回中位数/单值。 + /// + private float ComputeRobustDelaySeconds() + { + int n = rawOffsetSamples.Count; + if (n == 0) return 0f; + if (n == 1) return rawOffsetSamples[0]; + + float median = Median(rawOffsetSamples); + + // MAD = median(|x_i - median|)。用 1.4826 换算成与标准差可比的尺度。 + var absDeviations = new List(n); + for (int i = 0; i < n; i++) absDeviations.Add(Mathf.Abs(rawOffsetSamples[i] - median)); + float mad = Median(absDeviations); + float robustSigma = mad * 1.4826f; + + // 阈值:偏离中位数超过 3 个稳健标准差的样本视为异常值。 + // MAD 为 0(样本高度一致)时不剔除任何点,直接用中位数。 + if (robustSigma <= 1e-6f) return median; + + float threshold = 3f * robustSigma; + double sum = 0d; + int kept = 0; + for (int i = 0; i < n; i++) + { + if (Mathf.Abs(rawOffsetSamples[i] - median) <= threshold) + { + sum += rawOffsetSamples[i]; + kept++; + } + } + + return kept > 0 ? (float)(sum / kept) : median; + } + + private static float Median(List values) + { + int n = values.Count; + if (n == 0) return 0f; + var sorted = values.OrderBy(v => v).ToList(); + int mid = n / 2; + return (n % 2 == 1) ? sorted[mid] : 0.5f * (sorted[mid - 1] + sorted[mid]); + } + private void ClearTapperHandles() { Debug.Log("[delayTapperPrefab] ClearTapperHandles 被显式调用,即将清空 List 和累计数据"); @@ -583,11 +732,10 @@ public class delayTapperPrefab : MonoBehaviour Destroy(tapperHandlePrefab_toput.transform.GetChild(i).gameObject); } - // 重置所有累计数据 + // 重置所有采样数据 tapValueOffsets.Clear(); - totalOffsetSum = 0f; - tapCount = 0; - + rawOffsetSamples.Clear(); + Debug.Log("[delayTapperPrefab] 已手动清理 TapperHandles 和所有采样数据"); } @@ -678,20 +826,22 @@ public class delayTapperPrefab : MonoBehaviour // 注意:这里我们只清理生成的 Handle,不重置数据,因为数据是跨循环累计的 ClearVisualHandlesOnly(); - // 如果不是暂停后继续,则重置 currentElapsed - if (!isPaused) currentElapsed = 0f; - // 8拍模式走完 8 拍,4拍模式走完 4 拍 int beatsInCycle = (currentMode == TapperMode.FourBeats) ? 4 : 8; float cycleDuration = (60f / Mathf.Max(BPM, 1)) * beatsInCycle; + // 用 dspTime 作为 cycle 时钟起点,与按键捕获同源。每个 cycle 重置 pause 累计。 + cycleStartDsp = AudioSettings.dspTime; + accumulatedPauseDsp = 0d; + currentElapsed = 0f; + Debug.Log($"[delayTapperPrefab] 开始 {beatsInCycle} 拍滚动,BPM: {BPM}, 单次循环时长: {cycleDuration}s"); while (currentElapsed < cycleDuration) { if (!isPaused) { - currentElapsed += Time.deltaTime; + currentElapsed = ElapsedFromDsp(); runSlider.value = Mathf.Clamp01(currentElapsed / cycleDuration); } yield return null; diff --git a/Assets/scripts/Combat/AllyCombatant.cs b/Assets/scripts/Combat/AllyCombatant.cs index e04e4c99..7ad103bd 100644 --- a/Assets/scripts/Combat/AllyCombatant.cs +++ b/Assets/scripts/Combat/AllyCombatant.cs @@ -55,10 +55,18 @@ public class AllyCombatant : MonoBehaviour, ICombatant [Tooltip("Documentation text normalized.")] public int attack = 0; - // Keep an unbuffed attack baseline so temporary Buff.attackMultiplier can be applied/reverted correctly. + // True (permanent) attack baseline. Only permanent changes touch this: initial stats, + // level-ups, LevelRule AttackDelta, and duration<=0 skills. Timed skill buffs must NOT + // bake into this — they live in _attackModifiers so percentages stay computed off the + // unbuffed base and re-triggering a timed buff refreshes instead of compounding. private int _attackBaseUnbuffed = 0; private bool _attackBaseInitialized = false; + // Additive attack modifiers keyed by source (skillId). Each source contributes exactly one + // entry; re-applying the same source replaces its value (idempotent), so "+10% from A" and + // "+20% from B" sum to +30% of base rather than compounding multiplicatively per note hit. + private readonly Dictionary _attackModifiers = new Dictionary(); + // scoring fields [Header("Scoring")] [Tooltip("Base score value for a perfect hit on this track")] @@ -122,6 +130,11 @@ public class AllyCombatant : MonoBehaviour, ICombatant private Coroutine skillIconAutoFadeCoroutine; private float lastSkillIconPushTime = -1f; + // Pool of removed-skill-icon GameObjects reused by PlaySkillIconExitAnim, so each icon + // eviction reuses an instance instead of new GameObject(...) + Destroy every time. + private readonly Stack _removedSkillIconPool = new Stack(); + private readonly List _removedSkillIconAll = new List(); + // Track skills that are currently active because HP percent condition is satisfied. private HashSet _activeHpPercentSkills = new HashSet(); @@ -914,10 +927,11 @@ public class AllyCombatant : MonoBehaviour, ICombatant if (!Application.isPlaying) return; // Render removed icon under the slot parent to avoid disturbing slot layout positions. - var go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); + var go = AcquireRemovedSkillIcon(); RectTransform hostRt = hostSlot.rectTransform; RectTransform parentRt = hostRt != null ? hostRt.parent as RectTransform : null; go.transform.SetParent(parentRt != null ? parentRt : hostSlot.transform, false); + go.SetActive(true); var rt = go.GetComponent(); if (hostRt != null) @@ -947,7 +961,32 @@ public class AllyCombatant : MonoBehaviour, ICombatant if (gameObject.activeInHierarchy) StartCoroutine(SkillIconExitCoroutine(go, rt, img, removedColor, hostSlot)); else - Destroy(go); + ReleaseRemovedSkillIcon(go); + } + + private GameObject AcquireRemovedSkillIcon() + { + GameObject go = null; + while (_removedSkillIconPool.Count > 0) + { + go = _removedSkillIconPool.Pop(); + if (go != null) break; + go = null; + } + if (go == null) + { + go = new GameObject("RemovedSkillIcon", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image)); + _removedSkillIconAll.Add(go); + } + return go; + } + + private void ReleaseRemovedSkillIcon(GameObject go) + { + if (go == null) return; + go.SetActive(false); + go.transform.SetParent(transform, false); + _removedSkillIconPool.Push(go); } private IEnumerator SkillIconExitCoroutine(GameObject go, RectTransform rt, Image img, Color baseColor, Image hostSlot) @@ -995,7 +1034,7 @@ public class AllyCombatant : MonoBehaviour, ICombatant yield return null; } - if (go != null) Destroy(go); + ReleaseRemovedSkillIcon(go); } private void InitializeStatsFromData() @@ -1228,13 +1267,23 @@ public class AllyCombatant : MonoBehaviour, ICombatant ScoreManager.Instance?.RecalculateTotal(); } + // 性能:记录上次写入的 "cur/max" 字符串及其目标 Text 对象。仅当数值或目标对象变化时才写 .text, + // 避免每次判定对同一 Text 重复赋相同字符串触发 TMP 网格重建。目标对象变化(换局/UI 重建)必写。 + private string _lastScoreUiText; + private UnityEngine.Object _lastScoreUiTarget; + private void UpdateScoreUI() { string value = $"{currentScore}/{maxTrackScore}"; if (currentScoreText != null) { - currentScoreText.text = value; - LogVerbose($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})"); + if (!ReferenceEquals(_lastScoreUiTarget, currentScoreText) || _lastScoreUiText != value) + { + currentScoreText.text = value; + _lastScoreUiTarget = currentScoreText; + _lastScoreUiText = value; + LogVerbose($"[AllyCombatant] Updated slot {slotIndex+1} score UI -> {value} (target {currentScoreText.gameObject.name})"); + } return; } @@ -1242,6 +1291,10 @@ public class AllyCombatant : MonoBehaviour, ICombatant var ui = teamUIController.Instance; if (ui != null) { + // 性能:fallback 走固定的 teammateXX 字段(slotIndex 固定,目标稳定),值未变则跳过整段写入(等效)。 + if (_lastScoreUiText == value) return; + _lastScoreUiText = value; + _lastScoreUiTarget = null; switch (slotIndex) { case 0: @@ -1590,8 +1643,13 @@ public class AllyCombatant : MonoBehaviour, ICombatant EnsureAttackBaseInitialized(); float mult = GetTotalAttackMultiplier(); + int modifierSum = 0; + foreach (var kv in _attackModifiers) modifierSum += kv.Value; + int old = attack; - attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult)); + // Percentage buffs are pre-summed into _attackModifiers off the base, so the final value is + // (base + Σ sourceModifiers) with the legacy multiplicative Buff path (mult, normally 1) on top. + attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult) + modifierSum); if (old != attack) { EvaluateAttackZeroTriggers(); @@ -1600,6 +1658,33 @@ public class AllyCombatant : MonoBehaviour, ICombatant } } + // Permanent base attack, unaffected by timed buffs. Skills that compute a percentage of + // attack should read this so the percentage is always relative to the unbuffed value. + public int GetBaseAttack() + { + EnsureAttackBaseInitialized(); + return _attackBaseUnbuffed; + } + + // Set/clear an additive attack modifier contributed by a single source (keyed by skillId). + // Re-applying the same source replaces its prior contribution instead of stacking, which is + // what keeps repeated OnNoteHit percentage buffs from compounding every note. + public void SetAttackModifier(string sourceKey, int delta) + { + if (string.IsNullOrEmpty(sourceKey)) { ModifyAttack(delta); return; } + EnsureAttackBaseInitialized(); + if (delta == 0) _attackModifiers.Remove(sourceKey); + else _attackModifiers[sourceKey] = delta; + RecalculateAttackFromBuffs(); + } + + public void ClearAttackModifier(string sourceKey) + { + if (string.IsNullOrEmpty(sourceKey)) return; + if (_attackModifiers.Remove(sourceKey)) RecalculateAttackFromBuffs(); + } + + // Permanent base change (level stats, LevelRule AttackDelta, duration<=0 skills). public void ModifyAttack(int delta) { EnsureAttackBaseInitialized(); @@ -2590,8 +2675,19 @@ public class AllyCombatant : MonoBehaviour, ICombatant state.iconId = iBudeffPrefabController.Instance?.RegisterTimedEffect(redirector, PlayerBudeffIconType.dmg_redirect_toSelf, 0f, duration); } + // Frame guard so the global redirect list is swept once per frame, not once per ally + // (this is static/global state; running it 5× per frame was pure redundant work). + private static int s_lastRedirectCleanupFrame = -1; + private static void CleanupGlobalRedirects() { + int frame = Time.frameCount; + if (frame == s_lastRedirectCleanupFrame) return; + s_lastRedirectCleanupFrame = frame; + + // Nothing registered -> nothing to sweep. + if (s_nextDamageRedirectStates.Count == 0) return; + for (int i = s_nextDamageRedirectStates.Count - 1; i >= 0; i--) { var state = s_nextDamageRedirectStates[i]; diff --git a/Assets/scripts/Combat/EffectSystem.cs b/Assets/scripts/Combat/EffectSystem.cs index accd23de..0c390ce4 100644 --- a/Assets/scripts/Combat/EffectSystem.cs +++ b/Assets/scripts/Combat/EffectSystem.cs @@ -771,9 +771,17 @@ public class EffectSystem : MonoBehaviour // - AdjacentAllies -> use AllyCombatant.slotIndex (from source) and teamUIController helpers // - CurrentEnemies -> specificTarget if provided else all objects with tag "Enemy" // - AllEntities -> find all enemies + allies + // Reusable gathering buffer for ResolveTargets. Safe to reuse because it never escapes + // this method (the deduped result is copied into a fresh `uniq` list that IS returned) + // and the gathering phase never re-enters ResolveTargets. The returned list cannot share + // this buffer: ApplyEffect's switch can re-enter EffectSystem.ApplyEffect (e.g. via + // ModifyHP -> skill triggers), which would clobber a shared return buffer mid-iteration. + private readonly List _resolveGatherBuffer = new List(8); + private List ResolveTargets(Selector selector, GameObject source, GameObject specificTarget) { - List list = new List(); + List list = _resolveGatherBuffer; + list.Clear(); // prefer teamUIController when available for authoritative ally/enemy objects var ui = teamUIController.Instance != null ? teamUIController.Instance : SceneObjectLookupCache.FindAny(); @@ -810,13 +818,13 @@ public class EffectSystem : MonoBehaviour if (go != null && !list.Contains(go)) list.Add(go); } if (source != null) - list.RemoveAll(g => g == null || g == source || g.gameObject == source.gameObject); + RemoveSelfFromList(list, source); } else { list.AddRange(FindAllAllies()); if (source != null) - list.RemoveAll(g => g == null || g == source || g.gameObject == source.gameObject); + RemoveSelfFromList(list, source); } break; @@ -910,33 +918,59 @@ public class EffectSystem : MonoBehaviour break; } - // remove nulls and duplicates - list.RemoveAll(x => x == null); - var uniq = new List(); - foreach (var g in list) - if (!uniq.Contains(g)) uniq.Add(g); - - // Filter out dead or empty combatants so they won't receive further effects (damage/score/etc.). - // This prevents "dead units still get hit / still gain score" edge cases. - uniq.RemoveAll(go => + // Build the deduped, alive-only result. `uniq` is a fresh list because it escapes + // (ApplyEffect can re-enter EffectSystem, which would clobber a shared buffer). The + // filtering is done inline (no RemoveAll closures) to avoid per-call lambda allocs on + // this per-effect/per-damage-tick hot path. + var uniq = new List(list.Count); + for (int i = 0; i < list.Count; i++) { - if (go == null) return true; - var ally = go.GetComponent(); - if (ally != null) return ally.IsDead || ally.maxHP == 0; // Skip dead or empty allies - var enemy = go.GetComponent(); - if (enemy != null) return enemy.IsDead || enemy.currentHP <= 0; - return false; - }); + GameObject g = list[i]; + if (g == null) continue; + if (uniq.Contains(g)) continue; + // Skip dead or empty combatants so they won't receive further effects + // (prevents "dead units still get hit / still gain score" edge cases). + var ally = g.GetComponent(); + if (ally != null) + { + if (ally.IsDead || ally.maxHP == 0) continue; + } + else + { + var enemy = g.GetComponent(); + if (enemy != null && (enemy.IsDead || enemy.currentHP <= 0)) continue; + } + uniq.Add(g); + } - // Debug: log resolved targets for diagnosis (guarded to avoid log spam in normal gameplay). - if (uniq.Count == 0) - LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} returned 0 targets (source={(source ? source.name : "null")}, specificTarget={(specificTarget ? specificTarget.name : "null")})"); - else - LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} resolved {uniq.Count} targets: {string.Join(",", uniq.ConvertAll(x => x != null ? x.name : "null"))}"); + // Debug: log resolved targets for diagnosis. Guarded by the flag directly so the + // per-target ConvertAll + string.Join (allocating) never runs in normal gameplay — + // ResolveTargets is on the per-effect/per-damage-tick hot path. + if (GameConfig.verboseLogs) + { + if (uniq.Count == 0) + LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} returned 0 targets (source={(source ? source.name : "null")}, specificTarget={(specificTarget ? specificTarget.name : "null")})"); + else + LogVerbose($"[EffectSystem] ResolveTargets -> selector={selector} resolved {uniq.Count} targets: {string.Join(",", uniq.ConvertAll(x => x != null ? x.name : "null"))}"); + } return uniq; } + // Allocation-free removal of `source` (and nulls) from a gathered target list, replacing + // a per-call closure-capturing RemoveAll on the ResolveTargets hot path. + private static void RemoveSelfFromList(List list, GameObject source) + { + for (int i = list.Count - 1; i >= 0; i--) + { + GameObject g = list[i]; + if (g == null || g == source || g.gameObject == source.gameObject) + { + list.RemoveAt(i); + } + } + } + private IEnumerable FindAllAllies() { if (_cachedAlliesFrame == Time.frameCount) return _cachedAllies; diff --git a/Assets/scripts/Combat/EnemyCombatant.cs b/Assets/scripts/Combat/EnemyCombatant.cs index af1fa87b..72e94fcf 100644 --- a/Assets/scripts/Combat/EnemyCombatant.cs +++ b/Assets/scripts/Combat/EnemyCombatant.cs @@ -19,10 +19,14 @@ public class EnemyCombatant : MonoBehaviour, ICombatant public float damageResistance = 0f; public int attack = 0; - // Keep an unbuffed attack baseline so temporary Buff.attackMultiplier can be applied/reverted correctly. + // True (permanent) attack baseline. Timed buffs live in _attackModifiers instead of baking here, + // so percentages stay computed off the unbuffed base and re-triggers refresh rather than compound. private int _attackBaseUnbuffed = 0; private bool _attackBaseInitialized = false; + // Additive attack modifiers keyed by source (skillId); one entry per source, replaced on re-apply. + private readonly Dictionary _attackModifiers = new Dictionary(); + private List activeBuffs = new List(); // Events to notify manager/UI @@ -43,6 +47,7 @@ public class EnemyCombatant : MonoBehaviour, ICombatant // This enemy GameObject can be reused for multiple enemy entries in sequence. // Clear any runtime buffs so they don't leak across enemy transitions. activeBuffs.Clear(); + _attackModifiers.Clear(); _attackBaseInitialized = false; _attackBaseUnbuffed = 0; _lastAttacker = null; @@ -94,6 +99,12 @@ public class EnemyCombatant : MonoBehaviour, ICombatant TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger.OnEnemyRevive); } + // Reused only for the formula vars (safe: used synchronously in the leaf before any + // nested ExecuteSkill call). The `executed` set is intentionally NOT pooled: an enemy + // skill can restore the enemy's own mana and re-enter TryTriggerConfiguredSkills(OnManaFull) + // mid-loop, so a shared set could be cleared under the outer iteration. + private readonly Dictionary _enemyFormulaVarsBuffer = new Dictionary(); + private void TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger when) { if (sourceData == null) return; @@ -131,11 +142,15 @@ public class EnemyCombatant : MonoBehaviour, ICombatant executed.Add(def); float perTick = 0f; - var vars = new Dictionary(); + var vars = _enemyFormulaVarsBuffer; + vars.Clear(); vars["maxHP"] = maxHP; vars["maxMana"] = maxMana; vars["damageResistance"] = damageResistance; - vars["attack"] = attack; + // Attack-buff effects compute their percentage off the unbuffed base so repeated triggers + // add by source instead of compounding; output effects keep using current attack. + bool attackPercentOffBase = def.effectType == EffectType.IncreaseAttack || def.effectType == EffectType.DecreaseAttack; + vars["attack"] = attackPercentOffBase ? GetBaseAttack() : attack; vars["enemy_currentHP"] = currentHP; if (!string.IsNullOrWhiteSpace(def.formula)) @@ -269,7 +284,8 @@ public class EnemyCombatant : MonoBehaviour, ICombatant public void ApplyBuff(Buff buff, GameObject source) { if (buff == null) return; - Debug.LogWarning($"[EnemyCombatant] ApplyBuff: {buff.description} (id={buff.buffId}) to {name}. AtkMult={buff.attackMultiplier}, HealMult={buff.healReceivedMultiplier}, ScoreMult={buff.scoreMultiplier}"); + if (GameConfig.verboseLogs) + Debug.LogWarning($"[EnemyCombatant] ApplyBuff: {buff.description} (id={buff.buffId}) to {name}. AtkMult={buff.attackMultiplier}, HealMult={buff.healReceivedMultiplier}, ScoreMult={buff.scoreMultiplier}"); activeBuffs.Add(buff); // Apply reversible multipliers. attackMultiplier is applied via baseline+recalc to ensure it can be reverted. if (buff.attackMultiplier != 1f) RecalculateAttackFromBuffs(); @@ -334,7 +350,30 @@ public class EnemyCombatant : MonoBehaviour, ICombatant { EnsureAttackBaseInitialized(); float mult = GetTotalAttackMultiplier(); - attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult)); + int modifierSum = 0; + foreach (var kv in _attackModifiers) modifierSum += kv.Value; + attack = Mathf.Max(0, Mathf.RoundToInt(_attackBaseUnbuffed * mult) + modifierSum); + } + + public int GetBaseAttack() + { + EnsureAttackBaseInitialized(); + return _attackBaseUnbuffed; + } + + public void SetAttackModifier(string sourceKey, int delta) + { + if (string.IsNullOrEmpty(sourceKey)) { ModifyAttack(delta); return; } + EnsureAttackBaseInitialized(); + if (delta == 0) _attackModifiers.Remove(sourceKey); + else _attackModifiers[sourceKey] = delta; + RecalculateAttackFromBuffs(); + } + + public void ClearAttackModifier(string sourceKey) + { + if (string.IsNullOrEmpty(sourceKey)) return; + if (_attackModifiers.Remove(sourceKey)) RecalculateAttackFromBuffs(); } public void ModifyAttack(int delta) @@ -414,7 +453,8 @@ public class EnemyCombatant : MonoBehaviour, ICombatant // If mana reached full, trigger configured skills and reset mana if (currentMana >= maxMana && maxMana > 0 && Application.isPlaying) { - Debug.Log($"[EnemyCombatant] Mana full for {gameObject.name} (slot) - triggering OnManaFull"); + if (GameConfig.verboseLogs) + Debug.Log($"[EnemyCombatant] Mana full for {gameObject.name} (slot) - triggering OnManaFull"); try { TryTriggerConfiguredSkills(SkillDefinition.SkillTrigger.OnManaFull); diff --git a/Assets/scripts/Combat/GameplaySkillLogger.cs b/Assets/scripts/Combat/GameplaySkillLogger.cs index 52e85928..eaf2baec 100644 --- a/Assets/scripts/Combat/GameplaySkillLogger.cs +++ b/Assets/scripts/Combat/GameplaySkillLogger.cs @@ -18,6 +18,13 @@ public static class GameplaySkillLogger private static float s_sessionStartRealtime; private static int s_lastSecondBucket = -1; + // Master gate. This is a diagnostic disk logger: when disabled, every Record* method + // returns before doing any Sanitize/string-concat/StringBuilder work, so it costs + // nothing on the per-damage/per-note/per-score hot paths in production. Latched from + // GameConfig at BeginSession so a whole session is consistently on or off. Hot call + // sites can also check GameplaySkillLogger.Enabled to skip building argument strings. + public static bool Enabled { get; private set; } + private const float RapidDuplicateSkillThresholdSeconds = 0.05f; // Off-thread writer: judge/skill events push formatted lines into this queue and a @@ -45,6 +52,14 @@ public static class GameplaySkillLogger public static void BeginSession(string sessionLabel) { + // Latch the gate for the whole session from the debug flags. Disabled in production + // (both flags default false) so the logger adds zero hot-path cost. + Enabled = GameConfig.verboseLogs || GameConfig.skillDebugMode; + if (!Enabled) + { + return; + } + lock (s_fileLock) { BeginSessionInternal(sessionLabel); @@ -58,6 +73,7 @@ public static class GameplaySkillLogger string attributeSummary, string targetSummary) { + if (!Enabled) return; lock (s_fileLock) { EnsureSessionLocked(); @@ -106,6 +122,7 @@ public static class GameplaySkillLogger float hitTime, float scheduledEndTime) { + if (!Enabled) return; lock (s_fileLock) { EnsureSessionLocked(); @@ -141,6 +158,7 @@ public static class GameplaySkillLogger int allIdol, int totalScore) { + if (!Enabled) return; lock (s_fileLock) { EnsureSessionLocked(); @@ -173,6 +191,7 @@ public static class GameplaySkillLogger float damageResistance, string sourceName) { + if (!Enabled) return; lock (s_fileLock) { EnsureSessionLocked(); @@ -200,6 +219,7 @@ public static class GameplaySkillLogger int maxMana, string source) { + if (!Enabled) return; lock (s_fileLock) { EnsureSessionLocked(); @@ -226,6 +246,7 @@ public static class GameplaySkillLogger int maxValue, string sourceName) { + if (!Enabled) return; lock (s_fileLock) { EnsureSessionLocked(); @@ -248,6 +269,7 @@ public static class GameplaySkillLogger public static void RecordConflictHint(string subsystem, string issueType, string details) { + if (!Enabled) return; lock (s_fileLock) { EnsureSessionLocked(); diff --git a/Assets/scripts/Combat/ScoreManager.cs b/Assets/scripts/Combat/ScoreManager.cs index fbbcbb7c..37fa2bac 100644 --- a/Assets/scripts/Combat/ScoreManager.cs +++ b/Assets/scripts/Combat/ScoreManager.cs @@ -18,6 +18,9 @@ public class ScoreManager : MonoBehaviour private readonly float[] _idolscoreLastTargetY = new float[5]; private readonly float[] _idolscorePulseAmplitude = new float[5]; private readonly float[] _idolscorePulseSeed = new float[5]; + // True once a key has fully converged to its target (no wobble, displayY == targetY). + // While settled we skip the per-frame noise + localScale write; cleared when target changes. + private readonly bool[] _idolscoreSettled = new bool[5]; // per-track pm score sums (red, green, yellow, purple, blue) public int red_pmScore_sum = 0; @@ -69,6 +72,12 @@ public class ScoreManager : MonoBehaviour int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue, int.MinValue }; + // 性能:RecalculateTotal 里 5 个分槽 "cur/max" 上次写入的字符串及其目标 Text 对象。 + // 仅当"数值(cur/max)变化"或"目标对象变化(换局/换场景导致 UI 重建)"时才写 .text, + // 避免每判定对 5 个槽位无条件赋值触发 TMP 网格重建。写入逻辑与原先完全一致,只是跳过重复写。 + private readonly string[] _lastSlotScoreText = new string[5]; + private readonly UnityEngine.Object[] _lastSlotScoreTarget = new UnityEngine.Object[5]; + // 仅当数值变化时写 TMP.text(避免相同字符串触发无谓网格重建)。 private void SetSumTextIfChanged(TextMeshProUGUI field, int value, int cacheIndex) { @@ -105,13 +114,21 @@ public class ScoreManager : MonoBehaviour public int countLate = 0; public float totalOffsetMs = 0f; public int offsetCount = 0; + // Raw per-hit samples (ms) for median/suggestion. Miss (0) not included. + private readonly System.Collections.Generic.List offsetSamples = + new System.Collections.Generic.List(); + + /// + /// 只读访问判定偏移样本列表(供直方图等外部组件读取)。 + /// + public System.Collections.Generic.IReadOnlyList OffsetSamples => offsetSamples; public void ResetStatistics() { countPerfect = countGreat = countGood = countMiss = 0; perfectClearBonusPm = 0; perfectClearBonusApplied = false; - + for (int i = 0; i < 5; i++) { trackPerfectCounts[i] = 0; @@ -123,11 +140,17 @@ public class ScoreManager : MonoBehaviour countEarly = countLate = 0; totalOffsetMs = 0f; offsetCount = 0; + offsetSamples.Clear(); } /// - /// Records a timing offset in milliseconds. - /// Positive = Early, Negative = Late. + /// Fired whenever a timing offset is recorded (Positive = Early, Negative = Late). + /// Display-only hook (e.g. realtime offset bar); does not affect scoring/judgement. + /// + public static event System.Action OnOffsetRecorded; + + /// + /// Records a timing offset in milliseconds. Positive = Early, Negative = Late. /// public void RecordOffset(float offsetMs) { @@ -135,9 +158,26 @@ public class ScoreManager : MonoBehaviour totalOffsetMs += offsetMs; offsetCount++; + offsetSamples.Add(offsetMs); if (offsetMs > 0) countEarly++; else if (offsetMs < 0) countLate++; + + OnOffsetRecorded?.Invoke(offsetMs); + } + + /// + /// Returns median offset (ms) across all recorded hits. More robust than mean for + /// calibration suggestions — single outlier taps don't skew it. + /// + public float GetMedianOffsetMs() + { + int n = offsetSamples.Count; + if (n == 0) return 0f; + var sorted = new System.Collections.Generic.List(offsetSamples); + sorted.Sort(); + int mid = n / 2; + return (n % 2 == 1) ? sorted[mid] : 0.5f * (sorted[mid - 1] + sorted[mid]); } private void Awake() @@ -200,6 +240,10 @@ public class ScoreManager : MonoBehaviour Transform t = _idolscoreKeys[i]; if (t == null) continue; + // Settled: display converged to target and wobble decayed. Nothing changes frame to + // frame, so skip the noise sample and localScale write until the target moves again. + if (_idolscoreSettled[i]) continue; + Vector3 baseScale = _idolscoreKeyBaseScaleCached[i] ? _idolscoreKeyBaseScales[i] : t.localScale; float targetY = Mathf.Max(0f, _idolscoreTargetY[i]); @@ -213,6 +257,16 @@ public class ScoreManager : MonoBehaviour float y = Mathf.Max(0f, _idolscoreDisplayY[i] + noise * _idolscorePulseAmplitude[i]); t.localScale = new Vector3(baseScale.x, y, baseScale.z); + + // Mark settled once the smoothed value has essentially reached the target and the + // wobble amplitude has decayed to zero, so the next frame can early-out. + if (Mathf.Abs(_idolscoreDisplayY[i] - targetY) < 0.0005f && _idolscorePulseAmplitude[i] < 0.0005f) + { + _idolscoreDisplayY[i] = targetY; + _idolscorePulseAmplitude[i] = 0f; + t.localScale = new Vector3(baseScale.x, targetY, baseScale.z); + _idolscoreSettled[i] = true; + } } } @@ -382,6 +436,8 @@ public class ScoreManager : MonoBehaviour { _idolscoreLastTargetY[i] = targetY; _idolscorePulseAmplitude[i] = UnityEngine.Random.Range(0.08f, 0.24f); + // Target moved (and/or wobble re-seeded): resume per-frame animation. + _idolscoreSettled[i] = false; } if (targetY <= 0.001f) @@ -638,13 +694,37 @@ public class ScoreManager : MonoBehaviour var ui = teamUIController.Instance; // helper lambda to set text on TMP or legacy Text under a parent fallback - void SetScoreText(TextMeshProUGUI assignedTmp, GameObject parent, int cur, int max, string slotName) + void SetScoreText(TextMeshProUGUI assignedTmp, GameObject parent, int cur, int max, string slotName, int slotIdx) { string value = cur + "/" + max; + + // 【性能】值变门控:仅当 (目标 Text 对象, value) 与该槽位上次写入不同才赋值 .text。 + // 目标对象或数值任一变化都会写(换局/换场景使 UI 重建时目标对象变,必写一次), + // 因此可见文本与原先逐次无条件赋值完全一致,只是跳过"同对象同值"的重复写。 + // 用纯判断函数(非闭包 lambda)避免额外 GC:返回 true 表示需要写并已更新缓存。 + bool ShouldWriteSlot(UnityEngine.Object target) + { + if (slotIdx >= 0 && slotIdx < 5 && + ReferenceEquals(_lastSlotScoreTarget[slotIdx], target) && + _lastSlotScoreText[slotIdx] == value) + { + return false; // 同对象同值,已是该文本,跳过写入(等效)。 + } + if (slotIdx >= 0 && slotIdx < 5) + { + _lastSlotScoreTarget[slotIdx] = target; + _lastSlotScoreText[slotIdx] = value; + } + return true; + } + if (assignedTmp != null) { - assignedTmp.text = value; - if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to assigned TMP for {slotName}: '{value}' -> {assignedTmp.gameObject.name}"); + if (ShouldWriteSlot(assignedTmp)) + { + assignedTmp.text = value; + if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to assigned TMP for {slotName}: '{value}' -> {assignedTmp.gameObject.name}"); + } return; } if (parent != null) @@ -661,15 +741,21 @@ public class ScoreManager : MonoBehaviour var tmp = slotTmpFallback[idx]; if (tmp != null) { - tmp.text = value; - if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}"); + if (ShouldWriteSlot(tmp)) + { + tmp.text = value; + if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}"); + } return; } var legacy = slotLegacyFallback[idx]; if (legacy != null) { - legacy.text = value; - if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}"); + if (ShouldWriteSlot(legacy)) + { + legacy.text = value; + if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}"); + } return; } } @@ -678,15 +764,21 @@ public class ScoreManager : MonoBehaviour var tmp = parent.GetComponentInChildren(true); if (tmp != null) { - tmp.text = value; - if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}"); + if (ShouldWriteSlot(tmp)) + { + tmp.text = value; + if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to TMP in parent {parent.name} for {slotName}: '{value}' -> {tmp.gameObject.name}"); + } return; } var legacy = parent.GetComponentInChildren(true); if (legacy != null) { - legacy.text = value; - if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}"); + if (ShouldWriteSlot(legacy)) + { + legacy.text = value; + if (JudgeManager.IsDebugEnabled) Debug.Log($"[ScoreManager] Wrote to legacy Text in parent {parent.name} for {slotName}: '{value}' -> {legacy.gameObject.name}"); + } return; } } @@ -695,11 +787,11 @@ public class ScoreManager : MonoBehaviour } // Update each slot: use the explicit TMP fields if set; otherwise try parent object fields - SetScoreText(ui.teammate01_current_scoreText, ui.objectFather_ally01, tmpCurrents[0], tmpMaxes[0], "teammate01"); - SetScoreText(ui.teammate02_current_scoreText, ui.objectFather_ally02, tmpCurrents[1], tmpMaxes[1], "teammate02"); - SetScoreText(ui.teammate03_current_scoreText, ui.objectFather_ally03, tmpCurrents[2], tmpMaxes[2], "teammate03"); - SetScoreText(ui.teammate04_current_scoreText, ui.objectFather_ally04, tmpCurrents[3], tmpMaxes[3], "teammate04"); - SetScoreText(ui.teammate05_current_scoreText, ui.objectFather_ally05, tmpCurrents[4], tmpMaxes[4], "teammate05"); + SetScoreText(ui.teammate01_current_scoreText, ui.objectFather_ally01, tmpCurrents[0], tmpMaxes[0], "teammate01", 0); + SetScoreText(ui.teammate02_current_scoreText, ui.objectFather_ally02, tmpCurrents[1], tmpMaxes[1], "teammate02", 1); + SetScoreText(ui.teammate03_current_scoreText, ui.objectFather_ally03, tmpCurrents[2], tmpMaxes[2], "teammate03", 2); + SetScoreText(ui.teammate04_current_scoreText, ui.objectFather_ally04, tmpCurrents[3], tmpMaxes[3], "teammate04", 3); + SetScoreText(ui.teammate05_current_scoreText, ui.objectFather_ally05, tmpCurrents[4], tmpMaxes[4], "teammate05", 4); // update total score (try TMP first, then legacy Text) if (ui.currentTotalScore != null) diff --git a/Assets/scripts/Combat/SkillBuilder.cs b/Assets/scripts/Combat/SkillBuilder.cs index fa3458d0..32f5b885 100644 --- a/Assets/scripts/Combat/SkillBuilder.cs +++ b/Assets/scripts/Combat/SkillBuilder.cs @@ -69,7 +69,8 @@ public class SkillBuilder : MonoBehaviour // Reusable buffers to reduce allocations during gameplay private readonly Dictionary _varsBuffer = new Dictionary(16); private readonly List _tmpNoteIdRemoval = new List(16); - private readonly Dictionary _lastSkillTriggerTime = new Dictionary(256); + // (slot, skillId) tuple key avoids a string concat on the repeat-window path. + private readonly Dictionary<(int, string), float> _lastSkillTriggerTime = new Dictionary<(int, string), float>(256); private readonly Dictionary _refreshOnlyTimedEffectCoroutines = new Dictionary(32); private readonly Dictionary _refreshOnlyTimedEffectStates = new Dictionary(32); private readonly Dictionary _refreshOnlyOverTimeCoroutines = new Dictionary(16); @@ -196,6 +197,9 @@ public class SkillBuilder : MonoBehaviour public string budeffIconId; public int budeffSlotIndex = -1; public int budeffEnemyInstanceId; + // For attack buffs: the per-source key used with SetAttackModifier/ClearAttackModifier, + // so revert clears exactly this source's contribution instead of baking into base. + public string attackModifierKey; } private void PrewarmAllyHeroSOIndex() @@ -235,7 +239,8 @@ public class SkillBuilder : MonoBehaviour } // Apply as single-instance damage to current enemies; EffectSystem will pick first applicable enemy for DamageSingleEnemy global::EffectSystem.Instance.ApplyEffect(Selector.CurrentEnemies, EffectType.DamageSingleEnemy, amount, 0f, caster, null); - LogVerbose($"[SkillBuilder] DealDamageFromAllyToEnemies: applied {amount} from slot {slotIndex} via EffectSystem"); + if (GameConfig.verboseLogs) + LogVerbose($"[SkillBuilder] DealDamageFromAllyToEnemies: applied {amount} from slot {slotIndex} via EffectSystem"); } private void ApplySharedOnNoteHit(int trackIndex, string judgeResult) @@ -436,15 +441,22 @@ public class SkillBuilder : MonoBehaviour } // Documentation text normalized. // Documentation text normalized. + // Reused gather buffer for ResolveTargetsLocal. Safe: it is fully consumed into the + // returned `uniq` list before this method returns, and the gather phase never re-enters + // ResolveTargetsLocal (skill execution happens later, on the returned list). The specific + // -target fast path returns a fresh single-element list (it escapes as the result). + private readonly List _resolveLocalGatherBuffer = new List(8); + public List ResolveTargetsLocal(Selector selector, GameObject source = null, GameObject specificTarget = null) { - List list = new List(); // If caller provided a specificTarget, honor it as the single target regardless of selector. if (specificTarget != null) { - list.Add(specificTarget); - return list; + return new List { specificTarget }; } + + List list = _resolveLocalGatherBuffer; + list.Clear(); switch (selector) { case Selector.Self: @@ -461,7 +473,11 @@ public class SkillBuilder : MonoBehaviour { if (go != null) list.Add(go); } - if (source != null) list.RemoveAll(g => g == null || g == source); + if (source != null) + { + for (int i = list.Count - 1; i >= 0; i--) + if (list[i] == null || list[i] == source) list.RemoveAt(i); + } break; case Selector.AdjacentAllies: if (source == null) break; @@ -524,9 +540,15 @@ public class SkillBuilder : MonoBehaviour try { var taggedE = GameObject.FindGameObjectsWithTag("Enemy"); foreach (var g in taggedE) if (!list.Contains(g)) list.Add(g); } catch { var single = SceneObjectLookupCache.Find("thisEnemy"); if (single != null && !list.Contains(single)) list.Add(single); } break; } - list.RemoveAll(x => x == null); - var uniq = new List(); - foreach (var g in list) if (!uniq.Contains(g)) uniq.Add(g); + // Build the deduped result (fresh list because it escapes into the caller / skill + // execution). Inline null-skip + dedupe avoids the RemoveAll closure allocation. + var uniq = new List(list.Count); + for (int i = 0; i < list.Count; i++) + { + var g = list[i]; + if (g == null) continue; + if (!uniq.Contains(g)) uniq.Add(g); + } return uniq; } @@ -845,10 +867,31 @@ public class SkillBuilder : MonoBehaviour ExecuteSkill($"slot{slotIndex + 1}_{skillId}", effectType, finalValue, selector, caster, specificTarget, duration, tickInterval); } + // Stat-multiplier buff effects that must stack additively by source rather than compound. + // Any timed skill of these types is treated as refresh-only so re-triggering the same skill + // refreshes its single per-source contribution instead of piling on a new one each trigger. + private static bool IsAdditiveBySourceStatEffect(EffectType effectType) + { + switch (effectType) + { + case EffectType.IncreaseAttack: + case EffectType.DecreaseAttack: + case EffectType.IncreaseScoreEfficiency: + case EffectType.DecreaseScoreEfficiency: + case EffectType.IncreaseDamageResistance: + case EffectType.DecreaseDamageResistance: + return true; + default: + return false; + } + } + private static bool IsRefreshOnlyTimedSkill(SkillDefinition def) { if (def == null || string.IsNullOrWhiteSpace(def.skillId)) return false; - return s_refreshOnlyTimedSkillIds.Contains(def.skillId); + if (s_refreshOnlyTimedSkillIds.Contains(def.skillId)) return true; + // Timed stat-multiplier buffs default to refresh-only so they add by source, not compound. + return def.defaultDuration > 0f && IsAdditiveBySourceStatEffect(def.effectType); } private static bool IsRefreshOnlyOverTimeSkill(SkillDefinition def) @@ -961,7 +1004,7 @@ public class SkillBuilder : MonoBehaviour StopCoroutine(running); } - if (!TryApplyRefreshOnlyTimedEffectNow(target, def.effectType, amount, out var applied)) + if (!TryApplyRefreshOnlyTimedEffectNow(target, def.effectType, amount, out var applied, key)) { _refreshOnlyTimedEffectStates.Remove(key); _refreshOnlyTimedEffectCoroutines.Remove(key); @@ -1021,7 +1064,7 @@ public class SkillBuilder : MonoBehaviour _refreshOnlyTimedEffectCoroutines.Remove(key); } - private bool TryApplyRefreshOnlyTimedEffectNow(GameObject target, EffectType effectType, float amount, out RefreshOnlyTimedState state) + private bool TryApplyRefreshOnlyTimedEffectNow(GameObject target, EffectType effectType, float amount, out RefreshOnlyTimedState state, string attackModifierSourceKey = null) { state = null; var ally = target != null ? target.GetComponent() : null; @@ -1058,11 +1101,14 @@ public class SkillBuilder : MonoBehaviour int atkDelta = Mathf.CeilToInt(Mathf.Abs(amount)); if (effectType == EffectType.DecreaseAttack) atkDelta = -atkDelta; int oldAtk = ally != null ? ally.attack : enemy.attack; - if (ally != null) ally.ModifyAttack(atkDelta); - else enemy.ModifyAttack(atkDelta); + // Apply as a keyed additive modifier so re-triggering this same skill replaces its + // contribution (does not compound) and revert removes exactly this source. + string atkKey = attackModifierSourceKey ?? ("atk:" + (ally != null ? ally.GetInstanceID() : enemy.GetInstanceID())); + if (ally != null) ally.SetAttackModifier(atkKey, atkDelta); + else enemy.SetAttackModifier(atkKey, atkDelta); int newAtk = ally != null ? ally.attack : enemy.attack; CheckLimitAndWarnInt(targetName, "攻击力", oldAtk, newAtk, atkDelta); - state = new RefreshOnlyTimedState { effectType = effectType, intDelta = atkDelta }; + state = new RefreshOnlyTimedState { effectType = effectType, intDelta = atkDelta, attackModifierKey = atkKey }; return true; case EffectType.IncreaseMaxHP: @@ -1148,8 +1194,17 @@ public class SkillBuilder : MonoBehaviour case EffectType.IncreaseAttack: case EffectType.DecreaseAttack: - if (ally != null) ally.ModifyAttack(-state.intDelta); - else enemy.ModifyAttack(-state.intDelta); + if (!string.IsNullOrEmpty(state.attackModifierKey)) + { + if (ally != null) ally.ClearAttackModifier(state.attackModifierKey); + else enemy.ClearAttackModifier(state.attackModifierKey); + } + else + { + // Legacy fallback for states created before keyed modifiers. + if (ally != null) ally.ModifyAttack(-state.intDelta); + else enemy.ModifyAttack(-state.intDelta); + } break; case EffectType.IncreaseMaxHP: @@ -1438,10 +1493,15 @@ ResolvedGroup: vars.Clear(); vars["slot"] = slotIndex; + // For attack-buff effects the percentage must be computed off the UNBUFFED base so + // that "+10% then +20%" sums to +30% of base instead of compounding off already-buffed + // attack every trigger. Output effects (damage/heal/score) keep using current attack. + bool attackPercentOffBase = def.effectType == EffectType.IncreaseAttack || def.effectType == EffectType.DecreaseAttack; + // Prefer runtime values so buffs/debuffs and max stat changes affect formulas. if (casterAlly != null) { - vars["attack"] = casterAlly.attack; + vars["attack"] = attackPercentOffBase ? casterAlly.GetBaseAttack() : casterAlly.attack; vars["maxHP"] = casterAlly.maxHP; vars["maxMana"] = casterAlly.maxMana; vars["damageResistance"] = casterAlly.damageResistance; @@ -1547,7 +1607,7 @@ ResolvedGroup: if (def.repeatWindowSeconds > 0f && def.repeatValue != 0f) { string sid = !string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name; - string repeatKey = $"{slotIndex}:{sid}"; + (int, string) repeatKey = (slotIndex, sid); float now = GameplayClock.NowSongTime; if (_lastSkillTriggerTime.TryGetValue(repeatKey, out float last) && (now - last) <= def.repeatWindowSeconds) { @@ -1556,24 +1616,27 @@ ResolvedGroup: _lastSkillTriggerTime[repeatKey] = now; } - try + if (GameplaySkillLogger.Enabled) { - string casterDisplayName = ResolveCasterDisplayNameForLog(slotIndex, heroSoForName, caster); - string skillDisplayNameForLog = string.IsNullOrWhiteSpace(smallSkillName) - ? (!string.IsNullOrWhiteSpace(def.displayName) ? def.displayName : (!string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name)) - : smallSkillName; - string effectSummary = BuildSkillEffectSummaryForLog(def, amountPerTick, amountTotal); - string targetSummary = BuildSkillTargetSummaryForLog(def.defaultSelector, specificTarget); - GameplaySkillLogger.RecordSkillRelease( - casterDisplayName, - skillDisplayNameForLog, - def.effectType.ToString(), - effectSummary, - targetSummary); - } - catch (System.Exception ex) - { - LogVerbose("[SkillBuilder] GameplaySkillLogger failed: " + ex.Message); + try + { + string casterDisplayName = ResolveCasterDisplayNameForLog(slotIndex, heroSoForName, caster); + string skillDisplayNameForLog = string.IsNullOrWhiteSpace(smallSkillName) + ? (!string.IsNullOrWhiteSpace(def.displayName) ? def.displayName : (!string.IsNullOrWhiteSpace(def.skillId) ? def.skillId : def.name)) + : smallSkillName; + string effectSummary = BuildSkillEffectSummaryForLog(def, amountPerTick, amountTotal); + string targetSummary = BuildSkillTargetSummaryForLog(def.defaultSelector, specificTarget); + GameplaySkillLogger.RecordSkillRelease( + casterDisplayName, + skillDisplayNameForLog, + def.effectType.ToString(), + effectSummary, + targetSummary); + } + catch (System.Exception ex) + { + LogVerbose("[SkillBuilder] GameplaySkillLogger failed: " + ex.Message); + } } if (def.operateDirectly) @@ -3381,7 +3444,10 @@ ResolvedGroup: } } - private Dictionary _lastOnNoteHitTriggerTime = new Dictionary(); + // Keyed by (trackIndex, skillId) value-tuple instead of an interpolated string, so the + // per-note-hit cooldown lookup allocates nothing. Only touched when a skill actually + // configures onNoteHitCooldown > 0. + private Dictionary<(int, string), float> _lastOnNoteHitTriggerTime = new Dictionary<(int, string), float>(); // track processed unique note IDs (e.g. long-hold note id) so shared effects (mana/hp) are applied only once private Dictionary _processedNoteHitTimestamps = new Dictionary(); @@ -3391,7 +3457,10 @@ ResolvedGroup: // noteType indicates whether the hit event came from a Tap or Hold (tail) public bool NotifyNoteHit(int trackIndex, string judgeResult, SkillDefinition.NoteTypeTrigger noteType = SkillDefinition.NoteTypeTrigger.Tap, string uniqueNoteId = null) { - LogVerbose($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType} uniqueId={uniqueNoteId}"); + // Guard interpolation: this runs on every note hit; the $"..." would otherwise + // build a string + box the enum each hit even when verbose logging is off. + if (GameConfig.verboseLogs) + LogVerbose($"[SkillBuilder] NotifyNoteHit RECEIVED: track={trackIndex} judge={judgeResult} noteType={noteType} uniqueId={uniqueNoteId}"); // cleanup old processed ids (> 10s) float nowCleanup = GameplayClock.NowSongTime; _tmpNoteIdRemoval.Clear(); @@ -3510,11 +3579,13 @@ ResolvedGroup: if (quality < required) continue; } - // cooldown per slot+skill - string key = $"{trackIndex}:{def.skillId}"; + // cooldown per (slot, skill) — tuple key, no per-hit string alloc float now = GameplayClock.NowSongTime; - if (def.onNoteHitCooldown > 0f) + bool hasCooldown = def.onNoteHitCooldown > 0f; + (int, string) key = default; + if (hasCooldown) { + key = (trackIndex, def.skillId); if (_lastOnNoteHitTriggerTime.TryGetValue(key, out float last)) { if (now - last < def.onNoteHitCooldown) continue; @@ -3522,9 +3593,10 @@ ResolvedGroup: } // trigger - LogVerbose($"[SkillBuilder] NotifyNoteHit: triggering equipped skill {def.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})"); + if (GameConfig.verboseLogs) + LogVerbose($"[SkillBuilder] NotifyNoteHit: triggering equipped skill {def.skillId} for slot {trackIndex} due to note hit {judgeResult} (noteType={noteType})"); UseSkillDefinition(def, trackIndex, -1f, null); - _lastOnNoteHitTriggerTime[key] = now; + if (hasCooldown) _lastOnNoteHitTriggerTime[key] = now; anyTriggered = true; } } diff --git a/Assets/scripts/Combat/SkillDefinition.cs b/Assets/scripts/Combat/SkillDefinition.cs index 62d3f40d..619bbe5f 100644 --- a/Assets/scripts/Combat/SkillDefinition.cs +++ b/Assets/scripts/Combat/SkillDefinition.cs @@ -9,6 +9,11 @@ public class SkillDefinition : ScriptableObject // Cache compiled RPN for formulas to avoid re-tokenizing every trigger. private static readonly Dictionary> s_rpnCache = new Dictionary>(); private static readonly HashSet s_badFormulaCache = new HashSet(); + // Reused across EvalRPN calls to avoid a per-evaluation allocation. Formula eval runs + // synchronously on the main thread and is not re-entrant, so a shared instance is safe. + private static readonly Stack s_evalStack = new Stack(); + // Warn once per unknown variable name instead of on every evaluation. + private static readonly HashSet s_warnedUnknownVars = new HashSet(); public enum SkillTrigger { None, @@ -301,7 +306,8 @@ public class SkillDefinition : ScriptableObject private static float EvalRPN(List rpn, Dictionary vars) { - var st = new Stack(); + var st = s_evalStack; + st.Clear(); foreach (var t in rpn) { if (t.type == TokType.Number) @@ -314,8 +320,11 @@ public class SkillDefinition : ScriptableObject if (vars != null && vars.TryGetValue(t.text, out float v)) st.Push(v); else { - // unknown identifiers treat as 0 but warn - Debug.LogWarning($"SkillDefinition: unknown variable '{t.text}' in formula, treated as 0"); + // unknown identifiers treat as 0 but warn (once per unique name) + if (s_warnedUnknownVars.Add(t.text)) + { + Debug.LogWarning($"SkillDefinition: unknown variable '{t.text}' in formula, treated as 0"); + } st.Push(0f); } } diff --git a/Assets/scripts/Data/AllyHero_SO.cs b/Assets/scripts/Data/AllyHero_SO.cs index 13885bf7..c937e411 100644 --- a/Assets/scripts/Data/AllyHero_SO.cs +++ b/Assets/scripts/Data/AllyHero_SO.cs @@ -224,8 +224,8 @@ public class AllyHero_SO : ScriptableObject equippedSkillGroupIDs = Array.Empty(); } - int currentLevelId = GetEffectiveLevelForCurrentEXP()?.levelID ?? 0; - int maxSkillSlots = Mathf.Max(0, GetEffectiveSkillSlotLimit()); + int currentLevelId = AllyHeroLevelRuntimeResolver.GetCurrentLevelId(this); + int maxSkillSlots = Mathf.Max(0, AllyHeroLevelRuntimeResolver.GetEffectiveSkillSlotLimit(this)); List validatedGroupIds = new List(Mathf.Min(equippedSkillGroupIDs.Length, maxSkillSlots)); HashSet seenGroupIds = new HashSet(); @@ -285,8 +285,45 @@ public class AllyHero_SO : ScriptableObject return true; } + // 性能:GetEffectiveEquippedSkillGroupIDs 在单次掉血/事件中会被多入口重复调用(每次都 new List+HashSet+ToArray)。 + // 用"输入快照比对"缓存返回数组:仅当影响返回值的可变输入变化时才重建。所有调用点均只读遍历返回数组, + // 故返回共享缓存安全。EXP/tier 由外部 AllyHeroDeployLedger 直接写公有字段(无法用脏标记拦截), + // 故此处按值/引用快照比对——这些比较都不分配,命中缓存时零 GC,且结果与每次重算完全一致(等效)。 + // 全局装备数据版本号。装备的换装/卸下(影响跨英雄幻象额外槽)以及对已装备装备 + // sa_skillID/ia_skillID 的“原地修改”(引用不变,单靠 _snapEquipment 引用比对无法察觉) + // 都会自增此计数,从而让所有英雄的 GetEffectiveEquippedSkillGroupIDs 缓存失效重建。 + // 这些操作都发生在菜单/养成界面,战斗中不变,故战斗热路径仍是零 GC 命中缓存。 + private static int s_equipmentDataRevision; + + // 装备数据发生“引用不变的原地变更”或“可能影响跨英雄派生结果”的变更时调用, + // 使所有英雄的装备派生缓存(有效技能组 ID)在下次读取时重算。 + public static void InvalidateEquipmentDerivedCaches() + { + s_equipmentDataRevision++; + } + + [System.NonSerialized] private int[] _cachedEffectiveGroupIds; + [System.NonSerialized] private bool _cachedGroupIdsValid; + [System.NonSerialized] private int[] _snapEquippedGroupIdsRef; // equippedSkillGroupIDs 引用(整体替换才变) + [System.NonSerialized] private int _snapCurrentEXP; + [System.NonSerialized] private int _snapUnlockedTierIndex; + [System.NonSerialized] private equipmentSO _snapEquipment; + [System.NonSerialized] private int _snapEquipmentDataRevision = -1; + public int[] GetEffectiveEquippedSkillGroupIDs() { + // 快照比对:任一影响返回值的输入变化则失效重建。 + equipmentSO currentEquipment = GetEquippedEquipmentResolved(); + if (_cachedGroupIdsValid + && _snapEquipmentDataRevision == s_equipmentDataRevision + && ReferenceEquals(_snapEquippedGroupIdsRef, equippedSkillGroupIDs) + && _snapCurrentEXP == ally_currentEXP + && _snapUnlockedTierIndex == ally_growthUnlockedTierIndex + && ReferenceEquals(_snapEquipment, currentEquipment)) + { + return _cachedEffectiveGroupIds; + } + var result = new List(); var dedupe = new HashSet(); int heroSkillSlotsRemaining = GetEffectiveSkillSlotLimit(); @@ -311,14 +348,21 @@ public class AllyHero_SO : ScriptableObject } } - equipmentSO equipment = GetEquippedEquipmentResolved(); - if (equipment != null) + if (currentEquipment != null) { - TryAddEquipmentSkillGroupId(result, dedupe, equipment.sa_skillID); - TryAddEquipmentSkillGroupId(result, dedupe, equipment.ia_skillID); + TryAddEquipmentSkillGroupId(result, dedupe, currentEquipment.sa_skillID); + TryAddEquipmentSkillGroupId(result, dedupe, currentEquipment.ia_skillID); } - return result.ToArray(); + // 写入缓存并记录本次输入快照(与上面失效判断的字段一一对应)。 + _cachedEffectiveGroupIds = result.ToArray(); + _snapEquippedGroupIdsRef = equippedSkillGroupIDs; + _snapCurrentEXP = ally_currentEXP; + _snapUnlockedTierIndex = ally_growthUnlockedTierIndex; + _snapEquipment = currentEquipment; + _snapEquipmentDataRevision = s_equipmentDataRevision; + _cachedGroupIdsValid = true; + return _cachedEffectiveGroupIds; } public int GetEffectiveSkillSlotLimit() @@ -586,11 +630,31 @@ public class AllyHero_SO : ScriptableObject return sorted[effectiveIndex]; } + // 性能:levelStats 是序列化配置,运行时不变,故排序结果可缓存,避免每次事件 new List + Sort(闭包+排序)。 + // 用 (引用, Count) 快照判失效,防止极少数运行时改配置的情况漏更新。返回缓存列表,调用方只读(仅索引读取)。 + [System.NonSerialized] private List _cachedSortedLevelStats; + [System.NonSerialized] private bool _cachedSortedLevelStatsIsNull; + [System.NonSerialized] private object _snapLevelStatsRef; + [System.NonSerialized] private int _snapLevelStatsCount = -1; + private List BuildSortedLevelStats() { + if (_cachedSortedLevelStats != null + && ReferenceEquals(_snapLevelStatsRef, levelStats) + && _snapLevelStatsCount == (levelStats != null ? levelStats.Count : 0)) + { + return _cachedSortedLevelStatsIsNull ? null : _cachedSortedLevelStats; + } + var sorted = new List(); + _snapLevelStatsRef = levelStats; + _snapLevelStatsCount = levelStats != null ? levelStats.Count : 0; + if (levelStats == null || levelStats.Count == 0) { + // 空配置:原逻辑返回空列表(非 null)。缓存该空列表。 + _cachedSortedLevelStats = sorted; + _cachedSortedLevelStatsIsNull = false; return sorted; } @@ -604,10 +668,15 @@ public class AllyHero_SO : ScriptableObject if (sorted.Count == 0) { + // 原逻辑:全为 null 时返回 null。缓存该 null 语义(仍记录快照避免每次重建)。 + _cachedSortedLevelStats = sorted; // 存空列表占位,使缓存有效 + _cachedSortedLevelStatsIsNull = true; return null; } sorted.Sort((left, right) => left.requiredEXP.CompareTo(right.requiredEXP)); + _cachedSortedLevelStats = sorted; + _cachedSortedLevelStatsIsNull = false; return sorted; } @@ -888,6 +957,8 @@ public class AllyHero_SO : ScriptableObject equippedEquipment = equipment; equippedEquipmentId = equipment != null ? equipment.name : string.Empty; equippedEquipmentHydrated = true; + // 换装可能改变本英雄及其它英雄的跨英雄幻象额外槽归属,使全体派生缓存失效。 + InvalidateEquipmentDerivedCaches(); #if UNITY_EDITOR if (!Application.isPlaying) @@ -927,6 +998,8 @@ public class AllyHero_SO : ScriptableObject equippedEquipment = null; equippedEquipmentId = string.Empty; equippedEquipmentHydrated = true; + // 卸下装备同样可能改变跨英雄幻象额外槽归属,使全体派生缓存失效。 + InvalidateEquipmentDerivedCaches(); SecureSaveVault.Delete(EquippedEquipmentSaveCategory, GetEquippedEquipmentSaveKey()); LocalRecoveryMirror.DeleteSlot(GetEquippedEquipmentRecoverySlotKey()); PlayerPrefs.DeleteKey(GetEquippedEquipmentPrefsKey()); diff --git a/Assets/scripts/UI/PauseManager.cs b/Assets/scripts/UI/PauseManager.cs index a869b7ab..b5f5eae3 100644 --- a/Assets/scripts/UI/PauseManager.cs +++ b/Assets/scripts/UI/PauseManager.cs @@ -192,6 +192,14 @@ public class PauseManager : MonoBehaviour IsPaused = false; Time.timeScale = 1f; GameplayClock.Resume(); + + // Re-anchor clock to actual music progress to correct any drift during pause + var gm = SceneObjectLookupCache.FindAny(); + if (gm != null && gm.musicSource != null && gm.musicSource.isPlaying) + { + GameplayClock.ReanchorToMusicTime(gm.musicSource.time); + } + OnPauseStateChanged?.Invoke(false); ShowOverlayAnimated(false); } @@ -211,6 +219,14 @@ public class PauseManager : MonoBehaviour IsPaused = false; Time.timeScale = 1f; GameplayClock.Resume(); + + // Re-anchor clock to actual music progress to correct any drift during pause + var gm = SceneObjectLookupCache.FindAny(); + if (gm != null && gm.musicSource != null && gm.musicSource.isPlaying) + { + GameplayClock.ReanchorToMusicTime(gm.musicSource.time); + } + OnPauseStateChanged?.Invoke(false); if (hideOverlay) diff --git a/Assets/scripts/_runtimecache/PlayerSkillService.cs b/Assets/scripts/_runtimecache/PlayerSkillService.cs index 2f24870e..ecfb8910 100644 --- a/Assets/scripts/_runtimecache/PlayerSkillService.cs +++ b/Assets/scripts/_runtimecache/PlayerSkillService.cs @@ -34,6 +34,11 @@ public sealed class PlayerSkillService : MonoBehaviour public static PlayerSkillService Instance { get; private set; } + // Monotonic counter bumped whenever the selected skill (and thus any skill-derived + // scene state) may have changed. Per-frame consumers can compare against a cached copy + // to skip re-evaluation while the selection is stable. + public static int SelectionVersion { get; private set; } + private bool initialized; private PlayerSkillSaveData saveData; private userLevel_skills_SO registeredSkillAsset; @@ -467,6 +472,10 @@ public sealed class PlayerSkillService : MonoBehaviour private void ApplySceneBindings() { + // Selection-derived scene state is being (re)applied here; advance the version so + // per-frame consumers know to re-read it once. + SelectionVersion++; + bool revealLockedSkills = IsSkillEnabledInternal(RevealLockedIdolSkillsSkillIndex); idolSkillsHub[] hubs = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); @@ -599,6 +608,9 @@ public sealed class PlayerSkillService : MonoBehaviour } saveData.selectedSkillIndex = enabledIndex; + // Selection was repaired from the asset's enabled flags without going through + // ApplySceneBindings; bump the version so per-frame consumers re-read it. + SelectionVersion++; SaveNow(); } diff --git a/Assets/scripts/effects/DissolveEffectController.cs b/Assets/scripts/effects/DissolveEffectController.cs index b23dd8df..4f49326c 100644 --- a/Assets/scripts/effects/DissolveEffectController.cs +++ b/Assets/scripts/effects/DissolveEffectController.cs @@ -51,6 +51,14 @@ public class DissolveEffectController : MonoBehaviour private float timer; private bool playing; + // Track on-screen visibility so the continuous OutlinePulse write is skipped when the + // renderer is offscreen (nothing would be seen anyway). Assume visible until Unity tells + // us otherwise, so effects on objects that never entered the culling system still animate. + private bool isVisible = true; + + private void OnBecameVisible() { isVisible = true; } + private void OnBecameInvisible() { isVisible = false; } + private void Awake() { targetRenderer = GetComponent(); @@ -85,7 +93,7 @@ public class DissolveEffectController : MonoBehaviour { if (effect == Effect.OutlinePulse) { - UpdatePulse(); + if (isVisible) UpdatePulse(); return; } diff --git a/Assets/scripts/gamePlay_gameplay/Animation_GenerateJudgementSituationPrefab.cs b/Assets/scripts/gamePlay_gameplay/Animation_GenerateJudgementSituationPrefab.cs index 5ff17668..bf8f1c5f 100644 --- a/Assets/scripts/gamePlay_gameplay/Animation_GenerateJudgementSituationPrefab.cs +++ b/Assets/scripts/gamePlay_gameplay/Animation_GenerateJudgementSituationPrefab.cs @@ -344,9 +344,13 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour if (obj == null) yield break; if (settings == null) settings = judgePopup; - SpriteRenderer[] renderers = obj.GetComponentsInChildren(true); - Graphic[] graphics = obj.GetComponentsInChildren(true); - CanvasGroup canvasGroup = obj.GetComponent(); + // Pooled popup instances keep the same child renderer/graphic set for their lifetime, + // so cache the (otherwise-allocating) GetComponentsInChildren sweeps on the instance + // and reuse them on every replay instead of re-scanning per judgement. + PopupRenderableCache cache = GetPopupRenderableCache(obj); + SpriteRenderer[] renderers = cache.renderers; + Graphic[] graphics = cache.graphics; + CanvasGroup canvasGroup = cache.canvasGroup; float elapsed = 0f; float vVelocity = settings.jumpForce; @@ -433,6 +437,28 @@ public class Animation_GenerateJudgementSituationPrefab : MonoBehaviour } } + // Caches the child SpriteRenderer/Graphic arrays + CanvasGroup of a pooled popup instance + // so AnimatePopup doesn't re-run allocating GetComponentsInChildren on every replay. + private sealed class PopupRenderableCache : MonoBehaviour + { + public SpriteRenderer[] renderers; + public Graphic[] graphics; + public CanvasGroup canvasGroup; + } + + private static PopupRenderableCache GetPopupRenderableCache(GameObject obj) + { + var cache = obj.GetComponent(); + if (cache == null) + { + cache = obj.AddComponent(); + cache.renderers = obj.GetComponentsInChildren(true); + cache.graphics = obj.GetComponentsInChildren(true); + cache.canvasGroup = obj.GetComponent(); + } + return cache; + } + private static void SetRenderersAlpha(SpriteRenderer[] renderers, float alpha) { if (renderers == null) diff --git a/Assets/scripts/gamePlay_gameplay/ChartModifiers.cs b/Assets/scripts/gamePlay_gameplay/ChartModifiers.cs new file mode 100644 index 00000000..9d5154e6 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/ChartModifiers.cs @@ -0,0 +1,126 @@ +using UnityEngine; + +/// +/// 谱面修饰符:Mirror(镜像)/ Random(随机打乱轨道)。 +/// 纯视觉重映射 trackIndex,对判定/计分完全等效(业务逻辑不变)。 +/// PlayerPrefs 持久化,默认全部关闭。 +/// +public static class ChartModifiers +{ + private const string PREF_MIRROR = "ChartModifier_Mirror"; + private const string PREF_RANDOM = "ChartModifier_Random"; + + // 当前会话的映射表缓存(Random 需要在谱面加载时固定一次,整局不变)。 + private static int[] cachedMapping = null; + private static bool cachedMappingDirty = true; + + /// + /// Mirror 开关(默认 false)。镜像 5 轨:0↔4, 1↔3, 2 不变。 + /// + public static bool MirrorEnabled + { + get => PlayerPrefs.GetInt(PREF_MIRROR, 0) == 1; + set + { + PlayerPrefs.SetInt(PREF_MIRROR, value ? 1 : 0); + PlayerPrefs.Save(); + cachedMappingDirty = true; + } + } + + /// + /// Random 开关(默认 false)。将 5 轨随机打乱(每次加载谱面时重新洗牌一次)。 + /// + public static bool RandomEnabled + { + get => PlayerPrefs.GetInt(PREF_RANDOM, 0) == 1; + set + { + PlayerPrefs.SetInt(PREF_RANDOM, value ? 1 : 0); + PlayerPrefs.Save(); + cachedMappingDirty = true; + } + } + + /// + /// 标记映射表需要重建(谱面重新加载时调用)。 + /// + public static void InvalidateMapping() + { + cachedMappingDirty = true; + } + + /// + /// 获取当前生效的 trackIndex 映射表。输入原始 trackIndex,返回重映射后的 trackIndex。 + /// 优先级:Random > Mirror > 原值。Random 会在首次调用时固定洗牌结果(整局不变)。 + /// + public static int RemapTrackIndex(int originalIndex) + { + if (originalIndex < 0 || originalIndex >= 5) return originalIndex; + + if (!MirrorEnabled && !RandomEnabled) return originalIndex; + + if (cachedMappingDirty) + { + RebuildMapping(); + cachedMappingDirty = false; + } + + return cachedMapping[originalIndex]; + } + + private static void RebuildMapping() + { + if (cachedMapping == null || cachedMapping.Length != 5) + cachedMapping = new int[5]; + + // 初始为恒等映射 + for (int i = 0; i < 5; i++) cachedMapping[i] = i; + + // Random 优先(若开启则整体洗牌,覆盖 Mirror) + if (RandomEnabled) + { + // Fisher-Yates shuffle + System.Random rng = new System.Random(); + for (int i = 4; i > 0; i--) + { + int j = rng.Next(i + 1); + int temp = cachedMapping[i]; + cachedMapping[i] = cachedMapping[j]; + cachedMapping[j] = temp; + } + } + // Mirror(仅在 Random 关闭时生效) + else if (MirrorEnabled) + { + cachedMapping[0] = 4; + cachedMapping[1] = 3; + cachedMapping[2] = 2; // 中轨不变 + cachedMapping[3] = 1; + cachedMapping[4] = 0; + } + } + + /// + /// 反向映射:输入重映射后的 trackIndex,返回原始 trackIndex(用于输入事件还原)。 + /// 当前项目输入事件已经按 trackIndex 匹配,通常无需反向映射。 + /// + public static int UnmapTrackIndex(int remappedIndex) + { + if (remappedIndex < 0 || remappedIndex >= 5) return remappedIndex; + if (!MirrorEnabled && !RandomEnabled) return remappedIndex; + + if (cachedMappingDirty) + { + RebuildMapping(); + cachedMappingDirty = false; + } + + for (int i = 0; i < 5; i++) + { + if (cachedMapping[i] == remappedIndex) return i; + } + + return remappedIndex; + } +} diff --git a/Assets/scripts/gamePlay_gameplay/ChartModifiers.cs.meta b/Assets/scripts/gamePlay_gameplay/ChartModifiers.cs.meta new file mode 100644 index 00000000..9874c352 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/ChartModifiers.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76f1a8eecfc581440a92d681be524071 \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/GameManager.cs b/Assets/scripts/gamePlay_gameplay/GameManager.cs index 5b31695a..3095c86f 100644 --- a/Assets/scripts/gamePlay_gameplay/GameManager.cs +++ b/Assets/scripts/gamePlay_gameplay/GameManager.cs @@ -226,7 +226,25 @@ public class GameManager : MonoBehaviour private void BeginGameplayClock() { GameplayClock.Reset(); - GameplayClock.StartChart(0f); + + // 练习模式:若开启,从指定的起始时间启动时钟(供段落练习/A-B 循环) + float startTime = PracticeMode.Enabled ? PracticeMode.StartTimeSeconds : 0f; + GameplayClock.StartChart(startTime); + + if (PracticeMode.Enabled && startTime > 0f) + { + Debug.Log($"[GameManager] Practice mode: starting chart at {startTime:F2}s"); + } + } + + // 玩家级音频偏移(补偿音频输出延迟,如蓝牙耳机)。存 PlayerPrefs,单位秒,通常为负值(让音乐更早输出)。 + // 与谱面自带的 globalDelaySeconds(谱师校准音频开头)彼此独立、相加:谱面偏移随谱走,玩家偏移随设备走。 + // 只移动音乐播放时刻,绝不改音符视觉/判定时刻 —— 这是补偿音频延迟唯一不污染判定的手段(参照成熟音游 UO/global audio offset)。 + public const string AudioOffsetPrefsKey = "UserAudioOffsetSeconds"; + + public static float GetUserAudioOffsetSeconds() + { + return PlayerPrefs.GetFloat(AudioOffsetPrefsKey, 0f); } // Public helper to reliably unmute and start music playback, respecting delay. @@ -235,6 +253,9 @@ public class GameManager : MonoBehaviour // reset flag until actual play occurs PlaybackStarted = false; + // 叠加玩家音频偏移。放在方法入口,保证所有调用点(谱面 globalDelaySeconds 各路径)统一生效、不遗漏。 + delaySeconds += GetUserAudioOffsetSeconds(); + // Stop any previous delay coroutine if (playbackDelayCoroutine != null) { @@ -243,15 +264,26 @@ public class GameManager : MonoBehaviour } if (musicSource == null) return; - + // Documentation text normalized. try { musicSource.Stop(); musicSource.time = 0f; + + // 练习模式:若从中间起播,提前将音乐 seek 到起始位置 + if (PracticeMode.Enabled) + { + float practiceStart = PracticeMode.StartTimeSeconds; + if (practiceStart > 0f) + { + musicSource.time = Mathf.Clamp(practiceStart, 0f, musicSource.clip != null ? musicSource.clip.length : 999f); + Debug.Log($"[GameManager] Practice mode: music seeked to {musicSource.time:F2}s"); + } + } } catch { } - + try { // Documentation text normalized. @@ -266,29 +298,49 @@ public class GameManager : MonoBehaviour if (delaySeconds < 0f) startTime = -delaySeconds; + float clampedStartTime = ClampAudioStartTime(startTime); + + // Use PlayScheduled to precisely anchor music start to a future dspTime. + // This eliminates the per-frame jitter from DelayAndPlayMusic's unscaledDeltaTime accumulation. if (delaySeconds > 0f) { - playbackDelayCoroutine = StartCoroutine(DelayAndPlayMusic(delaySeconds)); + // Schedule music to start at dspTime = now + delaySeconds + double scheduledDsp = AudioSettings.dspTime + delaySeconds; + musicSource.time = clampedStartTime; + musicSource.PlayScheduled(scheduledDsp); + PlaybackStarted = true; + + // Anchor game clock to this scheduled start so note timing matches actual audio output + GameplayClock.SetMusicAnchor(scheduledDsp - clampedStartTime); + + Debug.Log($"GameManager.PlayMusicWithDelay: Music scheduled at dsp={scheduledDsp:F3} (delay={delaySeconds:F3}s, startTime={clampedStartTime:F3})"); } else { - float clampedStartTime = ClampAudioStartTime(startTime); + // Play immediately musicSource.time = clampedStartTime; + double playDsp = AudioSettings.dspTime; musicSource.Play(); PlaybackStarted = true; - Debug.Log("GameManager.PlayMusicWithDelay: Music playback started immediately (no delay)"); + + // Anchor: music started at playDsp, playing from clampedStartTime + GameplayClock.SetMusicAnchor(playDsp - clampedStartTime); + + Debug.Log($"GameManager.PlayMusicWithDelay: Music started immediately at dsp={playDsp:F3}, musicTime={clampedStartTime:F3}"); } } catch (System.Exception ex) { Debug.LogWarning($"PlayMusicWithDelay failed: {ex}"); - try - { + try + { float clampedStartTime = ClampAudioStartTime(delaySeconds < 0f ? -delaySeconds : 0f); musicSource.time = clampedStartTime; + double playDsp = AudioSettings.dspTime; musicSource.Play(); PlaybackStarted = true; - } + GameplayClock.SetMusicAnchor(playDsp - clampedStartTime); + } catch { } } } @@ -540,6 +592,23 @@ public class GameManager : MonoBehaviour private void Update() { + // 练习模式:检查是否到达终点,需要结束或循环 + if (PlaybackStarted && PracticeMode.Enabled && PracticeMode.ShouldEndOrLoop(GameplayClock.NowSongTime)) + { + if (PracticeMode.LoopEnabled) + { + Debug.Log("[GameManager] Practice mode: looping back to start"); + RestartPracticeLoop(); + } + else + { + Debug.Log("[GameManager] Practice mode: reached end, stopping"); + // 可选:跳转结算或停止播放。当前仅停止,供后续接入 + if (musicSource != null) musicSource.Stop(); + PlaybackStarted = false; + } + } + // Detect Space or yellow note key to request start (only before gameplay starts) if (!startRequested) { @@ -557,6 +626,32 @@ public class GameManager : MonoBehaviour // Once startRequested is true, Escape is disabled and gameplay begins countdown } + /// + /// 练习模式 A-B 循环:重新加载场景并从起始位置继续。 + /// 供后续快速接入(当前仅重置时钟+音乐,未重新生成音符——需配合 NoteSpawner 重启)。 + /// + private void RestartPracticeLoop() + { + // 简单实现:重置时钟和音乐到起点。完整实现需清理已生成音符、重新加载谱面。 + // 当前作为脚手架,供你后续接入完整的场景重启或音符池清理逻辑。 + PlaybackStarted = false; + + if (musicSource != null) + { + musicSource.Stop(); + } + + BeginGameplayClock(); + + // 重新播放音乐(带练习模式起点 seek) + // BeatmapManager.globalDelaySeconds 是 public 字段,直接读取 + float globalDelay = beatmapManager != null ? beatmapManager.globalDelaySeconds : 0f; + PlayMusicWithDelay(globalDelay); + + // TODO: 清理已生成的音符、重启 NoteSpawner.LoadBeatmap(beatmap) 以重新生成从起点开始的音符。 + // 当前脚手架未实现完整的音符清理,仅提供时钟+音乐循环框架。 + } + private bool IsYellowStartKeyDown() { if (Input.GetKeyDown(KeyCode.Space)) return true; diff --git a/Assets/scripts/gamePlay_gameplay/GameplayClock.cs b/Assets/scripts/gamePlay_gameplay/GameplayClock.cs index 24c6d7f9..e45fcd2f 100644 --- a/Assets/scripts/gamePlay_gameplay/GameplayClock.cs +++ b/Assets/scripts/gamePlay_gameplay/GameplayClock.cs @@ -8,11 +8,25 @@ public static class GameplayClock private static double pauseStartDspTime; private static double accumulatedPauseSeconds; private static float chartStartSongTime; + private static double scheduledMusicStartDsp; // anchor: when music actually starts playing + // Seconds the chart clock leads the music clip position by (== globalDelaySeconds + userAudioOffset). + // Captured when music is scheduled; used to preserve the songTime = musicTime + offset relationship + // when re-anchoring to actual music progress after a pause. + private static double musicToChartOffsetSeconds; + + // Per-frame cache of NowSongTime. AudioSettings.dspTime only advances on the audio + // thread (~once per audio buffer), so it is effectively constant within a rendered + // frame. Many notes/segments read NowSongTime every frame; caching by frame turns + // hundreds of native dspTime reads per frame into one. Invalidated when the frame + // changes or clock state mutates (Reset/StartChart/Pause/Resume). + private static int nowSongTimeFrame = -1; + private static float nowSongTimeCached; public static bool IsInitialized => initialized; public static bool IsPaused => paused; public static float ChartStartSongTime => chartStartSongTime; public static double ChartStartDspTime => chartStartDspTime; + public static double ScheduledMusicStartDsp => scheduledMusicStartDsp; public static void Reset() { @@ -22,6 +36,9 @@ public static class GameplayClock pauseStartDspTime = 0d; accumulatedPauseSeconds = 0d; chartStartSongTime = 0f; + scheduledMusicStartDsp = 0d; + musicToChartOffsetSeconds = 0d; + nowSongTimeFrame = -1; } public static void StartChart(float initialSongTimeSeconds = 0f) @@ -32,6 +49,27 @@ public static class GameplayClock pauseStartDspTime = 0d; accumulatedPauseSeconds = 0d; chartStartSongTime = Mathf.Max(0f, initialSongTimeSeconds); + scheduledMusicStartDsp = 0d; // will be set by SetMusicAnchor when music plays + musicToChartOffsetSeconds = 0d; + nowSongTimeFrame = -1; + } + + /// + /// Record the scheduled music start time for reference. + /// Call this immediately after PlayScheduled or Play. + /// NOTE: Does NOT re-anchor chartStartDspTime — the chart clock stays pinned to gameplay start (StartChart), + /// and music simply plays offset into the future. Re-anchoring would shift all note timing by globalDelaySeconds. + /// + public static void SetMusicAnchor(double musicStartDspTime) + { + if (!initialized) return; + scheduledMusicStartDsp = musicStartDspTime; + // Do NOT re-anchor chartStartDspTime here — note spawn/hit times were computed from the original anchor. + // Re-anchoring would create a systematic offset equal to globalDelaySeconds. + // Capture the constant lead of chart song-time over music-clip time: songTime(D) - musicTime(D), + // where musicTime(D) = D - musicStartDspTime. This lets ReanchorToMusicTime preserve the relationship. + musicToChartOffsetSeconds = chartStartSongTime - chartStartDspTime - accumulatedPauseSeconds + musicStartDspTime; + nowSongTimeFrame = -1; } public static void Pause() @@ -43,6 +81,7 @@ public static class GameplayClock paused = true; pauseStartDspTime = AudioSettings.dspTime; + nowSongTimeFrame = -1; } public static void Resume() @@ -55,6 +94,24 @@ public static class GameplayClock accumulatedPauseSeconds += AudioSettings.dspTime - pauseStartDspTime; pauseStartDspTime = 0d; paused = false; + nowSongTimeFrame = -1; + } + + /// + /// Re-anchor the clock to the actual music progress on resume. + /// Call this after Resume() if musicSource.time is available, to correct any drift. + /// + public static void ReanchorToMusicTime(float actualMusicTime) + { + if (!initialized || paused) return; + // Reverse-calculate chartStartDspTime so that song-time now equals the music clip position + // PLUS the constant lead (globalDelaySeconds/audio offset) captured at SetMusicAnchor. + // Without adding musicToChartOffsetSeconds, resuming would force songTime == musicTime and + // reintroduce the exact backward offset the SetMusicAnchor fix removed. + double now = AudioSettings.dspTime; + double targetSongTime = actualMusicTime + musicToChartOffsetSeconds; + chartStartDspTime = now - accumulatedPauseSeconds - (targetSongTime - chartStartSongTime); + nowSongTimeFrame = -1; } public static float NowSongTime @@ -66,9 +123,21 @@ public static class GameplayClock return 0f; } + // While paused the value is frozen (pauseStartDspTime), so it's trivially stable; + // while playing, reuse the value already computed this frame. + int frame = Time.frameCount; + if (frame == nowSongTimeFrame) + { + return nowSongTimeCached; + } + double currentDsp = paused ? pauseStartDspTime : AudioSettings.dspTime; double elapsed = currentDsp - chartStartDspTime - accumulatedPauseSeconds; - return chartStartSongTime + Mathf.Max(0f, (float)elapsed); + float result = chartStartSongTime + Mathf.Max(0f, (float)elapsed); + + nowSongTimeFrame = frame; + nowSongTimeCached = result; + return result; } } diff --git a/Assets/scripts/gamePlay_gameplay/GameplayInputActions.cs b/Assets/scripts/gamePlay_gameplay/GameplayInputActions.cs new file mode 100644 index 00000000..8458adf1 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/GameplayInputActions.cs @@ -0,0 +1,207 @@ +using UnityEngine; +using UnityEngine.InputSystem; +using System; + +/// +/// Manages gameplay input via Unity's Input System, creating InputActions at runtime +/// and capturing sub-frame press timestamps (AudioSettings.dspTime) for precise judgement. +/// Replaces legacy Input.GetKeyDown polling with event-driven callbacks. +/// +public class GameplayInputActions : MonoBehaviour +{ + public static GameplayInputActions Instance { get; private set; } + + // Track press/release events. Args: (trackIndex, dspTime at the input event) for sub-frame precision. + public static event Action OnTrackPressed; + public static event Action OnTrackReleased; + + private InputAction[] trackActions = new InputAction[5]; + private InputAction secondaryTrack3Action; + private bool isEnabled; + + private void Awake() + { + if (Instance == null) + { + Instance = this; + } + else + { + Destroy(gameObject); + return; + } + + CreateActions(); + } + + private void OnEnable() + { + EnableActions(); + } + + private void OnDisable() + { + DisableActions(); + } + + private void OnDestroy() + { + DisableActions(); + DisposeActions(); + + if (Instance == this) + Instance = null; + } + + /// + /// Create InputActions at runtime and bind them to the keys from KeyBindingManager. + /// Called once at Awake. + /// + private void CreateActions() + { + string[] colors = { "red", "green", "yellow", "purple", "blue" }; + + for (int i = 0; i < 5; i++) + { + trackActions[i] = new InputAction($"Track{i}", InputActionType.Button); + string bindingPath = KeyBindingManager.GetBindingPathForTrack(i); + if (!string.IsNullOrEmpty(bindingPath)) + { + trackActions[i].AddBinding(bindingPath); + } + + int trackIndex = i; // capture for closure + trackActions[i].started += ctx => HandleTrackPress(trackIndex, ctx); + trackActions[i].canceled += ctx => HandleTrackRelease(trackIndex, ctx); + } + + // Secondary track 3 key (yellow副键) + secondaryTrack3Action = new InputAction("Track3Secondary", InputActionType.Button); + string secondaryPath = KeyBindingManager.GetSecondaryTrack3BindingPath(); + if (!string.IsNullOrEmpty(secondaryPath)) + { + secondaryTrack3Action.AddBinding(secondaryPath); + } + secondaryTrack3Action.started += ctx => HandleTrackPress(2, ctx); // track 2 = yellow + secondaryTrack3Action.canceled += ctx => HandleTrackRelease(2, ctx); + } + + private void EnableActions() + { + if (isEnabled) return; + + foreach (var action in trackActions) + { + action?.Enable(); + } + secondaryTrack3Action?.Enable(); + + isEnabled = true; + } + + private void DisableActions() + { + if (!isEnabled) return; + + foreach (var action in trackActions) + { + action?.Disable(); + } + secondaryTrack3Action?.Disable(); + + isEnabled = false; + } + + private void DisposeActions() + { + foreach (var action in trackActions) + { + action?.Dispose(); + } + secondaryTrack3Action?.Dispose(); + } + + /// + /// Refresh bindings from KeyBindingManager after user rebinds a key. + /// Uses ApplyBindingOverride so the action objects (and their event subscriptions) stay intact; + /// each action has exactly one binding (index 0) created in CreateActions. + /// + public void RefreshBindings() + { + bool wasEnabled = isEnabled; + DisableActions(); + + for (int i = 0; i < 5; i++) + { + if (trackActions[i] == null) continue; + string path = KeyBindingManager.GetBindingPathForTrack(i); + ApplySingleBinding(trackActions[i], path); + } + + if (secondaryTrack3Action != null) + { + string secondaryPath = KeyBindingManager.GetSecondaryTrack3BindingPath(); + ApplySingleBinding(secondaryTrack3Action, secondaryPath); + } + + if (wasEnabled) EnableActions(); + } + + // Set an action's single binding path. Actions are created with exactly one binding (index 0); + // if none exists yet (empty path at creation), add one now. + private static void ApplySingleBinding(InputAction action, string path) + { + if (string.IsNullOrEmpty(path)) return; + + if (action.bindings.Count == 0) + { + action.AddBinding(path); + } + else + { + action.ApplyBindingOverride(0, path); + } + } + + private void HandleTrackPress(int trackIndex, InputAction.CallbackContext context) + { + // Sub-frame precise timestamp: convert the Input System event time (realtime base) + // into the dspTime axis the whole judgement chain already runs on. + OnTrackPressed?.Invoke(trackIndex, ResolveEventDspTime(context)); + } + + private void HandleTrackRelease(int trackIndex, InputAction.CallbackContext context) + { + OnTrackReleased?.Invoke(trackIndex, ResolveEventDspTime(context)); + } + + // Upper bound (seconds) on how far back an event timestamp may sit before we distrust it. + // Real input latency between the event occurring and this callback is a few ms at most; + // anything larger means the timestamp is unusable (e.g. synthetic/queued event) and we + // fall back to the previous behaviour for exact equivalence. + private const double MaxEventLatencySeconds = 0.05; + + /// + /// Convert an Input System event time (Time.realtimeSinceStartup base) to the + /// AudioSettings.dspTime axis used by GameplayClock. The event physically occurred + /// `latency` seconds before this callback runs, so the equivalent dsp instant is + /// dspTime(now) - latency. Recovers sub-audio-buffer precision without introducing any + /// systematic offset. Degrades EXACTLY to AudioSettings.dspTime (the prior behaviour) + /// whenever the event timestamp is missing or out of a sane range — business logic equivalent. + /// + private static double ResolveEventDspTime(InputAction.CallbackContext context) + { + double dspNow = AudioSettings.dspTime; + + double eventTime = context.time; + if (eventTime <= 0d) return dspNow; // no usable timestamp + + double realtimeNow = Time.realtimeSinceStartupAsDouble; + double latency = realtimeNow - eventTime; + + // Negative (clock skew) or implausibly large latency → distrust, keep old behaviour. + if (latency < 0d || latency > MaxEventLatencySeconds) return dspNow; + + return dspNow - latency; + } +} diff --git a/Assets/scripts/gamePlay_gameplay/GameplayInputActions.cs.meta b/Assets/scripts/gamePlay_gameplay/GameplayInputActions.cs.meta new file mode 100644 index 00000000..7b37707a --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/GameplayInputActions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37756526f634f014e8c2ad870f607eda \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/GfxPooledItem.cs b/Assets/scripts/gamePlay_gameplay/GfxPooledItem.cs new file mode 100644 index 00000000..3ffea9c5 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/GfxPooledItem.cs @@ -0,0 +1,91 @@ +using UnityEngine; + +/// +/// 挂在被 GfxController 池化的特效实例上,记录它来自哪个 prefab, +/// 并在复用前把会被逐次修改的可视状态(粒子起始色/循环、拖尾渐变、材质属性块) +/// 还原到首次实例化时的原始值,保证复用后的视觉与 Instantiate 全新实例完全等价。 +/// +public class GfxPooledItem : MonoBehaviour +{ + public GameObject prefabKey; + public GfxController owner; + + // 防止同一实例被重复回收(粒子回调 + 计时回收等路径)。 + [System.NonSerialized] public bool released; + + private ParticleSystem[] _particles; + private ParticleSystem.MinMaxGradient[] _origStartColors; + private bool[] _origLoops; + + private TrailRenderer[] _trails; + private Gradient[] _origTrailGradients; + + private Renderer[] _renderers; + + private bool _captured; + + /// 首次实例化后调用一次,快照原始可视状态。 + public void CaptureOriginals() + { + _particles = GetComponentsInChildren(true); + _origStartColors = new ParticleSystem.MinMaxGradient[_particles.Length]; + _origLoops = new bool[_particles.Length]; + for (int i = 0; i < _particles.Length; i++) + { + var main = _particles[i].main; + _origStartColors[i] = main.startColor; + _origLoops[i] = main.loop; + } + + _trails = GetComponentsInChildren(true); + _origTrailGradients = new Gradient[_trails.Length]; + for (int i = 0; i < _trails.Length; i++) + { + _origTrailGradients[i] = CloneGradient(_trails[i].colorGradient); + } + + _renderers = GetComponentsInChildren(true); + _captured = true; + } + + /// 复用前还原到原始状态。 + public void ResetToOriginal() + { + if (!_captured) return; + + for (int i = 0; i < _particles.Length; i++) + { + if (_particles[i] == null) continue; + var main = _particles[i].main; + main.startColor = _origStartColors[i]; + main.loop = _origLoops[i]; + } + + for (int i = 0; i < _trails.Length; i++) + { + if (_trails[i] == null) continue; + _trails[i].Clear(); + _trails[i].colorGradient = CloneGradient(_origTrailGradients[i]); + } + + for (int i = 0; i < _renderers.Length; i++) + { + if (_renderers[i] != null) _renderers[i].SetPropertyBlock(null); + } + } + + // 粒子播放完毕(root 的 stopAction 设为 Callback)时回收整个特效。 + private void OnParticleSystemStopped() + { + if (owner != null) owner.ReleaseFx(gameObject); + } + + private static Gradient CloneGradient(Gradient src) + { + if (src == null) return null; + var g = new Gradient(); + g.SetKeys(src.colorKeys, src.alphaKeys); + g.mode = src.mode; + return g; + } +} diff --git a/Assets/scripts/gamePlay_gameplay/GfxPooledItem.cs.meta b/Assets/scripts/gamePlay_gameplay/GfxPooledItem.cs.meta new file mode 100644 index 00000000..ab2a3fb4 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/GfxPooledItem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e87e3050581cbb448fbd5c29555ef96 \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/HoldNote.cs b/Assets/scripts/gamePlay_gameplay/HoldNote.cs index 811a4bd4..2e6bf19b 100644 --- a/Assets/scripts/gamePlay_gameplay/HoldNote.cs +++ b/Assets/scripts/gamePlay_gameplay/HoldNote.cs @@ -42,8 +42,14 @@ public class HoldNote : BaseNote private string queueNoteId = null; private bool isQueued = false; + // Cached instance-id string used by NotePool to unregister without re-allocating. + // May be null if this segment never registered a queue id (e.g. non-start segment). + public string GetQueueNoteId() => queueNoteId; + public int id; public int trackIndex; + // Reused by EvaluateHoldEnd's skill-trigger dispatch to avoid a per-hold-end List allocation. + private readonly List _holdEndCandidatesBuffer = new List(4); public float speed; public float startTime; public float delay; @@ -65,6 +71,40 @@ public class HoldNote : BaseNote // Input.GetKeyDown/Up directly (which touch cannot drive on Android). private bool prevTrackHeld = false; + // Latest press/release dspTime captured from the Input System (via InputManager events) + // for this note's track. When the polled keyDown/keyUp edge fires this frame, we convert + // the captured dspTime → song time for sub-frame-accurate timing instead of reading the + // frame-quantized NowSongTime. Falls back to NowSongTime if no event fired (e.g. touch + // paths that don't carry a distinct timestamp still work via the AudioSettings.dspTime default). + private double lastPressDspTime = double.NaN; + private double lastReleaseDspTime = double.NaN; + + private void OnTrackPressedDsp(int track, double dspTime) + { + if (track == trackIndex) lastPressDspTime = dspTime; + } + + private void OnTrackReleasedDsp(int track, double dspTime) + { + if (track == trackIndex) lastReleaseDspTime = dspTime; + } + + // Convert the most-recent captured press dspTime to song time; fall back to NowSongTime. + private float PressSongTime() + { + return double.IsNaN(lastPressDspTime) + ? GameplayClock.NowSongTime + : GameplayClock.SongTimeFromDsp(lastPressDspTime); + } + + // Convert the most-recent captured release dspTime to song time; fall back to NowSongTime. + private float ReleaseSongTime() + { + return double.IsNaN(lastReleaseDspTime) + ? GameplayClock.NowSongTime + : GameplayClock.SongTimeFromDsp(lastReleaseDspTime); + } + // Query the shared held-state table (keyboard OR touch). Falls back to legacy // Input.GetKey if the InputManager is somehow absent so editor/standalone still works. private bool IsHeld() @@ -96,6 +136,26 @@ public class HoldNote : BaseNote // track when the hold actually started (real-time) so we can compute held fraction private float holdStartTime = -1f; + // ==== 单条 Tiled 长音符 body(纯视觉,不参与任何判定/计分) ==== + // 原本中段由 N 个 middle 段密铺组成;本 body 用一条 Tiled sprite 覆盖 head→end 的整段, + // 按绝对时间下落,底边在判定线处被"消耗"(drain),end 到线时回收。 + // 它绝不注册 JudgeManager/TrackKeyManager 状态,start/end 判定路径完全不受影响。 + // 抗失真关键:Tiled 会按 sprite 原生像素在"局部空间"平铺,再乘 transform 世界缩放; + // prefab 里烘焙的非等比 Y 缩放(如 3.75)会把每块拉长导致失真。故 body 实例把渲染器 + // transform 的 Y 缩放归一为与 X 相同(等比),长度改由 SpriteRenderer.size.y 承载(世界单位)。 + private bool bodyMode = false; + private bool bodyReturning = false; + private float bodyTravelTime; + private float bodyHeadActivationTime; // head 发车的绝对歌曲时间(= baseHit - travelTime) + private float bodyLengthSeconds; // 长音符时长(秒) + private float bodySpeed; // 世界速度(单位/秒) + private float bodySpawnX, bodySpawnZ; // 车道原点(x,z 固定) + private float bodySpawnY; // 发车高度(head 在 activation 时刻的 Y) + private SpriteRenderer[] bodyRenderers; + private SpriteDrawMode[] bodyRendererOrigDrawMode; + private Vector2[] bodyRendererOrigSize; + private Vector3[] bodyRendererOrigLocalScale; + // Cache allies per track to avoid GameObject.Find on first judgement. private static AllyCombatant[] allyCache; private static BeatmapManager beatmapManagerCache; @@ -335,6 +395,8 @@ public class HoldNote : BaseNote private void OnDisable() { prevTrackHeld = false; + InputManager.OnTrackPressedWithDspTime -= OnTrackPressedDsp; + InputManager.OnTrackReleasedWithDspTime -= OnTrackReleasedDsp; } private void OnDestroy() @@ -360,6 +422,8 @@ public class HoldNote : BaseNote private void OnEnable() { ResetState(); + InputManager.OnTrackPressedWithDspTime += OnTrackPressedDsp; + InputManager.OnTrackReleasedWithDspTime += OnTrackReleasedDsp; } private void PlayHitAnimation() @@ -459,7 +523,7 @@ public class HoldNote : BaseNote } else { - if (JudgeManager.IsDebugEnabled) Debug.Log($"HoldNoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}"); + if (JudgeManager.IsDebugEnabled) Debug.Log($"HoldNoteJudgeConfig: perfectE={judgeConfig.perfectEarlyRange} perfectL={judgeConfig.perfectLateRange}, greatE={judgeConfig.greatEarlyRange} greatL={judgeConfig.greatLateRange}, goodE={judgeConfig.goodEarlyRange} goodL={judgeConfig.goodLateRange}, miss={judgeConfig.missRange}"); } this.noteData = noteData; @@ -508,6 +572,13 @@ public class HoldNote : BaseNote private void Update() { + // 纯视觉 body:只更新位置/尺寸(drain),不进入任何判定逻辑。 + if (bodyMode) + { + UpdateBodyVisual(GameplayClock.NowSongTime); + return; + } + if (isJudged) return; var jm = cachedJudgeManager; @@ -525,14 +596,25 @@ public class HoldNote : BaseNote bool keyUp = !keyHeld && prevTrackHeld; prevTrackHeld = keyHeld; + // Optimization: Early exit if the note is far from judgment line and hasn't entered yet + if (!hasEnteredLine) + { + // For all segments, if they haven't triggered collision yet and are far away, skip input checks + // We use travelTime as a safe buffer. Only check input if note is within 0.5s of hitTime. + if (now < hitTime - 0.5f) return; + } + // 池化泄漏兜底:不依赖 hasEnteredLine / 几何进入离开事件。 - // 正常情况下 middle/start/end 由 HandleZoneEnter/Exit(几何驱动)或第 556 行的 + // 正常情况下 middle/start/end 由 HandleZoneEnter/Exit(几何驱动)或下方的 // in-zone auto-miss 负责回收;但当几何阈值未就绪、或帧尖峰导致音符 Y 未被采样到判定区内时, // 上述事件可能永不触发,音符会一直下落不回池,严重拖累性能(尤其 useMultiSegmentFill 使 // 中段数量随速度倍增)。此兜底在"正常判定窗口 + backstopBuffer"之后才触发,严格晚于所有正常 // 路径,因此对正常音符零行为改变——仅回收那些几何事件从未触发、否则永远泄漏的段。 + // 【性能】本 block 放在上方"远距离早退"之后:兜底触发要求 now > hitTime(或 scheduledEndTime) + // + missRange + 0.5,而早退只在 now < hitTime - 0.5 时发生,两条件互斥,故早退帧本就不会命中 + // 任何兜底分支。移到早退后仅避免对数百个"离判定线尚远"的段每帧空算,行为与之前完全等效。 { - float missRangeBackstop = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; + float missRangeBackstop = (judgeConfig != null ? judgeConfig.missRange : 0.2f) * holdWindowMultiplier; const float backstopBuffer = 0.5f; bool startJudgedBs = jm != null && jm.IsStartJudged(noteID); @@ -541,9 +623,9 @@ public class HoldNote : BaseNote if (scheduledEndTime > 0f && now > scheduledEndTime + missRangeBackstop + backstopBuffer) { // 兜底触发时音符早已越过判定线,几何仅是漏采样。置 hasEnteredLine 让 ReturnToPool - // 的 Start 守卫(第 1519 行,拒收未入线 Start)不再阻挡回收。 + // 的 Start 守卫(拒收未入线 Start)不再阻挡回收。 hasEnteredLine = true; - // 已按住到尾部的 End 早已在第 677 行按 Perfect 自动完成并 return; + // 已按住到尾部的 End 早已在下方按 Perfect 自动完成并 return; // 走到这里说明几何事件从未触发。按原 HandleZoneExit(End) 的收尾语义处理。 bool releasedBs = jm != null && jm.HasNoteReleased(noteID); if (!startJudgedBs) @@ -562,10 +644,10 @@ public class HoldNote : BaseNote else if (hitTime > 0f && now > hitTime + missRangeBackstop + backstopBuffer) { // 兜底触发时音符早已越过判定线,几何仅是漏采样。置 hasEnteredLine 让 ReturnToPool - // 的 Start 守卫(第 1519 行,拒收未入线 Start)不再阻挡回收。 + // 的 Start 守卫(拒收未入线 Start)不再阻挡回收。 hasEnteredLine = true; - // Start 未判定 → 走正常 Miss(与第 569 行一致);已判定的 start / middle → 静默回收 - // (与第 579-581 行、第 883 行一致,绝不 StopHoldParticles 杀掉长按持续特效)。 + // Start 未判定 → 走正常 Miss;已判定的 start / middle → 静默回收 + // (绝不 StopHoldParticles 杀掉长按持续特效)。 if (segment == NoteSegment.Start && !startJudgedBs) { EvaluateHoldEnd(now, true); @@ -576,14 +658,6 @@ public class HoldNote : BaseNote } } - // Optimization: Early exit if the note is far from judgment line and hasn't entered yet - if (!hasEnteredLine) - { - // For all segments, if they haven't triggered collision yet and are far away, skip input checks - // We use travelTime as a safe buffer. Only check input if note is within 0.5s of hitTime. - if (now < hitTime - 0.5f) return; - } - if (GameConfig.autoPlayEnabled) { UpdateAutoplay(now, jm, tkm, debugEnabled); @@ -606,7 +680,7 @@ public class HoldNote : BaseNote // Auto-miss checks when inside judge line if (hasEnteredLine) { - float missRangeScaled = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; + float missRangeScaled = (judgeConfig != null ? judgeConfig.missRange : 0.2f) * holdWindowMultiplier; // 只对"尚未判定"的 start 段做超时 auto-Miss。已成功判定的 start 段(玩家已按下、 // 正持续按住)绝不能在此被误判 Miss——否则 EvaluateHoldEnd(forceMiss) 会调 StopHoldParticles, // 把长按持续特效杀掉。物理版时 collider 早已离开判定区不会走到这里;几何版 exit 更晚才暴露此隐患。 @@ -653,7 +727,7 @@ public class HoldNote : BaseNote if (!hasReleased) { hasReleased = true; - releaseTime = now; + releaseTime = ReleaseSongTime(); jm?.RegisterNoteReleased(noteID, true); if (debugEnabled) Debug.Log($"[HoldNote] RegisterNoteReleased on KeyUp. NoteID: {noteID}, releaseTime={releaseTime:F2}"); @@ -679,8 +753,10 @@ public class HoldNote : BaseNote if (debugEnabled) Debug.Log($"[HoldNote] KeyDown detected for {noteID} key={keyToPress} time={now:F3} hitTime={hitTime:F3} hasEnteredLine={hasEnteredLine}"); - float pressTimeLocal = now; - float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; + float pressTimeLocal = PressSongTime(); + float maxWindow = (judgeConfig != null + ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) + : 0.3f) * holdWindowMultiplier; // Only consume the frame if this hold start is actually within the timing window. if (Mathf.Abs(pressTimeLocal - hitTime) > maxWindow) @@ -776,7 +852,9 @@ public class HoldNote : BaseNote } float pressTimeLocal = hitTime; // force Perfect regardless of frame timing - float maxWindow = (judgeConfig?.missRange ?? 0.5f) * holdWindowMultiplier; + float maxWindow = (judgeConfig != null + ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) + : 0.3f) * holdWindowMultiplier; if (tkm != null && !tkm.IsBestCandidate(trackIndex, queueNoteId, pressTimeLocal, maxWindow)) { @@ -1053,17 +1131,22 @@ public class HoldNote : BaseNote return; } - float pressTime = GameplayClock.NowSongTime; + float pressTime = PressSongTime(); float rawOffsetMs = (hitTime - pressTime) * 1000f; - float offset = Mathf.Abs(pressTime - hitTime); + float offset = pressTime - hitTime; // positive = late, negative = early string result; float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f); - float pRange = (judgeConfig?.perfectRange ?? 0.1f) * holdWindowMultiplier * visualScaleFactor; - float gRange = (judgeConfig?.greatRange ?? 0.2f) * holdWindowMultiplier * visualScaleFactor; - float gdRange = (judgeConfig?.goodRange ?? 0.3f) * holdWindowMultiplier * visualScaleFactor; + float windowScale = holdWindowMultiplier * visualScaleFactor; - if (offset <= pRange) + float perfectEarly = (judgeConfig?.perfectEarlyRange ?? 0.1f) * windowScale; + float perfectLate = (judgeConfig?.perfectLateRange ?? 0.1f) * windowScale; + float greatEarly = (judgeConfig?.greatEarlyRange ?? 0.2f) * windowScale; + float greatLate = (judgeConfig?.greatLateRange ?? 0.2f) * windowScale; + float goodEarly = (judgeConfig?.goodEarlyRange ?? 0.3f) * windowScale; + float goodLate = (judgeConfig?.goodLateRange ?? 0.3f) * windowScale; + + if (offset >= -perfectEarly && offset <= perfectLate) { result = "Perfect"; // Score statistics for Hold notes are now handled exclusively in EvaluateHoldEnd @@ -1082,7 +1165,7 @@ public class HoldNote : BaseNote // store into pool for use on end HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); } - else if (offset <= gRange) + else if (offset >= -greatEarly && offset <= greatLate) { result = "Great"; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; @@ -1096,7 +1179,7 @@ public class HoldNote : BaseNote HoldNoteJudgePool.RegisterStart(noteID, pressTime, hitTime, scheduledEndTime, noteColor, trackIndex, (object)noteData); } - else if (offset <= gdRange) + else if (offset >= -goodEarly && offset <= goodLate) { result = "Good"; if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; @@ -1256,83 +1339,38 @@ public class HoldNote : BaseNote } else { - // try to use pool record for accurate press duration + // 松手时刻判定:以玩家松手时间(releaseTime)与预定结束时间(scheduledEndTime)的绝对时间差 + // 评级,与头段"按下时刻判定"完全对称,不再依赖按住时长占比或几何进出判定。 + // forcePerfect(按住到尾部自动完成)仍直接判 Perfect。 + float endScheduled = scheduledEndTime; if (HoldNoteJudgePool.TryGet(noteID, out var info)) { if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Found pool info for {noteID}: pressTime={info.pressTime:F3} length={info.length:F3} scheduledEnd={info.scheduledEnd:F3}"); - float playerHeld = Mathf.Clamp(releaseTime - info.pressTime, 0f, info.length); - float frac = info.length <= 0f ? 0f : (playerHeld / info.length); - - if (forcePerfect || frac > 0.7f) - { - result = "Perfect"; - ScoreManager.Instance.countPerfect += 1; - if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++; - } - else if (frac > 0.4f) - { - result = "Great"; - ScoreManager.Instance.countGreat += 1; - if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++; - } - else - { - result = "Good"; - ScoreManager.Instance.countGood += 1; - if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++; - } - - if (noteData != null && result != "Miss") - { - float offsetEnd = (info.scheduledEnd - releaseTime) * 1000f; - noteData.judgeOffsetMsEnd = offsetEnd; - - // Record the head offset (stored during HandleStart) as the representative timing for this note - float headOffset = noteData.judgeOffsetMs; - if (float.IsNaN(headOffset)) headOffset = 0; - ScoreManager.Instance?.RecordOffset(headOffset); - } - - // cleanup pool + endScheduled = info.scheduledEnd; HoldNoteJudgePool.Unregister(noteID); } else { - if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, falling back to local held fraction calculation"); - // fallback logic if no pool info - float holdRequired = Mathf.Max(0.0001f, scheduledEndTime - hitTime); - float held = Mathf.Clamp(actualReleaseTime - hitTime, 0f, holdRequired); - float frac = holdRequired <= 0f ? 0f : (held / holdRequired); + if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] No pool info for {noteID}, using field scheduledEndTime for release timing"); + } - if (forcePerfect || frac > 0.7f) - { - result = "Perfect"; - ScoreManager.Instance.countPerfect += 1; - if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++; - } - else if (frac > 0.4f) - { - result = "Great"; - ScoreManager.Instance.countGreat += 1; - if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++; - } - else - { - result = "Good"; - ScoreManager.Instance.countGood += 1; - if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++; - } + float endOffset = Mathf.Abs(releaseTime - endScheduled); + result = GradeAndCountHoldEndByReleaseTiming(forcePerfect, endOffset); - float rawOffsetMsEnd = (scheduledEndTime - actualReleaseTime) * 1000f; - if (noteData != null && result != "Miss") - { - noteData.judgeOffsetMsEnd = rawOffsetMsEnd; - - // Fallback: use head offset if available, otherwise 0 - float headOffset = noteData.judgeOffsetMs; - if (float.IsNaN(headOffset)) headOffset = 0; - ScoreManager.Instance?.RecordOffset(headOffset); - } + if (result == "Miss") + { + // 松手时刻严重偏离预定结束点 → 记 Miss(与头段 Miss 语义一致)。 + if (JudgeManager.IsDebugEnabled) Debug.LogWarning($"[HoldNote] END judged as Miss by release timing: {noteColor} endOffset={endOffset:F3}"); + ScoreManager.Instance?.RecordOffset(0); + ReleaseTrackJudgeLockSafe(); + } + else if (noteData != null) + { + noteData.judgeOffsetMsEnd = (endScheduled - releaseTime) * 1000f; + // 以头段偏移作为本音符的代表性时序记录(与原逻辑一致,保证每条长音符只记一次 offset)。 + float headOffset = noteData.judgeOffsetMs; + if (float.IsNaN(headOffset)) headOffset = 0; + ScoreManager.Instance?.RecordOffset(headOffset); } } @@ -1412,7 +1450,8 @@ public class HoldNote : BaseNote bool triggeredHold = false; bool triggeredTap = false; - var candidates = new List(); + var candidates = _holdEndCandidatesBuffer; + candidates.Clear(); candidates.Add(this.trackIndex); if (!candidates.Contains(this.TrackIndex)) candidates.Add(this.TrackIndex); var ui = teamUIController.Instance; @@ -1488,6 +1527,50 @@ public class HoldNote : BaseNote ScheduleReturnToPool(0.2f); } + // 按"松手时刻偏移"评级并累加对应计数,返回评级字符串。与头段 HandleStart 的判定窗口 + // 计算方式一致(perfect/great/good * holdWindowMultiplier * visualScaleFactor),超出 good 窗口判 Miss。 + // forcePerfect(按住到尾部自动完成)直接判 Perfect。 + private string GradeAndCountHoldEndByReleaseTiming(bool forcePerfect, float endOffset) + { + float visualScaleFactor = Mathf.Clamp(visualSpeedMultiplier, 0.5f, 2f); + float windowScale = holdWindowMultiplier * visualScaleFactor; + + float perfectEarly = (judgeConfig?.perfectEarlyRange ?? 0.1f) * windowScale; + float perfectLate = (judgeConfig?.perfectLateRange ?? 0.1f) * windowScale; + float greatEarly = (judgeConfig?.greatEarlyRange ?? 0.2f) * windowScale; + float greatLate = (judgeConfig?.greatLateRange ?? 0.2f) * windowScale; + float goodEarly = (judgeConfig?.goodEarlyRange ?? 0.3f) * windowScale; + float goodLate = (judgeConfig?.goodLateRange ?? 0.3f) * windowScale; + + // endOffset = releaseTime - scheduledEndTime, positive = late, negative = early + string result; + if (forcePerfect || (endOffset >= -perfectEarly && endOffset <= perfectLate)) + { + result = "Perfect"; + ScoreManager.Instance.countPerfect += 1; + if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackPerfectCounts[trackIndex]++; + } + else if (endOffset >= -greatEarly && endOffset <= greatLate) + { + result = "Great"; + ScoreManager.Instance.countGreat += 1; + if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGreatCounts[trackIndex]++; + } + else if (endOffset >= -goodEarly && endOffset <= goodLate) + { + result = "Good"; + ScoreManager.Instance.countGood += 1; + if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackGoodCounts[trackIndex]++; + } + else + { + result = "Miss"; + ScoreManager.Instance.countMiss += 1; + if (trackIndex >= 0 && trackIndex < 5) ScoreManager.Instance.trackMissCounts[trackIndex]++; + } + return result; + } + private void ApplyJudgedMaterial() { if (JudgeManager.Instance == null || !JudgeManager.Instance.enableJudgedMaterial) return; @@ -1590,6 +1673,23 @@ public class HoldNote : BaseNote public void ResetState() { + // 纯视觉 body 回收:还原渲染器到 prefab 原始状态,退出 body 模式,不走判定相关重置。 + if (bodyMode || bodyReturning) + { + // body 与 head/end 共享 noteID,OnHoldAlphaSync 会让 body 也 ApplyJudgedMaterial + // (与原中段行为一致)。回收时必须还原材质,否则换过的材质会残留到下次池化复用。 + RestoreOriginalMaterials(); + RestoreBodyRenderers(); + bodyMode = false; + bodyReturning = false; + segment = NoteSegment.None; + noteColor = string.Empty; + noteID = string.Empty; + type = string.Empty; + if (controller != null) controller.DisableVisualBodyMode(); + return; + } + // also release any stale lock from previous lifecycle ReleaseTrackJudgeLockSafe(); @@ -1616,6 +1716,8 @@ public class HoldNote : BaseNote isHoldActive = false; hasBeenHeldFromStart = false; prevTrackHeld = false; + lastPressDspTime = double.NaN; + lastReleaseDspTime = double.NaN; // 去物理判定:池化复用时重置 controller 的几何判定状态(重新算阈值、清进入标志)。 if (controller != null) controller.ResetJudgeZoneGeometry(); @@ -1660,6 +1762,139 @@ public class HoldNote : BaseNote } } + // 以纯视觉 body 身份初始化:一条 Tiled 长条覆盖 head→end 整段,按绝对时间下落, + // end 越过判定线后回收(时机与原分段一致)。不注册任何判定/计分状态。 + // baseHit=head 到达判定线的绝对歌曲时间;lengthSeconds=长音符时长;speed=世界速度; + // travelTime=从发车到判定线的时间;spawnPoint=车道原点。 + public void SetupBody(int holdNoteId, int trackIndexArg, float speed, float baseHit, + float lengthSeconds, float travelTime, string color, Vector3 spawnPoint) + { + this.id = holdNoteId; + this.noteID = holdNoteId.ToString(); + this.trackIndex = trackIndexArg; + this.TrackIndex = trackIndexArg; + this.color = color; + this.noteColor = color; + this.type = "body"; + this.segment = NoteSegment.None; // 不参与任何 segment 判定分支 + + bodyMode = true; + bodyReturning = false; + bodySpeed = Mathf.Max(0f, speed); + bodyLengthSeconds = Mathf.Max(0f, lengthSeconds); + bodyTravelTime = Mathf.Max(0f, travelTime); + bodyHeadActivationTime = baseHit - bodyTravelTime; + bodySpawnX = spawnPoint.x; + bodySpawnZ = spawnPoint.z; + bodySpawnY = spawnPoint.y; + + if (controller == null) controller = GetComponent(); + controller?.EnableVisualBodyMode(); + + CaptureBodyRenderers(); + UpdateBodyVisual(GameplayClock.NowSongTime); + } + + // 缓存 body 上的所有 SpriteRenderer 并切到 Tiled。 + // 不改 transform 缩放(保留 prefab 里 outer/inner 各自的构图比例),长度全部交给 size.y: + // 世界高度 = size.y × lossyScale.y ⇒ 设 size.y = 目标世界高度 / lossyScale.y。 + // 相比原来的 Simple+整体拉伸,Tiled 会沿 Y 重复贴图而非把单张越拉越长,消除拉伸失真。 + private void CaptureBodyRenderers() + { + if (bodyRenderers == null) + { + bodyRenderers = GetComponentsInChildren(true); + int n = bodyRenderers.Length; + bodyRendererOrigDrawMode = new SpriteDrawMode[n]; + bodyRendererOrigSize = new Vector2[n]; + bodyRendererOrigLocalScale = new Vector3[n]; + for (int i = 0; i < n; i++) + { + var sr = bodyRenderers[i]; + if (sr == null) continue; + bodyRendererOrigDrawMode[i] = sr.drawMode; + bodyRendererOrigSize[i] = sr.size; + bodyRendererOrigLocalScale[i] = sr.transform.localScale; + } + } + + for (int i = 0; i < bodyRenderers.Length; i++) + { + var sr = bodyRenderers[i]; + if (sr == null) continue; + sr.drawMode = SpriteDrawMode.Tiled; + sr.tileMode = SpriteTileMode.Continuous; + // 不改 transform 缩放(保留 outer/inner 各自构图比例)。宽度取一块 tile 宽, + // 使世界宽度 = sprite 原生宽 × lossyScale.x,与原 Simple 模式一致,仅水平不平铺。 + if (sr.sprite != null) + { + var sz = sr.size; + sz.x = sr.sprite.bounds.size.x; + sr.size = sz; + } + } + } + + // 逐帧驱动 body 的位置(纯视觉,不触判定)。 + // 复刻原 N 个 middle 段的整体行为:一条定高长条(全高 = speed×length)随 head 绝对时间下落、 + // 穿过判定线继续下移,直到 end 越过判定线后(≈ scheduledEnd)再回收——与原分段"经过判定线 + // 后 ~0.2s 回收"的滚动/消失时机一致,不做额外的底部裁剪(避免改变既有视觉行为)。 + private void UpdateBodyVisual(float t) + { + if (!bodyMode) return; + + float scheduledEnd = bodyHeadActivationTime + bodyTravelTime + bodyLengthSeconds; + if (t > scheduledEnd + 0.2f) { ReturnBodyToPool(); return; } + + float elapsed = t - bodyHeadActivationTime; + float headY = bodySpawnY - bodySpeed * elapsed; // head(底端)世界 Y + float fullHeight = bodySpeed * bodyLengthSeconds; // 定高(= end 高出 head 的量) + float centerY = headY + fullHeight * 0.5f; + + transform.position = new Vector3(bodySpawnX, centerY, bodySpawnZ); + ApplyBodyHeight(fullHeight); + } + + // 世界高度 → 每个渲染器的 size.y(= 目标世界高度 / 该渲染器世界 Y 缩放)。 + private void ApplyBodyHeight(float worldHeight) + { + if (bodyRenderers == null) return; + for (int i = 0; i < bodyRenderers.Length; i++) + { + var sr = bodyRenderers[i]; + if (sr == null) continue; + float ly = Mathf.Abs(sr.transform.lossyScale.y); + if (ly < 1e-4f) ly = 1f; + var sz = sr.size; + sz.y = Mathf.Max(0f, worldHeight / ly); + sr.size = sz; + } + } + + private void ReturnBodyToPool() + { + if (bodyReturning) return; + bodyReturning = true; + if (!gameObject.activeSelf) return; + gameObject.SetActive(false); + if (NotePool.Instance != null) + NotePool.Instance.ReturnHoldNoteSegment(gameObject, noteColor); + } + + // 池化复用/回收时把 body 渲染器还原到 prefab 原始状态。 + private void RestoreBodyRenderers() + { + if (bodyRenderers == null) return; + for (int i = 0; i < bodyRenderers.Length; i++) + { + var sr = bodyRenderers[i]; + if (sr == null) continue; + sr.drawMode = bodyRendererOrigDrawMode[i]; + sr.size = bodyRendererOrigSize[i]; + sr.transform.localScale = bodyRendererOrigLocalScale[i]; + } + } + public void ApplyVisualScale(float scaleY) { float s = Mathf.Clamp(scaleY, 0.1f, 4f); diff --git a/Assets/scripts/gamePlay_gameplay/HoldNoteController.cs b/Assets/scripts/gamePlay_gameplay/HoldNoteController.cs index 59a68538..a4f03ce8 100644 --- a/Assets/scripts/gamePlay_gameplay/HoldNoteController.cs +++ b/Assets/scripts/gamePlay_gameplay/HoldNoteController.cs @@ -17,6 +17,10 @@ public class HoldNoteController : MonoBehaviour private float _enterY, _exitY; private bool _thresholdsReady; + // 单条 Tiled 长音符 body 模式:本 controller 不再自行移动/几何判定, + // 位置与尺寸完全由 HoldNote.UpdateBodyVisual 逐帧驱动(纯视觉,不参与判定)。 + private bool visualBodyMode = false; + // Absolute positioning for hold segments (keeps segments connected under frame spikes) private bool useAbsolutePositioning = false; private Vector3 spawnPosition; @@ -43,6 +47,9 @@ public class HoldNoteController : MonoBehaviour public void ManualTick(float songTime, float deltaTime) { + // body 模式:位置/尺寸由 HoldNote 驱动,controller 不移动、不做几何判定(纯视觉)。 + if (visualBodyMode) return; + if (useAbsolutePositioning) { if (isMoving) ApplyAbsolutePosition(songTime); @@ -150,6 +157,20 @@ public class HoldNoteController : MonoBehaviour visualOffset = 0f; } + // 单条 body 由 HoldNote 驱动,关闭 controller 自身的移动与几何判定。 + public void EnableVisualBodyMode() + { + visualBodyMode = true; + useAbsolutePositioning = false; + isMoving = false; + } + + // 池化复用时清掉 body 模式,让实例可回到普通段语义。 + public void DisableVisualBodyMode() + { + visualBodyMode = false; + } + // 去物理判定:进入/离开判定区改由 UpdateJudgeZoneByGeometry 驱动,原 OnTrigger 回调移除。 // 池化复用时重置几何判定状态。 @@ -157,6 +178,7 @@ public class HoldNoteController : MonoBehaviour { isInsideJudgeZone = false; _thresholdsReady = false; + visualBodyMode = false; } public bool IsInsideJudgArea() diff --git a/Assets/scripts/gamePlay_gameplay/InputManager.cs b/Assets/scripts/gamePlay_gameplay/InputManager.cs index 8d4aa618..8bea0a4e 100644 --- a/Assets/scripts/gamePlay_gameplay/InputManager.cs +++ b/Assets/scripts/gamePlay_gameplay/InputManager.cs @@ -10,9 +10,17 @@ using UnityEngine.UI; public class InputManager : MonoBehaviour { public static InputManager Instance { get; private set; } + + // Legacy KeyCode-based events (maintained for backward compatibility with existing Note/HoldNote code). + // These are now fired by OnTrackInput handlers after receiving dspTime from GameplayInputActions. public static event Action OnKeyPressed; public static event Action OnKeyReleased; + // New dspTime-carrying events: track index + dspTime at the moment Input System captured the event. + // Allows sub-frame-accurate judgement by converting dspTime → song time via GameplayClock.SongTimeFromDsp. + public static event Action OnTrackPressedWithDspTime; + public static event Action OnTrackReleasedWithDspTime; + private static readonly string[] TrackColors = { "red", "green", "yellow", "purple", "blue" }; [Header("Inspector")] @@ -82,6 +90,48 @@ public class InputManager : MonoBehaviour if (t != null) t.color = keyInactiveColor; } } + + // Ensure GameplayInputActions exists (auto-create if missing). + if (GameplayInputActions.Instance == null) + { + GameObject inputActionsGo = new GameObject("GameplayInputActions"); + inputActionsGo.AddComponent(); + DontDestroyOnLoad(inputActionsGo); + } + + // Subscribe to Input System events from GameplayInputActions (dspTime-carrying press/release). + GameplayInputActions.OnTrackPressed += OnTrackInput_Press; + GameplayInputActions.OnTrackReleased += OnTrackInput_Release; + } + + private void OnDestroy() + { + if (GameplayInputActions.Instance != null) + { + GameplayInputActions.OnTrackPressed -= OnTrackInput_Press; + GameplayInputActions.OnTrackReleased -= OnTrackInput_Release; + } + } + + /// + /// Handle track press from GameplayInputActions (Input System). Updates held state, + /// fires both legacy KeyCode event and new dspTime event, and updates visuals. + /// + private void OnTrackInput_Press(int trackIndex, double pressDspTime) + { + if (IsBlockedByPause()) return; + KeyCode sourceKey = trackIndex >= 0 && trackIndex < cachedKeys.Length ? cachedKeys[trackIndex] : KeyCode.None; + PressTrack(trackIndex, sourceKey, pressDspTime); + } + + /// + /// Handle track release from GameplayInputActions (Input System). Updates held state, + /// fires both legacy KeyCode event and new dspTime event, and updates visuals. + /// + private void OnTrackInput_Release(int trackIndex, double releaseDspTime) + { + if (IsBlockedByPause()) return; + ReleaseTrack(trackIndex, releaseDspTime); } private void RefreshKeyCache() @@ -129,24 +179,9 @@ public class InputManager : MonoBehaviour } pauseBlockedLastFrame = false; - for (int i = 0; i < TrackColors.Length; i++) - { - KeyCode key = cachedKeys[i]; - if (key == KeyCode.None) continue; - - if (Input.GetKeyDown(key)) - PressTrack(i); - if (Input.GetKeyUp(key)) - ReleaseTrack(i); - } - - if (cachedSecondaryTrack3Key != KeyCode.None) - { - if (Input.GetKeyDown(cachedSecondaryTrack3Key)) - PressTrack(2, cachedSecondaryTrack3Key); - if (Input.GetKeyUp(cachedSecondaryTrack3Key)) - ReleaseTrack(2); - } + // Legacy Input.GetKeyDown polling removed: keyboard input now comes from + // GameplayInputActions (Input System) via OnTrackInput_Press/Release handlers. + // Touch input still drives PressTrack/ReleaseTrack directly via TrackTouchInput. } /// @@ -158,19 +193,27 @@ public class InputManager : MonoBehaviour public void PressTrack(int index) { KeyCode sourceKey = index >= 0 && index < cachedKeys.Length ? cachedKeys[index] : KeyCode.None; - PressTrack(index, sourceKey); + // Touch/other callers without an event timestamp use the current dspTime. + PressTrack(index, sourceKey, AudioSettings.dspTime); } public void PressTrack(int index, KeyCode sourceKey) + { + PressTrack(index, sourceKey, AudioSettings.dspTime); + } + + public void PressTrack(int index, KeyCode sourceKey, double pressDspTime) { if (index < 0 || index >= TrackColors.Length) return; bool wasHeld = trackHeldCount[index] > 0; trackHeldCount[index]++; - // Always fire tap judgment pulse, even if already held (fixes rapid same-track taps) + // Always fire tap judgment pulse, even if already held (fixes rapid same-track taps). + // Fire both the legacy KeyCode event and the new dspTime event so both consumers work. KeyCode key = cachedKeys[index]; if (key != KeyCode.None) OnKeyPressed?.Invoke(key); + OnTrackPressedWithDspTime?.Invoke(index, pressDspTime); if (trackKeyTexts != null && index < trackKeyTexts.Length) { @@ -201,6 +244,11 @@ public class InputManager : MonoBehaviour /// on pointer-up. Clears the held-state table, raises OnKeyReleased, and resets visuals. /// public void ReleaseTrack(int index) + { + ReleaseTrack(index, AudioSettings.dspTime); + } + + public void ReleaseTrack(int index, double releaseDspTime) { if (index < 0 || index >= TrackColors.Length) return; if (trackHeldCount[index] <= 0) return; // not held; nothing to release @@ -211,6 +259,7 @@ public class InputManager : MonoBehaviour KeyCode key = cachedKeys[index]; if (key != KeyCode.None) OnKeyReleased?.Invoke(key); + OnTrackReleasedWithDspTime?.Invoke(index, releaseDspTime); if (laneSprites != null && index < laneSprites.Length) SetSpriteAlpha(laneSprites[index], 0f); diff --git a/Assets/scripts/gamePlay_gameplay/InputSystemKeyMap.cs b/Assets/scripts/gamePlay_gameplay/InputSystemKeyMap.cs new file mode 100644 index 00000000..dbc338a0 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/InputSystemKeyMap.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using UnityEngine; + +/// +/// Bidirectional mapping between legacy UnityEngine.KeyCode and Unity Input System +/// keyboard control paths (e.g. KeyCode.D <-> "<Keyboard>/d"). +/// +/// The gameplay binding store persists paths (Input System canonical format), while +/// most game/UI code still speaks KeyCode. This map keeps both representations in sync +/// without changing any judgement/binding business logic. +/// +public static class InputSystemKeyMap +{ + // KeyCode -> Input System control name (the part after "/"). + private static readonly Dictionary keyCodeToControl = new Dictionary(); + private static readonly Dictionary controlToKeyCode = new Dictionary(); + + private const string KeyboardPrefix = "/"; + + static InputSystemKeyMap() + { + // Letters + for (char c = 'a'; c <= 'z'; c++) + { + KeyCode kc = (KeyCode)System.Enum.Parse(typeof(KeyCode), char.ToUpper(c).ToString()); + Register(kc, c.ToString()); + } + + // Top-row digits (KeyCode.Alpha0..9 -> "1".."0") + Register(KeyCode.Alpha0, "0"); + Register(KeyCode.Alpha1, "1"); + Register(KeyCode.Alpha2, "2"); + Register(KeyCode.Alpha3, "3"); + Register(KeyCode.Alpha4, "4"); + Register(KeyCode.Alpha5, "5"); + Register(KeyCode.Alpha6, "6"); + Register(KeyCode.Alpha7, "7"); + Register(KeyCode.Alpha8, "8"); + Register(KeyCode.Alpha9, "9"); + + // Numpad + Register(KeyCode.Keypad0, "numpad0"); + Register(KeyCode.Keypad1, "numpad1"); + Register(KeyCode.Keypad2, "numpad2"); + Register(KeyCode.Keypad3, "numpad3"); + Register(KeyCode.Keypad4, "numpad4"); + Register(KeyCode.Keypad5, "numpad5"); + Register(KeyCode.Keypad6, "numpad6"); + Register(KeyCode.Keypad7, "numpad7"); + Register(KeyCode.Keypad8, "numpad8"); + Register(KeyCode.Keypad9, "numpad9"); + Register(KeyCode.KeypadDivide, "numpadDivide"); + Register(KeyCode.KeypadMultiply, "numpadMultiply"); + Register(KeyCode.KeypadMinus, "numpadMinus"); + Register(KeyCode.KeypadPlus, "numpadPlus"); + Register(KeyCode.KeypadEnter, "numpadEnter"); + Register(KeyCode.KeypadPeriod, "numpadPeriod"); + Register(KeyCode.KeypadEquals, "numpadEquals"); + + // Whitespace / editing + Register(KeyCode.Space, "space"); + Register(KeyCode.Return, "enter"); + Register(KeyCode.Tab, "tab"); + Register(KeyCode.Backspace, "backspace"); + Register(KeyCode.Delete, "delete"); + Register(KeyCode.Escape, "escape"); + + // Modifiers + Register(KeyCode.LeftShift, "leftShift"); + Register(KeyCode.RightShift, "rightShift"); + Register(KeyCode.LeftControl, "leftCtrl"); + Register(KeyCode.RightControl, "rightCtrl"); + Register(KeyCode.LeftAlt, "leftAlt"); + Register(KeyCode.RightAlt, "rightAlt"); + Register(KeyCode.LeftCommand, "leftMeta"); + Register(KeyCode.RightCommand, "rightMeta"); + Register(KeyCode.CapsLock, "capsLock"); + + // Arrows + Register(KeyCode.UpArrow, "upArrow"); + Register(KeyCode.DownArrow, "downArrow"); + Register(KeyCode.LeftArrow, "leftArrow"); + Register(KeyCode.RightArrow, "rightArrow"); + + // Navigation + Register(KeyCode.Home, "home"); + Register(KeyCode.End, "end"); + Register(KeyCode.PageUp, "pageUp"); + Register(KeyCode.PageDown, "pageDown"); + Register(KeyCode.Insert, "insert"); + + // Punctuation / symbols + Register(KeyCode.Quote, "quote"); + Register(KeyCode.Semicolon, "semicolon"); + Register(KeyCode.Comma, "comma"); + Register(KeyCode.Period, "period"); + Register(KeyCode.Slash, "slash"); + Register(KeyCode.Backslash, "backslash"); + Register(KeyCode.LeftBracket, "leftBracket"); + Register(KeyCode.RightBracket, "rightBracket"); + Register(KeyCode.Minus, "minus"); + Register(KeyCode.Equals, "equals"); + Register(KeyCode.BackQuote, "backquote"); + + // Function keys + Register(KeyCode.F1, "f1"); + Register(KeyCode.F2, "f2"); + Register(KeyCode.F3, "f3"); + Register(KeyCode.F4, "f4"); + Register(KeyCode.F5, "f5"); + Register(KeyCode.F6, "f6"); + Register(KeyCode.F7, "f7"); + Register(KeyCode.F8, "f8"); + Register(KeyCode.F9, "f9"); + Register(KeyCode.F10, "f10"); + Register(KeyCode.F11, "f11"); + Register(KeyCode.F12, "f12"); + } + + private static void Register(KeyCode kc, string control) + { + keyCodeToControl[kc] = control; + if (!controlToKeyCode.ContainsKey(control)) + controlToKeyCode[control] = kc; + } + + /// KeyCode -> full Input System path (e.g. "<Keyboard>/d"). Null if unmapped/None. + public static string KeyCodeToPath(KeyCode key) + { + if (key == KeyCode.None) return null; + if (keyCodeToControl.TryGetValue(key, out string control)) + return KeyboardPrefix + control; + return null; + } + + /// Full Input System path -> KeyCode. Returns KeyCode.None if unmapped. + public static KeyCode PathToKeyCode(string path) + { + if (string.IsNullOrEmpty(path)) return KeyCode.None; + string control = path.StartsWith(KeyboardPrefix) ? path.Substring(KeyboardPrefix.Length) : path; + if (controlToKeyCode.TryGetValue(control, out KeyCode kc)) + return kc; + return KeyCode.None; + } +} diff --git a/Assets/scripts/gamePlay_gameplay/InputSystemKeyMap.cs.meta b/Assets/scripts/gamePlay_gameplay/InputSystemKeyMap.cs.meta new file mode 100644 index 00000000..2920774c --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/InputSystemKeyMap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e7ce48130a4116409417420a4dcef30 \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/Judge/NoteJudgeConfig.asset b/Assets/scripts/gamePlay_gameplay/Judge/NoteJudgeConfig.asset index 08dbd5d3..a79f91e0 100644 --- a/Assets/scripts/gamePlay_gameplay/Judge/NoteJudgeConfig.asset +++ b/Assets/scripts/gamePlay_gameplay/Judge/NoteJudgeConfig.asset @@ -11,8 +11,11 @@ MonoBehaviour: m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 088ed1ed6b6731f43ad3166781e3e931, type: 3} m_Name: NoteJudgeConfig - m_EditorClassIdentifier: - perfectRange: 0.1 - greatRange: 0.12 - goodRange: 0.15 - missRange: 0.18 + m_EditorClassIdentifier: + perfectEarlyRange: 0.07 + perfectLateRange: 0.07 + greatEarlyRange: 0.09 + greatLateRange: 0.09 + goodEarlyRange: 0.11 + goodLateRange: 0.11 + missRange: 0.11 diff --git a/Assets/scripts/gamePlay_gameplay/JudgeFeedback.cs b/Assets/scripts/gamePlay_gameplay/JudgeFeedback.cs new file mode 100644 index 00000000..5ecc2293 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/JudgeFeedback.cs @@ -0,0 +1,60 @@ +using System; + +/// +/// FAST/SLOW 早晚反馈预埋接口。 +/// +/// 判定命中时(非 Miss)由判定逻辑广播一次带符号的时间偏移,供 UI 层显示"早/晚"提示、 +/// 或供校准/统计模块消费。默认无任何订阅者时为 no-op,不产生任何行为、不影响判定与计分。 +/// +/// 约定(与 osu 系惯例一致): +/// signedOffsetMs = (pressTime - hitTime) * 1000 +/// > 0 → 按晚了(SLOW / Late) +/// < 0 → 按早了(FAST / Early) +/// = 0 → 正中 +/// +/// 纯附加接口:不改变任何现有判定/计分业务逻辑。接入方只需订阅 OnJudgeOffset。 +/// +public static class JudgeFeedback +{ + public enum Timing + { + Exact, + Fast, // 偏早 + Slow // 偏晚 + } + + /// + /// 判定偏移事件。参数:(trackIndex, judgeResult, signedOffsetMs, timing)。 + /// 仅在命中档(Perfect/Great/Good)判定成功时触发;Miss 不触发(无有效偏移)。 + /// + public static event Action OnJudgeOffset; + + /// + /// 将带符号偏移归类为 Fast/Slow/Exact。deadZoneMs 内视为正中(默认 0,即严格按符号)。 + /// + public static Timing Classify(float signedOffsetMs, float deadZoneMs = 0f) + { + if (signedOffsetMs > deadZoneMs) return Timing.Slow; + if (signedOffsetMs < -deadZoneMs) return Timing.Fast; + return Timing.Exact; + } + + /// + /// 由判定逻辑调用,广播一次早晚反馈。无订阅者时为 no-op。 + /// 内部吞掉订阅者异常,保证反馈通道永不影响判定主流程。 + /// + public static void Report(int trackIndex, string judgeResult, float signedOffsetMs) + { + var handler = OnJudgeOffset; + if (handler == null) return; // 预埋:默认无接入,零开销、零副作用。 + + try + { + handler.Invoke(trackIndex, judgeResult, signedOffsetMs, Classify(signedOffsetMs)); + } + catch + { + // 反馈通道故障绝不能影响判定/计分。 + } + } +} diff --git a/Assets/scripts/gamePlay_gameplay/JudgeFeedback.cs.meta b/Assets/scripts/gamePlay_gameplay/JudgeFeedback.cs.meta new file mode 100644 index 00000000..aee1428a --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/JudgeFeedback.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 16fa42cbb336b524c88ce7cd80c3072a \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/JudgeSoundManager.cs b/Assets/scripts/gamePlay_gameplay/JudgeSoundManager.cs index 64297ba0..2b6a989e 100644 --- a/Assets/scripts/gamePlay_gameplay/JudgeSoundManager.cs +++ b/Assets/scripts/gamePlay_gameplay/JudgeSoundManager.cs @@ -59,6 +59,15 @@ public class JudgeSoundManager : MonoBehaviour src.spatialBlend = 0f; // ensure not looping src.loop = false; + + // Lowest-latency reactive playback for judge SFX: + // bypass any AudioMixer-group / AudioListener DSP effects (reverb, compressor, etc.) + // that would otherwise add buffer-sized processing latency on top of hardware output. + src.bypassEffects = true; + src.bypassListenerEffects = true; + src.bypassReverbZones = true; + // Highest priority so a judge hit is never delayed by voice-stealing under load. + src.priority = 0; } public enum JudgeResult { Perfect, Great, Good, Miss } diff --git a/Assets/scripts/gamePlay_gameplay/KeyBindingManager.cs b/Assets/scripts/gamePlay_gameplay/KeyBindingManager.cs index bcbf1a9b..c72a5c57 100644 --- a/Assets/scripts/gamePlay_gameplay/KeyBindingManager.cs +++ b/Assets/scripts/gamePlay_gameplay/KeyBindingManager.cs @@ -1,45 +1,67 @@ -using UnityEngine; -using System.Collections.Generic; - -public class KeyBindingManager : MonoBehaviour -{ +using UnityEngine; +using System.Collections.Generic; + +public class KeyBindingManager : MonoBehaviour +{ private static Dictionary keyBindings = new Dictionary(); private static readonly string[] colors = { "red", "green", "yellow", "purple", "blue" }; private static readonly KeyCode[] defaultKeys = { KeyCode.D, KeyCode.F, KeyCode.Space, KeyCode.J, KeyCode.K }; private const string SecondaryTrack3PrefsKey = "KeyBinding_yellow_secondary"; private static KeyCode secondaryTrack3Key = KeyCode.V; private static bool initialized = false; - - private void Awake() - { - if (!initialized) - { - LoadKeyBindings(); - } - Debug.Log("KeyBindingManager Awake() 被调用,开始加载按键映射..."); - } - - public static KeyCode GetKeyForColor(string color) - { - if (!initialized) - { - LoadKeyBindings(); - } - - if (string.IsNullOrEmpty(color)) return KeyCode.None; - var keyLower = color.ToLower(); - if (keyBindings.TryGetValue(keyLower, out KeyCode key)) - { - return key; - } - // fallback: try to map by index order - for (int i = 0; i < colors.Length; i++) - { - if (colors[i] == keyLower) - { - return defaultKeys[i]; - } - } + + // Migration marker: once we've converted legacy (int)KeyCode prefs to the new + // Input System path storage, we set this so we don't re-migrate. Storage stays + // keyed by KeyCode for backward compatibility; paths are derived on demand from KeyCode. + private const string BindingFormatVersionKey = "KeyBindingFormatVersion"; + private const int BindingFormatVersion = 2; + + private void Awake() + { + if (!initialized) + { + LoadKeyBindings(); + } + Debug.Log("KeyBindingManager Awake() 被调用,开始加载按键映射..."); + } + + // ---- Input System path accessors (new) ---- + // The binding store remains KeyCode-based for backward compatibility with saved prefs. + // These helpers translate the stored KeyCode into an Input System control path + // (e.g. KeyCode.D -> "/d") for GameplayInputActions to bind at runtime. + + public static string GetBindingPathForTrack(int trackIndex) + { + if (trackIndex < 0 || trackIndex >= colors.Length) return null; + return InputSystemKeyMap.KeyCodeToPath(GetKeyForColor(colors[trackIndex])); + } + + public static string GetSecondaryTrack3BindingPath() + { + return InputSystemKeyMap.KeyCodeToPath(GetSecondaryKeyForTrack3()); + } + + public static KeyCode GetKeyForColor(string color) + { + if (!initialized) + { + LoadKeyBindings(); + } + + if (string.IsNullOrEmpty(color)) return KeyCode.None; + var keyLower = color.ToLower(); + if (keyBindings.TryGetValue(keyLower, out KeyCode key)) + { + return key; + } + // fallback: try to map by index order + for (int i = 0; i < colors.Length; i++) + { + if (colors[i] == keyLower) + { + return defaultKeys[i]; + } + } return KeyCode.None; } @@ -85,26 +107,26 @@ public class KeyBindingManager : MonoBehaviour for (int i = 0; i < colors.Length; i++) { var col = colors[i]; - KeyCode k = defaultKeys[i]; + KeyCode k = defaultKeys[i]; if (keyBindings.TryGetValue(col, out KeyCode stored)) k = stored; PlayerPrefs.SetInt($"KeyBinding_{col}", (int)k); } PlayerPrefs.SetInt(SecondaryTrack3PrefsKey, (int)secondaryTrack3Key); PlayerPrefs.Save(); } - - private static void LoadKeyBindings() - { - keyBindings.Clear(); - for (int i = 0; i < colors.Length; i++) - { - string col = colors[i]; - if (PlayerPrefs.HasKey($"KeyBinding_{col}")) - { - keyBindings[col] = (KeyCode)PlayerPrefs.GetInt($"KeyBinding_{col}"); - } - else - { + + private static void LoadKeyBindings() + { + keyBindings.Clear(); + for (int i = 0; i < colors.Length; i++) + { + string col = colors[i]; + if (PlayerPrefs.HasKey($"KeyBinding_{col}")) + { + keyBindings[col] = (KeyCode)PlayerPrefs.GetInt($"KeyBinding_{col}"); + } + else + { keyBindings[col] = defaultKeys[i]; } } @@ -121,39 +143,39 @@ public class KeyBindingManager : MonoBehaviour initialized = true; Debug.Log("KeyBindings 初始化成功:" + string.Join(", ", keyBindings)); } - - // Return a human-friendly display string for a KeyCode (symbols shown as their character) - public static string GetDisplayName(KeyCode key) - { - if (key == KeyCode.Space) return "Space"; - switch (key) - { - case KeyCode.Quote: return "'"; // single quote - case KeyCode.Semicolon: return ";"; - case KeyCode.Comma: return ","; - case KeyCode.Period: return "."; - case KeyCode.Slash: return "/"; - case KeyCode.Backslash: return "\\"; - case KeyCode.LeftBracket: return "["; - case KeyCode.RightBracket: return "]"; - case KeyCode.Minus: return "-"; - case KeyCode.Equals: return "="; - case KeyCode.BackQuote: return "`"; - case KeyCode.Keypad0: return "Num0"; - case KeyCode.Keypad1: return "Num1"; - case KeyCode.Keypad2: return "Num2"; - case KeyCode.Keypad3: return "Num3"; - case KeyCode.Keypad4: return "Num4"; - case KeyCode.Keypad5: return "Num5"; - case KeyCode.Keypad6: return "Num6"; - case KeyCode.Keypad7: return "Num7"; - case KeyCode.Keypad8: return "Num8"; - case KeyCode.Keypad9: return "Num9"; - default: - // For letters and named keys, use ToString(); for better readability, split "Alpha0" -> "0" - string s = key.ToString(); - if (s.StartsWith("Alpha") && s.Length > 5) return s.Substring(5); - return s; - } - } -} + + // Return a human-friendly display string for a KeyCode (symbols shown as their character) + public static string GetDisplayName(KeyCode key) + { + if (key == KeyCode.Space) return "Space"; + switch (key) + { + case KeyCode.Quote: return "'"; // single quote + case KeyCode.Semicolon: return ";"; + case KeyCode.Comma: return ","; + case KeyCode.Period: return "."; + case KeyCode.Slash: return "/"; + case KeyCode.Backslash: return "\\"; + case KeyCode.LeftBracket: return "["; + case KeyCode.RightBracket: return "]"; + case KeyCode.Minus: return "-"; + case KeyCode.Equals: return "="; + case KeyCode.BackQuote: return "`"; + case KeyCode.Keypad0: return "Num0"; + case KeyCode.Keypad1: return "Num1"; + case KeyCode.Keypad2: return "Num2"; + case KeyCode.Keypad3: return "Num3"; + case KeyCode.Keypad4: return "Num4"; + case KeyCode.Keypad5: return "Num5"; + case KeyCode.Keypad6: return "Num6"; + case KeyCode.Keypad7: return "Num7"; + case KeyCode.Keypad8: return "Num8"; + case KeyCode.Keypad9: return "Num9"; + default: + // For letters and named keys, use ToString(); for better readability, split "Alpha0" -> "0" + string s = key.ToString(); + if (s.StartsWith("Alpha") && s.Length > 5) return s.Substring(5); + return s; + } + } +} diff --git a/Assets/scripts/gamePlay_gameplay/LevelRules/LevelRuleController.cs b/Assets/scripts/gamePlay_gameplay/LevelRules/LevelRuleController.cs index 4399040f..829d241e 100644 --- a/Assets/scripts/gamePlay_gameplay/LevelRules/LevelRuleController.cs +++ b/Assets/scripts/gamePlay_gameplay/LevelRules/LevelRuleController.cs @@ -37,6 +37,13 @@ public class LevelRuleController : MonoBehaviour private int lastEchoCastSlot = -1; private float lastEchoCastTime = -999f; private float nextConfigResolveTime; + // The (song, difficulty) pair we have already run FindConfig for. Lets the periodic + // Update skip re-resolving (and re-running Resources.LoadAll) when the current song has + // NO matching config — otherwise activeConfig stays null and the null-check early-out + // never trips, re-scanning Resources every 0.5s for the whole song. + private SongData resolvedForSong; + private int resolvedForDifficulty = -1; + private bool hasResolvedForCurrent; private int pendingSettlementScoreBonus; private bool handlingEnemyDamagedActions; @@ -145,6 +152,7 @@ public class LevelRuleController : MonoBehaviour { ResetRuntimeState(); nextConfigResolveTime = 0f; + hasResolvedForCurrent = false; TryResolveActiveConfig(true); } @@ -160,10 +168,15 @@ public class LevelRuleController : MonoBehaviour SongData song = beatmapManager.assignedSongData; int difficulty = beatmapManager.assignedDifficulty; - if (!force && activeConfig != null && currentSong == song && currentDifficulty == difficulty) + // Skip re-resolving when we've already resolved for this exact (song, difficulty), + // regardless of whether a config matched (null result is still a resolved result). + if (!force && hasResolvedForCurrent && resolvedForSong == song && resolvedForDifficulty == difficulty) return; LevelRuleConfig_SO matched = FindConfig(song, difficulty); + resolvedForSong = song; + resolvedForDifficulty = difficulty; + hasResolvedForCurrent = true; SetActiveConfig(matched, song, difficulty); } diff --git a/Assets/scripts/gamePlay_gameplay/Note.cs b/Assets/scripts/gamePlay_gameplay/Note.cs index 5db8c068..041453d4 100644 --- a/Assets/scripts/gamePlay_gameplay/Note.cs +++ b/Assets/scripts/gamePlay_gameplay/Note.cs @@ -203,9 +203,9 @@ public class Note : BaseNote this.judgeConfig = judgeConfig; this.noteData = data; this.isSyncNote = isSync; - + // Set miss deadline: hitTime + missRange (the latest time to judge before auto-miss) - float missRange = (judgeConfig?.missRange ?? 0.5f); + float missRange = judgeConfig != null ? judgeConfig.missRange : 0.2f; this.missDeadlineTime = hitTime + missRange; if (controller != null) @@ -213,7 +213,7 @@ public class Note : BaseNote controller.SetSpeed(speed); } - InputManager.OnKeyPressed += HandlePress; + InputManager.OnTrackPressedWithDspTime += HandlePress; if (judgeConfig == null) { @@ -221,13 +221,13 @@ public class Note : BaseNote } else { - if (JudgeManager.IsDebugEnabled) Debug.Log($"NoteJudgeConfig: perfect={judgeConfig.perfectRange}, great={judgeConfig.greatRange}, good={judgeConfig.goodRange}, miss={judgeConfig.missRange}"); + if (JudgeManager.IsDebugEnabled) Debug.Log($"NoteJudgeConfig: perfectE={judgeConfig.perfectEarlyRange} perfectL={judgeConfig.perfectLateRange}, greatE={judgeConfig.greatEarlyRange} greatL={judgeConfig.greatLateRange}, goodE={judgeConfig.goodEarlyRange} goodL={judgeConfig.goodLateRange}, miss={judgeConfig.missRange}"); } } private void OnDestroy() { - InputManager.OnKeyPressed -= HandlePress; + InputManager.OnTrackPressedWithDspTime -= HandlePress; // Release lock if we still hold it if (hasLock) { @@ -238,11 +238,15 @@ public class Note : BaseNote private void Update() { + // 【性能】NowSongTime 每次访问都读 native AudioSettings.dspTime;帧内其值恒定, + // 缓存到局部只读一次,与原先多次读取同值,行为等效。 + float now = GameplayClock.NowSongTime; + // Autoplay: automatically judge notes as Perfect without user input. if (!isJudged && GameConfig.autoPlayEnabled && gameObject.activeSelf) { // Only judge once the note reaches its scheduled hit time. - if (GameplayClock.NowSongTime >= hitTime) + if (now >= hitTime) { TryAutoJudgePerfect(); } @@ -252,9 +256,9 @@ public class Note : BaseNote // Force miss if we've passed the deadline without being judged if (!isJudged && gameObject.activeSelf) { - if (GameplayClock.NowSongTime >= missDeadlineTime) + if (now >= missDeadlineTime) { - if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.Update] Forcing miss for {noteColor} on track {TrackIndex}: now={GameplayClock.NowSongTime:F3}, deadline={missDeadlineTime:F3}"); + if (JudgeManager.IsDebugEnabled) Debug.Log($"[Note.Update] Forcing miss for {noteColor} on track {TrackIndex}: now={now:F3}, deadline={missDeadlineTime:F3}"); JudgeMiss(); } } @@ -278,7 +282,9 @@ public class Note : BaseNote hasLock = locked; float pressTime = hitTime; // force Perfect regardless of frame timing - float maxWindow = (judgeConfig?.missRange ?? 0.5f); + float maxWindow = judgeConfig != null + ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) + : 0.5f; // If not in judge zone, still allow judgement (we're using scheduled timing). if (TrackKeyManager.Instance != null) @@ -344,14 +350,14 @@ public class Note : BaseNote Judge(); } - private void HandlePress(KeyCode key) + private void HandlePress(int trackIndex, double pressDspTime) { // Autoplay ignores player input to guarantee Perfect. if (GameConfig.autoPlayEnabled) return; - // only proceed for matching key and not already judged - if (isJudged || key != keyToPress) + // only proceed for matching track and not already judged + if (isJudged || trackIndex != TrackIndex) return; string myId = CachedId; @@ -362,8 +368,12 @@ public class Note : BaseNote hasLock = true; - float pressTime = GameplayClock.NowSongTime; - float maxWindow = (judgeConfig?.missRange ?? 0.5f); + // Convert the Input System press timestamp (dspTime) into song time for sub-frame-accurate + // judgement. Equivalent to the old NowSongTime read but captured at the exact input moment. + float pressTime = GameplayClock.SongTimeFromDsp(pressDspTime); + float maxWindow = judgeConfig != null + ? Mathf.Max(judgeConfig.goodEarlyRange, judgeConfig.goodLateRange) + : 0.5f; // If there is a controller and the note is not inside judge zone, only allow judgment // if the press time is within the allowed window. Otherwise ignore the press. @@ -399,14 +409,19 @@ public class Note : BaseNote } } - float timeDifference = Mathf.Abs(pressTime - hitTime); // raw offset ms: positive = note was early (hitTime > pressTime) float rawOffsetMs = (hitTime - pressTime) * 1000f; + float offset = pressTime - hitTime; // positive = late, negative = early string judgeResult = null; if (judgeConfig != null) { - if (timeDifference <= judgeConfig.perfectRange) + float earlyTolerance, lateTolerance; + + // Perfect window + earlyTolerance = judgeConfig.perfectEarlyRange; + lateTolerance = judgeConfig.perfectLateRange; + if (offset >= -earlyTolerance && offset <= lateTolerance) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Perfect"); judgeResult = "Perfect"; @@ -415,7 +430,8 @@ public class Note : BaseNote if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; ScoreManager.Instance.RecordOffset(rawOffsetMs); } - else if (timeDifference <= judgeConfig.greatRange) + // Great window + else if (offset >= -(earlyTolerance = judgeConfig.greatEarlyRange) && offset <= (lateTolerance = judgeConfig.greatLateRange)) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Great"); judgeResult = "Great"; @@ -424,7 +440,8 @@ public class Note : BaseNote if (noteData != null) noteData.judgeOffsetMs = rawOffsetMs; ScoreManager.Instance.RecordOffset(rawOffsetMs); } - else if (timeDifference <= judgeConfig.goodRange) + // Good window + else if (offset >= -(earlyTolerance = judgeConfig.goodEarlyRange) && offset <= (lateTolerance = judgeConfig.goodLateRange)) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Good"); judgeResult = "Good"; @@ -451,21 +468,22 @@ public class Note : BaseNote } else { - // fallback timing + // fallback timing (symmetric, legacy) + float timeDifference = Mathf.Abs(pressTime - hitTime); if (timeDifference <= 0.08f) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Perfect"); judgeResult = "Perfect"; ScoreManager.Instance.countPerfect += 1; // Add missing offset record for fallback path too if possible - ScoreManager.Instance.RecordOffset(0); + ScoreManager.Instance.RecordOffset(rawOffsetMs); } else if (timeDifference <= 0.15f) { if (JudgeManager.IsDebugEnabled) Debug.Log($"{keyToPress}: Great"); judgeResult = "Great"; ScoreManager.Instance.countGreat += 1; - ScoreManager.Instance.RecordOffset(0); + ScoreManager.Instance.RecordOffset(rawOffsetMs); } else { @@ -522,6 +540,10 @@ public class Note : BaseNote LogTapJudge(judgeResult, rawOffsetMs, rewrittenToPerfect, false, pressTime); GameplayLevelRuleEventBus.NotifyTapJudged(TrackIndex, judgeResult, pressTime, noteData); + // FAST/SLOW 预埋反馈:offset = pressTime - hitTime,正=晚(SLOW)/负=早(FAST)。 + // 默认无订阅者时为 no-op,不影响任何判定/计分逻辑。 + JudgeFeedback.Report(TrackIndex, judgeResult, offset * 1000f); + if (isSyncNote && judgeResult != "Miss") { effectEventController.TryTriggerMultiNoteShake(); @@ -656,8 +678,8 @@ public class Note : BaseNote private void ReturnToPool() { - InputManager.OnKeyPressed -= HandlePress; - + InputManager.OnTrackPressedWithDspTime -= HandlePress; + // CRITICAL: Always release lock and unregister before deactivating string myId = CachedId; diff --git a/Assets/scripts/gamePlay_gameplay/NoteJudgeConfig.cs b/Assets/scripts/gamePlay_gameplay/NoteJudgeConfig.cs index 1f89620c..171485d8 100644 --- a/Assets/scripts/gamePlay_gameplay/NoteJudgeConfig.cs +++ b/Assets/scripts/gamePlay_gameplay/NoteJudgeConfig.cs @@ -6,13 +6,33 @@ [CreateAssetMenu(fileName = "NoteJudgeConfig", menuName = "Game/NoteJudgeConfig")] public class NoteJudgeConfig : ScriptableObject { - [Header("Inspector")] - [Tooltip("Perfect")] - public float perfectRange = 0.1f; - [Tooltip("Great")] - public float greatRange = 0.2f; - [Tooltip("Good")] - public float goodRange = 0.3f; - [Tooltip("Miss")] + [Header("Perfect Window")] + [Tooltip("Perfect early tolerance (seconds)")] + public float perfectEarlyRange = 0.1f; + [Tooltip("Perfect late tolerance (seconds)")] + public float perfectLateRange = 0.1f; + + [Header("Great Window")] + [Tooltip("Great early tolerance (seconds)")] + public float greatEarlyRange = 0.2f; + [Tooltip("Great late tolerance (seconds)")] + public float greatLateRange = 0.2f; + + [Header("Good Window")] + [Tooltip("Good early tolerance (seconds)")] + public float goodEarlyRange = 0.3f; + [Tooltip("Good late tolerance (seconds)")] + public float goodLateRange = 0.3f; + + [Header("Miss Window")] + [Tooltip("Miss deadline after hitTime (late only, seconds)")] public float missRange = 0.2f; + + // Legacy symmetric accessors for compatibility during migration + [System.Obsolete("Use perfectEarlyRange/perfectLateRange instead")] + public float perfectRange => Mathf.Max(perfectEarlyRange, perfectLateRange); + [System.Obsolete("Use greatEarlyRange/greatLateRange instead")] + public float greatRange => Mathf.Max(greatEarlyRange, greatLateRange); + [System.Obsolete("Use goodEarlyRange/goodLateRange instead")] + public float goodRange => Mathf.Max(goodEarlyRange, goodLateRange); } diff --git a/Assets/scripts/gamePlay_gameplay/NotePool.cs b/Assets/scripts/gamePlay_gameplay/NotePool.cs index 88656331..b729c9f6 100644 --- a/Assets/scripts/gamePlay_gameplay/NotePool.cs +++ b/Assets/scripts/gamePlay_gameplay/NotePool.cs @@ -14,9 +14,16 @@ public class NotePool : MonoBehaviour [Header("Pool Size")] public int poolSize = 24; - public int maxPoolSize = 300; + // Worst-case hold-segment count for one long note ≈ length(s) × sm(≤8) × bpm / 15. + // A 4s hold at 120bpm with max flow speed needs ~256 middle segments of one color at once. + // maxPoolSize is the retain cap on return (below it, returned segments are kept, not + // Destroyed), so it must exceed that worst case to avoid churn when a dense long note recycles. + public int maxPoolSize = 320; + // poolSize × this = per-color hold-segment prewarm target. Sized so prewarm covers the + // worst case (24 × 12 = 288 ≥ 256), preventing a mid-song runtime Instantiate spike the + // first time a dense long note needs more segments than were prewarmed. [Tooltip("Multiplier for hold segment pool size (per color).")] - public int holdSegmentPoolMultiplier = 8; + public int holdSegmentPoolMultiplier = 12; [Tooltip("Multiplier for hold end pool size (per color).")] public int holdEndPoolMultiplier = 8; @@ -257,12 +264,18 @@ public class NotePool : MonoBehaviour // Defensive: unregister/unlock any TrackKeyManager state referencing this object to avoid stale queues/locks try { - // Try to determine track index from Note or HoldNote components + // Try to determine track index from Note or HoldNote components. Reuse each + // note's already-cached instance-id string (Note.GetCachedInstanceId / + // HoldNote.GetQueueNoteId) instead of allocating a fresh GetInstanceID().ToString() + // on every return — a 256-segment hold recycle would otherwise burst 256 string + // allocations. The cached ids are byte-identical to what was used at RegisterKey. int trackIdx = -1; + string id = null; var noteComp = obj.GetComponent(); if (noteComp != null) { trackIdx = noteComp.GetTrackIndex(); + id = noteComp.GetCachedInstanceId(); } else { @@ -270,11 +283,20 @@ public class NotePool : MonoBehaviour if (holdComp != null) { trackIdx = holdComp.trackIndex; + id = holdComp.GetQueueNoteId(); } } - string id = obj.GetInstanceID().ToString(); - if (TrackKeyManager.Instance != null && trackIdx >= 0) + // If a note component supplied a cached id, use it. When it's null (e.g. a hold + // middle/end segment that never registered a queue key), there is nothing to + // unregister — skip without allocating a throwaway ToString. Only the fully + // unknown case (no note component at all) falls back to a fresh id. + if (string.IsNullOrEmpty(id) && noteComp == null && trackIdx < 0) + { + id = obj.GetInstanceID().ToString(); + } + + if (TrackKeyManager.Instance != null && trackIdx >= 0 && !string.IsNullOrEmpty(id)) { try { TrackKeyManager.Instance.UnregisterKey(trackIdx, id); } catch { } try { TrackKeyManager.Instance.UnlockTrackForJudge(trackIdx, id); } catch { } diff --git a/Assets/scripts/gamePlay_gameplay/NoteSpawner.cs b/Assets/scripts/gamePlay_gameplay/NoteSpawner.cs index be6de526..b7d44854 100644 --- a/Assets/scripts/gamePlay_gameplay/NoteSpawner.cs +++ b/Assets/scripts/gamePlay_gameplay/NoteSpawner.cs @@ -23,6 +23,13 @@ public class NoteSpawner : MonoBehaviour public float spawnOffset = 0f; // Documentation text normalized. public float static_value_add_to_spawnoffset = 0f; + [Header("Visual Spawn Lead")] + [Tooltip("Seconds to spawn notes earlier for visual readability only. Positive values make notes appear sooner without changing hit timing or judgement.")] + public float visualPreSpawnTime = 0f; + // Inspector 设定的视觉提前量基准;玩家 PlayerPrefs 视觉偏移在此之上叠加。 + private float visualPreSpawnTimeBase = 0f; + private bool visualPreSpawnBaseCaptured = false; + [Header("Global timing adjustments")] [Tooltip("Global additional realtime offset (seconds) added to hit times for all notes. Use to test input latency or adjust judgement timing. Default 0. Can be negative to make notes arrive earlier.")] public float globalHitDelay = 0f; @@ -35,7 +42,7 @@ public class NoteSpawner : MonoBehaviour // Documentation text normalized. [Tooltip("Multiplier applied to visual fall speed. Changing this will automatically adjust spawn timing so notes still arrive at their original beat times.")] - [Range(1f, 3f)] + [Range(1f, 3.25f)] public float speedMultiplier = 2f; [Header("Calibration")] @@ -84,9 +91,9 @@ public class NoteSpawner : MonoBehaviour public GameObject animations; // optional: constants for runtime clamping (kept for internal use) - // Player-facing speed is clamped to 1-3; FlowSpeedGlobalMultiplier makes the real range 2-6. + // Player-facing speed is clamped to 1-3.25; FlowSpeedGlobalMultiplier makes the real range 2-6.5. private const float SpeedMultiplierMin = 1f; - private const float SpeedMultiplierMax = 3f; + private const float SpeedMultiplierMax = 3.25f; // 全局流速系数:与用户可调流速(speedMultiplier)相乘的独立系数,加快音符下落。 // 为什么用它而非 bpm 倍率:noteSpeed = 10.75 * sm * bpm / 240,若靠 ×bpm 提速, // 长条音符的视觉长度由 ApplyVisualScale(sm) 决定(只随 sm 不随 bpm)、且分段数 segmentInterval=(60/bpm)/4 @@ -199,14 +206,39 @@ public class NoteSpawner : MonoBehaviour TrySubscribeJudgeManager(); } - private void Start() + // 计算最终 spawnOffset = 玩家校准量(PlayerPrefs) + 项目级定数(Inspector)。 + // 必须在生成协程启动前调用;Start 与 LoadBeatmap 都调,保证不受生命周期顺序影响。 + private void ApplySpawnOffsetFromPrefs() { - // 读取 PlayerPrefs 中的延迟偏移值(秒)并应用 const string DELAY_PREFS_KEY = "UserGlobalDelaySeconds"; float savedDelay = PlayerPrefs.GetFloat(DELAY_PREFS_KEY, 0f); spawnOffset = savedDelay + static_value_add_to_spawnoffset; if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Applied spawnOffset={spawnOffset} (Saved={savedDelay} + Static={static_value_add_to_spawnoffset})"); + ApplyVisualOffsetFromPrefs(); + } + + // 纯视觉偏移:仅影响音符出现时机(chartSpawnTime),不改变判定时间(chartHitTime), + // 因此对业务逻辑绝对等效。玩家值叠加在 Inspector 基准之上。 + private void ApplyVisualOffsetFromPrefs() + { + if (!visualPreSpawnBaseCaptured) + { + visualPreSpawnTimeBase = visualPreSpawnTime; + visualPreSpawnBaseCaptured = true; + } + + const string VISUAL_OFFSET_PREFS_KEY = "UserVisualOffsetSeconds"; + float savedVisual = PlayerPrefs.GetFloat(VISUAL_OFFSET_PREFS_KEY, 0f); + visualPreSpawnTime = visualPreSpawnTimeBase + savedVisual; + if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Applied visualPreSpawnTime={visualPreSpawnTime} (Base={visualPreSpawnTimeBase} + Saved={savedVisual})"); + } + + private void Start() + { + // 读取 PlayerPrefs 中的延迟偏移值(秒)并应用 + ApplySpawnOffsetFromPrefs(); + // 读取 PlayerPrefs 中的同步音符开关状态 enableSyncNotePrefab = PlayerPrefs.GetInt("EnableSyncNotePrefab", 1) == 1; if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Applied enableSyncNotePrefab={enableSyncNotePrefab}"); @@ -289,6 +321,9 @@ public class NoteSpawner : MonoBehaviour } noteIndexToHoldId.Clear(); + // 谱面修饰符:每次加载谱面时重建映射表(Random 会固定一次洗牌结果) + ChartModifiers.InvalidateMapping(); + // initialize JudgeManager total note count (count each NoteData as one logical note; // long notes are counted once as a single logical note) int total = 0; @@ -302,6 +337,11 @@ public class NoteSpawner : MonoBehaviour startTime = GameplayClock.ChartStartSongTime; if (JudgeManager.IsDebugEnabled) Debug.Log($"歌曲开始时间: {startTime}"); + // 关键:LoadBeatmap 可能早于本组件的 Start() 执行(GameManager 在启动流程里直接调本方法), + // 若只在 Start 里算 spawnOffset,生成协程会读到默认值 0,导致 Inspector 定数/玩家校准完全失效。 + // 故在启动生成协程前再算一次,保证不受生命周期顺序影响。 + ApplySpawnOffsetFromPrefs(); + // keep reference so we can stop spawning when doing immediate settlement spawnCoroutine = StartCoroutine(SpawnNotes()); } @@ -360,7 +400,11 @@ public class NoteSpawner : MonoBehaviour float travelTime = GetLaneTravelTimeSeconds(laneTravelTimes, note.trackIndex); float spawnTime = note.time - travelTime; - float chartSpawnTime = startTime + spawnTime + spawnOffset; + // globalHitDelay 是输入/音画延迟补偿:它已进入判定时刻(chartHitTime/baseHit/scheduledEndTime), + // 因而音符 activationTime = hitTime - travelTime 也被整体前/后移。生成时刻必须同样加上它, + // 否则负补偿(音符提前到线)时 activationTime 提前、生成没提前,音符会一出生就弹到半路。 + // 加上后 chartSpawnTime 恒等于 activationTime(再减去 visualPreSpawnTime 提前量),各取值下都从出生点自然下落。 + float chartSpawnTime = startTime + spawnTime + spawnOffset + globalHitDelay - Mathf.Max(0f, visualPreSpawnTime); float delay = chartSpawnTime - GameplayClock.NowSongTime; if (delay > 0) @@ -390,6 +434,39 @@ public class NoteSpawner : MonoBehaviour AllNotesSpawned?.Invoke(); } + // Caches the pooled note's sync-note child so we can toggle it on/off per spawn instead of + // Destroy+Instantiate (plus a child-enumerator alloc) on every note. The child is created + // lazily the first time a note actually needs it and reused for the note's whole lifetime. + private sealed class SyncNoteChildRef : MonoBehaviour + { + public GameObject child; + } + + private void ApplySyncNoteChild(GameObject note, bool isSync) + { + if (note == null || !enableSyncNotePrefab || syncNotePrefab == null) + { + return; + } + + var refComp = note.GetComponent(); + if (refComp == null) + { + refComp = note.AddComponent(); + } + + // Create the sync child once (only if this note has ever needed it). + if (refComp.child == null && isSync) + { + refComp.child = Instantiate(syncNotePrefab, note.transform); + } + + if (refComp.child != null && refComp.child.activeSelf != isSync) + { + refComp.child.SetActive(isSync); + } + } + private KeyCode GetCachedKeyCode(string color) { if (string.IsNullOrEmpty(color)) return KeyCode.None; @@ -409,6 +486,9 @@ public class NoteSpawner : MonoBehaviour return; } + // 应用谱面修饰符(Mirror/Random):重映射 trackIndex + int remappedTrackIndex = ChartModifiers.RemapTrackIndex(noteData.trackIndex); + KeyCode key = GetCachedKeyCode(noteData.color); if (key == KeyCode.None) { @@ -423,27 +503,16 @@ public class NoteSpawner : MonoBehaviour return; } - if (enableSyncNotePrefab && syncNotePrefab != null) - { - // Clean up any existing sync prefab from previous use in pool - foreach (Transform child in note.transform) - { - if (child.name.StartsWith(syncNotePrefab.name)) - { - Destroy(child.gameObject); - } - } + ApplySyncNoteChild(note, isSync); - if (isSync) - { - Instantiate(syncNotePrefab, note.transform); - } - } + Transform spawnPoint = spawnPoints[remappedTrackIndex]; - Transform spawnPoint = spawnPoints[noteData.trackIndex]; - // Calculate hit time (realtime when note should be judged) - float chartHitTime = Mathf.Max(0f, startTime + noteData.time + globalHitDelay); + // spawnOffset = 玩家在设置里校准的全局延迟量(PlayerPrefs UserGlobalDelaySeconds), + // 必须与生成时机(chartSpawnTime)一致地进入 hitTime,否则判定窗口不随校准移动、 + // 且 activation=hitTime-travelTime 与生成时机错位导致音符出生即弹跳。 + // 与谱面 globalDelaySeconds(作用于音频播放)彼此独立,不重复偏移。 + float chartHitTime = Mathf.Max(0f, startTime + noteData.time + spawnOffset + globalHitDelay); Vector3 initialPosition = spawnPoint.position; note.transform.position = initialPosition; @@ -451,11 +520,11 @@ public class NoteSpawner : MonoBehaviour Note noteScript = note.GetComponent(); NoteController noteController = note.GetComponent(); - + if (noteScript != null) { - // Setup note script with timing parameters - noteScript.Setup(key, noteData.trackIndex, noteSpeed, chartHitTime, noteData.color, judgeConfig, noteData, isSync); + // Setup note script with timing parameters — 传入重映射后的 trackIndex + noteScript.Setup(key, remappedTrackIndex, noteSpeed, chartHitTime, noteData.color, judgeConfig, noteData, isSync); // Configure controller for absolute positioning (replaces relative Translate) if (noteController != null) @@ -480,6 +549,9 @@ public class NoteSpawner : MonoBehaviour return -1; } + // 应用谱面修饰符(Mirror/Random):重映射 trackIndex + int remappedTrackIndex = ChartModifiers.RemapTrackIndex(noteData.trackIndex); + KeyCode key = GetCachedKeyCode(noteData.color); if (key == KeyCode.None) { @@ -512,15 +584,15 @@ public class NoteSpawner : MonoBehaviour // 新逻辑下不拉伸(每段保持 prefab 原始大小);旧逻辑下按流速 sm 拉伸每段。 float holdVisualScale = useMultiSegmentFill ? 1f : sm; - Transform spawnPoint = spawnPoints[noteData.trackIndex]; + Transform spawnPoint = spawnPoints[remappedTrackIndex]; // Documentation text normalized. - float scheduledEndTime = Mathf.Max(0f, startTime + (noteData.time + noteData.length) + globalHitDelay); + float scheduledEndTime = Mathf.Max(0f, startTime + (noteData.time + noteData.length) + spawnOffset + globalHitDelay); // Documentation text normalized. int holdNoteId = ++holdNoteIdCounter; // base realtime for hits - float baseHit = Mathf.Max(0f, startTime + noteData.time + globalHitDelay); + float baseHit = Mathf.Max(0f, startTime + noteData.time + spawnOffset + globalHitDelay); GameObject startObj = notePool.GetStartNote(noteData.color); if (startObj == null) @@ -529,22 +601,7 @@ public class NoteSpawner : MonoBehaviour return -1; } - if (enableSyncNotePrefab && syncNotePrefab != null) - { - // Clean up any existing sync prefab from previous use in pool - foreach (Transform child in startObj.transform) - { - if (child.name.StartsWith(syncNotePrefab.name)) - { - Destroy(child.gameObject); - } - } - - if (isSync) - { - Instantiate(syncNotePrefab, startObj.transform); - } - } + ApplySyncNoteChild(startObj, isSync); // Hold segments use absolute positioning; spawn at the lane origin. startObj.transform.position = spawnPoint.position; @@ -553,8 +610,8 @@ public class NoteSpawner : MonoBehaviour HoldNote holdNote = startObj.GetComponent(); if (holdNote != null) { - // pass realtime hit time (startTime + note.time) and delay 0, include globalHitDelay - holdNote.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, 0f, false, scheduledEndTime, noteData.color, key, "start", travelTime, judgeConfig, noteData, isSync); + // pass realtime hit time (startTime + note.time) and delay 0, include globalHitDelay — 使用重映射后的 trackIndex + holdNote.Setup(holdNoteId, remappedTrackIndex, noteSpeed, baseHit, 0f, false, scheduledEndTime, noteData.color, key, "start", travelTime, judgeConfig, noteData, isSync); // 旧逻辑=按流速 sm 拉伸;新逻辑(多段填充)=不拉伸(holdVisualScale=1)。 float visualScale = holdVisualScale; @@ -568,39 +625,31 @@ public class NoteSpawner : MonoBehaviour return -1; } - for (int i = 1; i < segmentCount; i++) + // 单条 Tiled body 取代原来的 N 个 middle 段密铺:一条 Tiled 长条覆盖 head→end 整段, + // 按绝对时间下落,end 越过判定线后回收。它纯视觉、不参与判定/计分,故 segmentCount / + // actualSegmentInterval 只再用于 end 段延迟(下方 endDelay),中段判定业务逻辑完全不变。 { - // keep segment delays based on actualSegmentInterval (unscaled) so middle pieces are consecutive regardless of visual speed - float segmentDelay = i * actualSegmentInterval; // Documentation text normalized. - GameObject segObj = notePool.GetHoldNoteSegment(noteData.color); - if (segObj == null) + GameObject bodyObj = notePool.GetHoldNoteSegment(noteData.color); + if (bodyObj == null) { - Debug.LogError("NotePool returned null hold segment object."); - continue; - } - - segObj.transform.position = spawnPoint.position; - segObj.transform.rotation = Quaternion.identity; - - HoldNote holdSeg = segObj.GetComponent(); - if (holdSeg != null) - { - // Documentation text normalized. - holdSeg.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, segmentDelay, false, scheduledEndTime, noteData.color, key, "middle", travelTime, judgeConfig, noteData, false); - - // 与开始段一致:旧逻辑拉伸、新逻辑不拉伸(靠更多段密铺填满)。 - float visualScaleMid = holdVisualScale; - holdSeg.ApplyVisualScale(visualScaleMid); - holdSeg.visualSpeedMultiplier = sm; - - // Immediately calibrate position and schedule additional checks to correct any offset - Vector3 calibSpawnPos = spawnPoint.position; - holdSeg.CalibratePosition(calibSpawnPos, calibrateTolerance); - EnqueueHoldCalibration(holdSeg, calibSpawnPos); + Debug.LogError("NotePool returned null hold body object."); } else { - Debug.LogError("Hold segment object is missing HoldNote component."); + bodyObj.transform.position = spawnPoint.position; + bodyObj.transform.rotation = Quaternion.identity; + + HoldNote holdBody = bodyObj.GetComponent(); + if (holdBody != null) + { + holdBody.visualSpeedMultiplier = sm; + holdBody.SetupBody(holdNoteId, remappedTrackIndex, noteSpeed, baseHit, + noteData.length, travelTime, noteData.color, spawnPoint.position); + } + else + { + Debug.LogError("Hold body object is missing HoldNote component."); + } } } @@ -618,7 +667,7 @@ public class NoteSpawner : MonoBehaviour HoldNote holdEnd = endObj.GetComponent(); if (holdEnd != null) { - holdEnd.Setup(holdNoteId, noteData.trackIndex, noteSpeed, baseHit, endDelay, true, scheduledEndTime, noteData.color, key, "end", travelTime, judgeConfig, noteData, false); + holdEnd.Setup(holdNoteId, remappedTrackIndex, noteSpeed, baseHit, endDelay, true, scheduledEndTime, noteData.color, key, "end", travelTime, judgeConfig, noteData, false); // 与开始段一致:旧逻辑拉伸、新逻辑不拉伸。 float visualScaleEnd = holdVisualScale; @@ -913,7 +962,9 @@ public class NoteSpawner : MonoBehaviour float end = note.type == "hold" ? (note.time + note.length) : note.time; if (end > chartEndTime) chartEndTime = end; } - chartEndTime += Mathf.Max(0f, globalHitDelay); + // 正向校准/延迟会把音符整体后移,结算需相应顺延以免最后一颗音符尚在判定就触发结算; + // 负向偏移不提前结算(钳 0),避免切掉仍在场的音符。postChartDelay 另有 2s 缓冲兜底。 + chartEndTime += Mathf.Max(0f, spawnOffset + globalHitDelay); const float postChartDelay = 2f; float settlementTime = startTime + chartEndTime + postChartDelay; diff --git a/Assets/scripts/gamePlay_gameplay/OffsetBarDisplay.cs b/Assets/scripts/gamePlay_gameplay/OffsetBarDisplay.cs new file mode 100644 index 00000000..19782c7c --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/OffsetBarDisplay.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +/// +/// 判定线旁的实时偏移条:每次判定在横条上生成一个彩色点, +/// x 位置表示偏移大小(左=偏晚 Late,右=偏早 Early),随后淡出。 +/// 纯显示组件,订阅 ScoreManager.OnOffsetRecorded,不参与任何判定/计分逻辑。 +/// 用法:挂到一个 RectTransform 上,作为横条容器;在 Inspector 指定 dotPrefab。 +/// +[RequireComponent(typeof(RectTransform))] +public class OffsetBarDisplay : MonoBehaviour +{ + [Header("References")] + [Tooltip("单个偏移点的预制(应带 Image/RectTransform)。若留空则运行时自动生成一个圆点。")] + public RectTransform dotPrefab; + [Tooltip("横条容器;留空则用本物体的 RectTransform。")] + public RectTransform barContainer; + [Tooltip("可选:中线(完美判定 0ms)指示线,仅用于视觉参考。")] + public RectTransform centerMarker; + + [Header("Mapping")] + [Tooltip("映射到横条边缘的最大偏移(毫秒)。超出按此裁剪。")] + public float maxOffsetMs = 120f; + [Tooltip("横条半宽(像素)。点的 x 在 [-halfWidth, halfWidth] 内。留 <=0 则用容器宽度的一半。")] + public float halfWidthPixels = 0f; + + [Header("Appearance")] + public float dotLifetime = 0.9f; + public float dotStartSize = 14f; + public Color earlyColor = new Color(0.35f, 0.7f, 1f, 1f); + public Color lateColor = new Color(1f, 0.55f, 0.35f, 1f); + public Color perfectColor = new Color(0.6f, 1f, 0.6f, 1f); + [Tooltip("|偏移| 小于此值视为完美,用 perfectColor。")] + public float perfectThresholdMs = 8f; + [Tooltip("对象池上限,避免高连击时无限实例化。")] + public int maxPooledDots = 48; + + private class Dot + { + public RectTransform rect; + public Image image; + public float age; + public bool active; + } + + private readonly List pool = new List(); + + private void OnEnable() + { + ScoreManager.OnOffsetRecorded += HandleOffset; + } + + private void OnDisable() + { + ScoreManager.OnOffsetRecorded -= HandleOffset; + } + + private void Awake() + { + if (barContainer == null) barContainer = GetComponent(); + } + + private float HalfWidth() + { + if (halfWidthPixels > 0f) return halfWidthPixels; + if (barContainer != null) return barContainer.rect.width * 0.5f; + return 200f; + } + + // 正数 = 偏早(Early),负数 = 偏晚(Late)。见 ScoreManager.RecordOffset。 + private void HandleOffset(float offsetMs) + { + if (!isActiveAndEnabled) return; + + Dot dot = GetPooledDot(); + if (dot == null) return; + + float clamped = Mathf.Clamp(offsetMs, -maxOffsetMs, maxOffsetMs); + float norm = maxOffsetMs > 0f ? clamped / maxOffsetMs : 0f; // [-1,1] + // Early(正) 放右侧,Late(负) 放左侧。 + float x = norm * HalfWidth(); + + Vector2 pos = dot.rect.anchoredPosition; + pos.x = x; + pos.y = 0f; + dot.rect.anchoredPosition = pos; + dot.rect.sizeDelta = new Vector2(dotStartSize, dotStartSize); + + Color c; + if (Mathf.Abs(offsetMs) < perfectThresholdMs) c = perfectColor; + else c = offsetMs > 0f ? earlyColor : lateColor; + dot.image.color = c; + + dot.age = 0f; + dot.active = true; + dot.rect.gameObject.SetActive(true); + } + + private void Update() + { + float dt = Time.unscaledDeltaTime; + float life = Mathf.Max(0.01f, dotLifetime); + + for (int i = 0; i < pool.Count; i++) + { + Dot d = pool[i]; + if (!d.active) continue; + + d.age += dt; + float t = d.age / life; + if (t >= 1f) + { + d.active = false; + d.rect.gameObject.SetActive(false); + continue; + } + + Color c = d.image.color; + c.a = 1f - t; + d.image.color = c; + } + } + + private Dot GetPooledDot() + { + for (int i = 0; i < pool.Count; i++) + { + if (!pool[i].active) return pool[i]; + } + + if (pool.Count >= maxPooledDots) + { + // 全部占用:复用最老的一个。 + Dot oldest = pool[0]; + for (int i = 1; i < pool.Count; i++) + if (pool[i].age > oldest.age) oldest = pool[i]; + return oldest; + } + + return CreateDot(); + } + + private Dot CreateDot() + { + RectTransform rect; + Image img; + + if (dotPrefab != null) + { + rect = Instantiate(dotPrefab, barContainer); + img = rect.GetComponent(); + if (img == null) img = rect.gameObject.AddComponent(); + } + else + { + GameObject go = new GameObject("OffsetDot", typeof(RectTransform), typeof(Image)); + rect = go.GetComponent(); + rect.SetParent(barContainer, false); + img = go.GetComponent(); + } + + rect.anchorMin = rect.anchorMax = new Vector2(0.5f, 0.5f); + rect.pivot = new Vector2(0.5f, 0.5f); + rect.gameObject.SetActive(false); + + Dot dot = new Dot { rect = rect, image = img, age = 0f, active = false }; + pool.Add(dot); + return dot; + } +} diff --git a/Assets/scripts/gamePlay_gameplay/OffsetBarDisplay.cs.meta b/Assets/scripts/gamePlay_gameplay/OffsetBarDisplay.cs.meta new file mode 100644 index 00000000..07cb8dbd --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/OffsetBarDisplay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd85f26f812f1724782915a7d982b21e \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/PracticeMode.cs b/Assets/scripts/gamePlay_gameplay/PracticeMode.cs new file mode 100644 index 00000000..a21ab17e --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/PracticeMode.cs @@ -0,0 +1,101 @@ +using UnityEngine; + +/// +/// 练习模式配置:支持从任意位置起播 + A-B 段落循环。 +/// 供后续快速接入(UI/GameManager 逻辑需另行连接)。 +/// 默认关闭,不影响现有正常游玩流程。 +/// +public static class PracticeMode +{ + private const string PREF_ENABLED = "PracticeMode_Enabled"; + private const string PREF_START_TIME = "PracticeMode_StartTime"; + private const string PREF_END_TIME = "PracticeMode_EndTime"; + private const string PREF_LOOP = "PracticeMode_Loop"; + + /// + /// 练习模式开关(默认 false)。开启后从 StartTimeSeconds 起播,到达 EndTimeSeconds 时结束或循环。 + /// + public static bool Enabled + { + get => PlayerPrefs.GetInt(PREF_ENABLED, 0) == 1; + set + { + PlayerPrefs.SetInt(PREF_ENABLED, value ? 1 : 0); + PlayerPrefs.Save(); + } + } + + /// + /// 起始时间(秒,谱面 songTime)。默认 0(从头开始)。 + /// + public static float StartTimeSeconds + { + get => PlayerPrefs.GetFloat(PREF_START_TIME, 0f); + set + { + PlayerPrefs.SetFloat(PREF_START_TIME, Mathf.Max(0f, value)); + PlayerPrefs.Save(); + } + } + + /// + /// 结束时间(秒,谱面 songTime)。默认 -1(无限制,播到谱面结束)。 + /// 若 >= 0 且 < StartTimeSeconds,视为无效,忽略。 + /// + public static float EndTimeSeconds + { + get => PlayerPrefs.GetFloat(PREF_END_TIME, -1f); + set + { + PlayerPrefs.SetFloat(PREF_END_TIME, value); + PlayerPrefs.Save(); + } + } + + /// + /// A-B 循环开关(默认 false)。若开启且 EndTimeSeconds 有效,到达终点时自动重新加载并从 StartTimeSeconds 继续。 + /// + public static bool LoopEnabled + { + get => PlayerPrefs.GetInt(PREF_LOOP, 0) == 1; + set + { + PlayerPrefs.SetInt(PREF_LOOP, value ? 1 : 0); + PlayerPrefs.Save(); + } + } + + /// + /// 检查当前 songTime 是否已到达练习终点(需要结束或循环)。 + /// + public static bool ShouldEndOrLoop(float currentSongTime) + { + if (!Enabled) return false; + float end = EndTimeSeconds; + if (end < 0f) return false; // 无限制 + if (end <= StartTimeSeconds) return false; // 无效配置 + return currentSongTime >= end; + } + + /// + /// 重置为默认值(关闭、从头播放、无循环)。 + /// + public static void Reset() + { + Enabled = false; + StartTimeSeconds = 0f; + EndTimeSeconds = -1f; + LoopEnabled = false; + } + + /// + /// 快捷方法:设置 A-B 段落循环(自动开启练习模式和循环开关)。 + /// + public static void SetABLoop(float startSeconds, float endSeconds) + { + Enabled = true; + StartTimeSeconds = startSeconds; + EndTimeSeconds = endSeconds; + LoopEnabled = true; + } +} diff --git a/Assets/scripts/gamePlay_gameplay/PracticeMode.cs.meta b/Assets/scripts/gamePlay_gameplay/PracticeMode.cs.meta new file mode 100644 index 00000000..a6b9355c --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/PracticeMode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3637ef405770ff5468c66074b796bf4b \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/ReplaySystem.cs b/Assets/scripts/gamePlay_gameplay/ReplaySystem.cs new file mode 100644 index 00000000..17ff6783 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/ReplaySystem.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// Replay 录制/回放系统:记录输入流(trackIndex + dspTime)并可重放。 +/// 供后续快速接入(需配合 InputManager 和 autoplay 逻辑连接)。 +/// +[Serializable] +public class ReplayData +{ + [Serializable] + public class InputEvent + { + public int trackIndex; + public double dspTime; + public bool isPress; // true=按下, false=松开 + + public InputEvent(int trackIndex, double dspTime, bool isPress) + { + this.trackIndex = trackIndex; + this.dspTime = dspTime; + this.isPress = isPress; + } + } + + public string songId; + public string difficulty; + public int totalScore; + public float accuracy; + public int maxCombo; + public string recordedAt; // ISO8601 时间戳 + public List inputEvents = new List(); + + // 元数据:录制时的游戏版本、offset 等(供回放时校验兼容性) + public float recordedSpawnOffset; + public float recordedVisualOffset; + public string gameVersion; + + public ReplayData() + { + recordedAt = DateTime.UtcNow.ToString("o"); + } + + public string ToJson() + { + return JsonUtility.ToJson(this, prettyPrint: true); + } + + public static ReplayData FromJson(string json) + { + return JsonUtility.FromJson(json); + } +} + +public static class ReplayRecorder +{ + private static ReplayData currentReplay = null; + private static bool isRecording = false; + + /// + /// 开始录制。通常在 GameManager 启动游玩时调用。 + /// + public static void StartRecording(string songId, string difficulty) + { + if (isRecording) + { + Debug.LogWarning("[ReplayRecorder] Already recording, stop first."); + return; + } + + currentReplay = new ReplayData + { + songId = songId, + difficulty = difficulty, + recordedSpawnOffset = PlayerPrefs.GetFloat("UserGlobalDelaySeconds", 0f), + recordedVisualOffset = PlayerPrefs.GetFloat("UserVisualOffsetSeconds", 0f), + gameVersion = Application.version + }; + + isRecording = true; + Debug.Log($"[ReplayRecorder] Started recording: {songId} ({difficulty})"); + } + + /// + /// 停止录制并返回录制数据。通常在结算时调用。 + /// + public static ReplayData StopRecording(int totalScore, float accuracy, int maxCombo) + { + if (!isRecording || currentReplay == null) + { + Debug.LogWarning("[ReplayRecorder] Not recording."); + return null; + } + + currentReplay.totalScore = totalScore; + currentReplay.accuracy = accuracy; + currentReplay.maxCombo = maxCombo; + + isRecording = false; + Debug.Log($"[ReplayRecorder] Stopped. Recorded {currentReplay.inputEvents.Count} input events."); + + ReplayData result = currentReplay; + currentReplay = null; + return result; + } + + /// + /// 记录一次输入事件。在 InputManager.OnTrackPressedWithDspTime / OnTrackReleasedWithDspTime 中调用。 + /// + public static void RecordInput(int trackIndex, double dspTime, bool isPress) + { + if (!isRecording || currentReplay == null) return; + + currentReplay.inputEvents.Add(new ReplayData.InputEvent(trackIndex, dspTime, isPress)); + } + + /// + /// 取消当前录制。 + /// + public static void CancelRecording() + { + if (isRecording) + { + Debug.Log("[ReplayRecorder] Recording cancelled."); + } + isRecording = false; + currentReplay = null; + } + + public static bool IsRecording => isRecording; +} + +public class ReplayPlayer : MonoBehaviour +{ + private ReplayData replayData; + private int nextEventIndex = 0; + private bool isPlaying = false; + private double replayStartDsp; + + /// + /// 开始回放。在 GameManager 启动游玩前调用(需先设置 autoplay 模式)。 + /// + public void StartPlayback(ReplayData replay) + { + if (replay == null || replay.inputEvents == null || replay.inputEvents.Count == 0) + { + Debug.LogError("[ReplayPlayer] Invalid replay data."); + return; + } + + replayData = replay; + nextEventIndex = 0; + isPlaying = true; + replayStartDsp = AudioSettings.dspTime; + + Debug.Log($"[ReplayPlayer] Started playback: {replay.inputEvents.Count} events"); + } + + /// + /// 停止回放。 + /// + public void StopPlayback() + { + isPlaying = false; + replayData = null; + nextEventIndex = 0; + Debug.Log("[ReplayPlayer] Playback stopped."); + } + + private void Update() + { + if (!isPlaying || replayData == null) return; + + double currentDsp = AudioSettings.dspTime; + double elapsedDsp = currentDsp - replayStartDsp; + + // 按时间顺序触发录制的输入事件 + while (nextEventIndex < replayData.inputEvents.Count) + { + var evt = replayData.inputEvents[nextEventIndex]; + if (evt.dspTime > elapsedDsp) break; // 还没到时间 + + // 触发输入事件(需接入 InputManager 或直接调用 Note 判定逻辑) + TriggerReplayInput(evt.trackIndex, evt.isPress, currentDsp); + + nextEventIndex++; + } + + // 所有事件已回放完成 + if (nextEventIndex >= replayData.inputEvents.Count) + { + Debug.Log("[ReplayPlayer] Playback finished."); + isPlaying = false; + } + } + + /// + /// 触发回放输入。需接入 InputManager 的事件系统或直接调用判定逻辑。 + /// 当前为占位实现,供后续快速接入。 + /// + private void TriggerReplayInput(int trackIndex, bool isPress, double dspTime) + { + // TODO: 接入 InputManager 或 JudgeManager + // 示例:InputManager.SimulateInput(trackIndex, dspTime, isPress); + // 或:直接触发 Note.HandlePress / HoldNote.HandleRelease 等 + + if (JudgeManager.IsDebugEnabled) + { + Debug.Log($"[ReplayPlayer] Trigger: track={trackIndex} press={isPress} dsp={dspTime:F3}"); + } + } + + public bool IsPlaying => isPlaying; +} diff --git a/Assets/scripts/gamePlay_gameplay/ReplaySystem.cs.meta b/Assets/scripts/gamePlay_gameplay/ReplaySystem.cs.meta new file mode 100644 index 00000000..b998b18b --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/ReplaySystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fedd361a2090d884f837c1ce7fc9157e \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/TrackCrashController.cs b/Assets/scripts/gamePlay_gameplay/TrackCrashController.cs index 659d9e51..a10872a9 100644 --- a/Assets/scripts/gamePlay_gameplay/TrackCrashController.cs +++ b/Assets/scripts/gamePlay_gameplay/TrackCrashController.cs @@ -29,8 +29,9 @@ public class TrackCrashController : MonoBehaviour [SerializeField] private bool useDestro2D = true; [SerializeField] private bool autoRestoreOnTrackCrash = false; [SerializeField] private float defaultRestoreDelay = 0f; - [SerializeField] private bool enableDebugKeyTrigger = true; - [SerializeField] private bool debugLogs = true; + [Tooltip("Editor-only debug hotkeys (1-5) to trigger track crash. Never active in builds.")] + [SerializeField] private bool enableDebugKeyTrigger = false; + [SerializeField] private bool debugLogs = false; [SerializeField] private bool useAutoFractureSize = true; [SerializeField] private float fractureCoreRadius = 0.05f; [SerializeField] private float fractureOuterRadius = 0.45f; @@ -97,8 +98,11 @@ public class TrackCrashController : MonoBehaviour RebuildCache(); } +#if UNITY_EDITOR private void Update() { + // Editor-only debug hotkeys; compiled out of player builds so a serialized scene + // value can never leave number-key crash triggers or per-frame Input polling active. if (!enableDebugKeyTrigger) return; @@ -128,6 +132,7 @@ public class TrackCrashController : MonoBehaviour TriggerTrackCrash(4); } } +#endif private void RebuildCache() { diff --git a/Assets/scripts/gamePlay_gameplay/TrackJudgeHitEffectController.cs b/Assets/scripts/gamePlay_gameplay/TrackJudgeHitEffectController.cs index 4a4829c0..c0d0c521 100644 --- a/Assets/scripts/gamePlay_gameplay/TrackJudgeHitEffectController.cs +++ b/Assets/scripts/gamePlay_gameplay/TrackJudgeHitEffectController.cs @@ -60,6 +60,18 @@ public class TrackJudgeHitEffectController : MonoBehaviour private readonly Dictionary particleCache = new Dictionary(); + // 【性能】延迟回收改用 Update 轮询,替代每命中 StartCoroutine + new WaitForSecondsRealtime 的分配。 + // dueUnscaledTime 用 Time.unscaledTime 记录到期时刻,与原 WaitForSecondsRealtime(realtime)语义一致; + // token 校验也原样保留,故回收时机与行为完全等效,仅消除每命中的协程/等待对象 GC。 + private struct PendingParticleReturn + { + public GameObject Instance; + public ParticleCacheEntry Entry; + public int RentToken; + public float DueUnscaledTime; + } + private readonly List pendingParticleReturns = new List(); + private void Awake() { if (Instance == null) @@ -299,7 +311,39 @@ public class TrackJudgeHitEffectController : MonoBehaviour Color tint = GetTrackColor(trackIndex); ApplyParticleColor(entry, tint); - StartCoroutine(RecycleParticleRoutine(instance, entry, CurrentParticleRentToken(instance), Mathf.Max(0.05f, entry.Lifetime))); + // 入队延迟回收(Update 轮询),替代每命中 StartCoroutine + WaitForSecondsRealtime 分配。等效。 + float delay = Mathf.Max(0.05f, entry.Lifetime); + pendingParticleReturns.Add(new PendingParticleReturn + { + Instance = instance, + Entry = entry, + RentToken = CurrentParticleRentToken(instance), + DueUnscaledTime = Time.unscaledTime + delay + }); + } + + private void Update() + { + if (pendingParticleReturns.Count == 0) + return; + + float nowUnscaled = Time.unscaledTime; + for (int i = pendingParticleReturns.Count - 1; i >= 0; i--) + { + var pending = pendingParticleReturns[i]; + if (nowUnscaled < pending.DueUnscaledTime) + continue; + + pendingParticleReturns.RemoveAt(i); + + // 与原 RecycleParticleRoutine 等效:实例已毁或已被重新租用(token 变了)则不回收。 + if (pending.Instance == null) + continue; + if (CurrentParticleRentToken(pending.Instance) != pending.RentToken) + continue; + + ReturnParticleInstance(pending.Instance, pending.Entry); + } } // Prewarm the track particle pool during load so the first judge does not pay @@ -407,20 +451,8 @@ public class TrackJudgeHitEffectController : MonoBehaviour return instance != null && particleRentIds.TryGetValue(instance, out int token) ? token : -1; } - private IEnumerator RecycleParticleRoutine(GameObject instance, ParticleCacheEntry entry, int rentToken, float delay) - { - // Short visual FX should always recycle in real time. Tying them to - // GameplayClock can leak rented objects while the song clock is paused. - yield return new WaitForSecondsRealtime(Mathf.Max(0f, delay)); - - if (instance == null) - yield break; - - if (CurrentParticleRentToken(instance) != rentToken) - yield break; - - ReturnParticleInstance(instance, entry); - } + // 说明:原 RecycleParticleRoutine 协程(每命中 StartCoroutine + new WaitForSecondsRealtime)已由 + // Update 轮询的 pendingParticleReturns 替代,回收时机(unscaled/realtime)与 token 校验语义完全一致。 private void ReturnParticleInstance(GameObject instance, ParticleCacheEntry entry) { @@ -459,6 +491,9 @@ public class TrackJudgeHitEffectController : MonoBehaviour particleCache.TryGetValue(instance, out var entry); ReturnParticleInstance(instance, entry); } + + // 所有租用粒子已强制回收,清空延迟回收队列避免残留项(与原协程在 OnDisable/OnDestroy 随之停止等效)。 + pendingParticleReturns.Clear(); } private Color GetTrackColor(int trackIndex) diff --git a/Assets/scripts/gamePlay_gameplay/effectEventController.cs b/Assets/scripts/gamePlay_gameplay/effectEventController.cs index a5cedf14..67c92518 100644 --- a/Assets/scripts/gamePlay_gameplay/effectEventController.cs +++ b/Assets/scripts/gamePlay_gameplay/effectEventController.cs @@ -71,6 +71,10 @@ public class effectEventController : MonoBehaviour // 门控:漂移关闭且已归位后,跳过每帧的 Perlin/缓存查找;开关切换时才归位一次。 private bool _driftResetDone; + // Last PlayerSkillService.SelectionVersion this controller applied its camera offset for. + // While it matches the current version (and the pivot is already resolved), the offset + // cannot change, so we skip the per-frame IsSkillEnabled traversal + localPosition write. + private int _appliedSkillSelectionVersion = -1; private void Update() { @@ -86,7 +90,13 @@ public class effectEventController : MonoBehaviour _driftResetDone = true; } - UpdatePlayerSkillCameraOffset(); + // The camera offset only depends on the selected skill. Re-evaluate it once per + // selection change; while the version is unchanged and the pivot is resolved, the + // result is identical, so we can skip the work entirely. + if (cachedCameraSkillPivot == null || _appliedSkillSelectionVersion != PlayerSkillService.SelectionVersion) + { + UpdatePlayerSkillCameraOffset(); + } } private void CacheShakeTarget() @@ -378,6 +388,10 @@ public class effectEventController : MonoBehaviour if (cachedCameraSkillPivot == null) return; + // Pivot is resolved; mark this selection version as applied so Update can skip + // subsequent frames until the selection changes again. + _appliedSkillSelectionVersion = PlayerSkillService.SelectionVersion; + Vector3 targetLocalPosition = cameraSkillBaseLocalPosition; if (PlayerSkillService.IsSkillEnabled(10)) { @@ -472,8 +486,12 @@ public class effectEventController : MonoBehaviour if (shake.timeSettings.fadeOutCurve == null || shake.timeSettings.fadeOutCurve.length == 0) shake.timeSettings.fadeOutCurve = AnimationCurve.Linear(0f, 1f, 1f, 0f); - shake.shakers = new[] { shake.positionShake, shake.rotationShake }; - shake.sum = new Vector3[2]; + // 复用已同步的内部数组(通常 PrewarmShakeComponent 已建好),仅在缺失/尺寸不符时重建, + // 避免每次多押命中都分配两个数组。 + if (shake.shakers == null || shake.shakers.Length != 2) + shake.shakers = new[] { shake.positionShake, shake.rotationShake }; + if (shake.sum == null || shake.sum.Length != 2) + shake.sum = new Vector3[2]; shake.StartShake(); } diff --git a/Assets/scripts/gamePlay_gameplay/gfxController.cs b/Assets/scripts/gamePlay_gameplay/gfxController.cs index 8501d884..f8a7997e 100644 --- a/Assets/scripts/gamePlay_gameplay/gfxController.cs +++ b/Assets/scripts/gamePlay_gameplay/gfxController.cs @@ -15,6 +15,80 @@ public class GfxController : MonoBehaviour // 复用的属性块:命中特效透明度覆写用,避免每次命中访问 r.materials 克隆材质。 private MaterialPropertyBlock _fxPropertyBlock; + // ---- 特效对象池:按 prefab 归类复用实例,避免每次命中/弹道 Instantiate+Destroy 造成的 GC。---- + private readonly System.Collections.Generic.Dictionary> _fxPools + = new System.Collections.Generic.Dictionary>(); + private Transform _fxPoolRoot; + + /// 从池中取出一个 prefab 实例(无则实例化),并挂上 GfxPooledItem 追踪。 + private GameObject AcquireFx(GameObject prefab) + { + if (prefab == null) return null; + + GameObject go = null; + if (_fxPools.TryGetValue(prefab, out var stack)) + { + while (stack.Count > 0) + { + var candidate = stack.Pop(); + if (candidate != null) { go = candidate; break; } + } + } + + GfxPooledItem item; + if (go == null) + { + go = Instantiate(prefab); + item = go.GetComponent(); + if (item == null) item = go.AddComponent(); + item.prefabKey = prefab; + item.owner = this; + item.CaptureOriginals(); + } + else + { + item = go.GetComponent(); + item.ResetToOriginal(); + go.SetActive(true); + } + item.released = false; + return go; + } + + /// 把特效实例还原并压回对应 prefab 的池中。 + public void ReleaseFx(GameObject go) + { + if (go == null) return; + var item = go.GetComponent(); + if (item == null) { Destroy(go); return; } + if (item.released) return; + item.released = true; + + if (_fxPoolRoot == null) + { + var root = new GameObject("_FxPool"); + root.transform.SetParent(transform, false); + _fxPoolRoot = root.transform; + } + + go.SetActive(false); + go.transform.SetParent(_fxPoolRoot, false); + + if (!_fxPools.TryGetValue(item.prefabKey, out var stack)) + { + stack = new System.Collections.Generic.Stack(); + _fxPools[item.prefabKey] = stack; + } + stack.Push(go); + } + + /// 延迟 delay 秒后回收(用于非粒子/动画类特效,替代 Destroy(fx, delay))。 + private IEnumerator ReleaseFxDelayed(GameObject go, float delay) + { + yield return new WaitForSeconds(delay); + ReleaseFx(go); + } + [Header("Combatant Objects")] public GameObject enemyMoveObject; public GameObject ally01MoveObject; @@ -287,9 +361,10 @@ public class GfxController : MonoBehaviour // 将缩放应用到挂载点物体(visualParent)本身 visualParent.transform.localScale = Vector3.one * fxScale; - // 作为子物体生成 - fx = Instantiate(fxPrefab, visualParent.transform); - + // 作为子物体生成(从池取出后挂到挂载点下) + fx = AcquireFx(fxPrefab); + fx.transform.SetParent(visualParent.transform, false); + // 确保特效在 UI 层级中显示在最前方 fx.transform.SetAsLastSibling(); @@ -329,11 +404,15 @@ public class GfxController : MonoBehaviour else { Vector3 spawnPos = customWorldPos.HasValue ? customWorldPos.Value : (target.transform.position + offsetVector); - fx = Instantiate(fxPrefab, spawnPos, Quaternion.identity); + fx = AcquireFx(fxPrefab); + fx.transform.SetParent(null, false); + fx.transform.position = spawnPos; + fx.transform.rotation = Quaternion.identity; fx.transform.localScale = Vector3.one * fxScale; } - // 针对粒子系统的特殊处理 + // 针对粒子系统的特殊处理。这里采集一次粒子数组,后面 ApplyTransparencyAndRender + // 复用它,避免对同一 fx 再做一次 GetComponentsInChildren() 分配。 ParticleSystem[] allParticles = fx.GetComponentsInChildren(); foreach (var ps in allParticles) { @@ -342,8 +421,8 @@ public class GfxController : MonoBehaviour main.scalingMode = ParticleSystemScalingMode.Hierarchy; } - // 应用透明度并处理渲染 - ApplyTransparencyAndRender(fx, transparency, isEnemy ? enemyFxSortingOrder : allyFxSortingOrder); + // 应用透明度并处理渲染(复用上面已采集的粒子数组) + ApplyTransparencyAndRender(fx, transparency, isEnemy ? enemyFxSortingOrder : allyFxSortingOrder, allParticles); if (allParticles.Length > 0) { @@ -352,18 +431,18 @@ public class GfxController : MonoBehaviour // 确保粒子在生成时立即播放 ps.Play(true); - // 设置停止动作,如果是根节点粒子,设置自动销毁 + // 设置停止动作:根节点粒子停止时回调,用于回收到对象池(替代 Destroy) if (ps.gameObject == fx) { var m = ps.main; - m.stopAction = ParticleSystemStopAction.Destroy; + m.stopAction = ParticleSystemStopAction.Callback; } } } else { - // 如果不是粒子系统(例如动画 Prefab),在固定时间后销毁(默认 2 秒) - Destroy(fx, 2.0f); + // 如果不是粒子系统(例如动画 Prefab),在固定时间后回收(默认 2 秒) + StartCoroutine(ReleaseFxDelayed(fx, 2.0f)); } } @@ -390,11 +469,11 @@ public class GfxController : MonoBehaviour Transform startTransform = visualSource != null ? visualSource.transform : source.transform; Transform endTransform = visualTarget != null ? visualTarget.transform : target.transform; - // 在源物体位置实例化 - GameObject projectile = Instantiate(projectileFX, startTransform.position, Quaternion.identity); - - // --- 核心修正:挂载到 GfxController (即当前脚本所在的 Canvas 层级物体) --- - projectile.transform.SetParent(this.transform, true); + // 在源物体位置实例化(从池取出) + GameObject projectile = AcquireFx(projectileFX); + projectile.transform.SetParent(this.transform, false); + projectile.transform.position = startTransform.position; + projectile.transform.rotation = Quaternion.identity; projectile.transform.localScale = Vector3.one * projectileScale; // 应用透明度 @@ -475,8 +554,10 @@ public class GfxController : MonoBehaviour Transform startTransform = visualSource != null ? visualSource.transform : source.transform; Transform endTransform = visualTarget != null ? visualTarget.transform : target.transform; - GameObject projectile = Instantiate(prefab, startTransform.position, Quaternion.identity); - projectile.transform.SetParent(this.transform, true); + GameObject projectile = AcquireFx(prefab); + projectile.transform.SetParent(this.transform, false); + projectile.transform.position = startTransform.position; + projectile.transform.rotation = Quaternion.identity; projectile.transform.localScale = Vector3.one * allySkillProjectileScale; // 应用层级(复用 projectileTransparency) @@ -572,19 +653,19 @@ public class GfxController : MonoBehaviour // 到达终点,触发回调并传回终点坐标 onComplete?.Invoke(lastEndPos); - // 到达后销毁 + // 到达后回收 if (projectile != null) { - // 如果有粒子系统,先停止发射让残余拖尾自然消失,或者直接销毁 + // 如果有粒子系统,先停止发射让残余拖尾自然消失,再延迟回收 ParticleSystem[] ps = projectile.GetComponentsInChildren(); if (ps.Length > 0) { foreach (var p in ps) p.Stop(true, ParticleSystemStopBehavior.StopEmitting); - Destroy(projectile, 1.0f); // 给一点时间让残余粒子消失 + StartCoroutine(ReleaseFxDelayed(projectile, 1.0f)); // 给一点时间让残余粒子消失 } else { - Destroy(projectile); + ReleaseFx(projectile); } } } @@ -631,18 +712,18 @@ public class GfxController : MonoBehaviour // 到达终点,触发回调并传回终点坐标 onComplete?.Invoke(lastEndPos); - // 到达后销毁 + // 到达后回收 if (projectile != null) { ParticleSystem[] ps = projectile.GetComponentsInChildren(); if (ps.Length > 0) { foreach (var p in ps) p.Stop(true, ParticleSystemStopBehavior.StopEmitting); - Destroy(projectile, 1.0f); // 给一点时间让残余粒子消失 + StartCoroutine(ReleaseFxDelayed(projectile, 1.0f)); // 给一点时间让残余粒子消失 } else { - Destroy(projectile); + ReleaseFx(projectile); } } } @@ -682,7 +763,7 @@ public class GfxController : MonoBehaviour /// /// 为特效物体及其子物体中的渲染器应用透明度和层级 /// - private void ApplyTransparencyAndRender(GameObject fx, float transparency, int sortingOrder) + private void ApplyTransparencyAndRender(GameObject fx, float transparency, int sortingOrder, ParticleSystem[] cachedParticles = null) { // 1. 处理所有普通渲染器 (MeshRenderer, SpriteRenderer 等) Renderer[] renderers = fx.GetComponentsInChildren(); @@ -717,8 +798,8 @@ public class GfxController : MonoBehaviour } } - // 2. 处理粒子系统颜色 (考虑不同模式) - ParticleSystem[] particles = fx.GetComponentsInChildren(); + // 2. 处理粒子系统颜色 (考虑不同模式)。若调用方已采集粒子数组则复用,避免重复分配。 + ParticleSystem[] particles = cachedParticles ?? fx.GetComponentsInChildren(); foreach (var ps in particles) { var main = ps.main; @@ -883,9 +964,11 @@ public class GfxController : MonoBehaviour return false; } - // 生成弹道 - GameObject projectile = Instantiate(projectilePrefab, enemyObject.transform.position, Quaternion.identity); - projectile.transform.SetParent(enemyObject.transform, true); + // 生成弹道(从池取出) + GameObject projectile = AcquireFx(projectilePrefab); + projectile.transform.SetParent(enemyObject.transform, false); + projectile.transform.position = enemyObject.transform.position; + projectile.transform.rotation = Quaternion.identity; projectile.transform.localScale = Vector3.one * enemyAttackProjectileScale; // 应用层级 @@ -934,7 +1017,7 @@ public class GfxController : MonoBehaviour // 停止粒子系统 ParticleSystem[] ps = projectile.GetComponentsInChildren(); foreach (var p in ps) { var m = p.main; m.loop = false; } - Destroy(projectile, 0.5f); + StartCoroutine(ReleaseFxDelayed(projectile, 0.5f)); } // 播放 Hit 特效、闪红,并执行实际扣血 @@ -1036,11 +1119,12 @@ public class GfxController : MonoBehaviour if (moveObj == null) return; SmoothShake ss = moveObj.GetComponent(); - if (ss == null) ss = moveObj.AddComponent(); + bool freshShaker = false; + if (ss == null) { ss = moveObj.AddComponent(); freshShaker = true; } // 修复:确保 Shaker 实例已初始化,避免 NullReferenceException - if (ss.positionShake == null) ss.positionShake = new Shaker(); - if (ss.rotationShake == null) ss.rotationShake = new Shaker(); + if (ss.positionShake == null) { ss.positionShake = new Shaker(); freshShaker = true; } + if (ss.rotationShake == null) { ss.rotationShake = new Shaker(); freshShaker = true; } // 选择震动参数 Vector3 amplitude = isEnemy ? hitShakeAmplitude : allyHitShakeAmplitude; @@ -1072,10 +1156,14 @@ public class GfxController : MonoBehaviour if (ss.timeSettings.fadeOutCurve == null || ss.timeSettings.fadeOutCurve.length == 0) ss.timeSettings.fadeOutCurve = AnimationCurve.Linear(0, 1, 1, 0); - // 初始化内部数组(插件在 AddComponent 时 Awake 已经运行,由于当时字段为空,需要重新同步数组) - ss.shakers = new Shaker[] { ss.positionShake, ss.rotationShake }; - ss.sum = new Vector3[2]; - + // 初始化内部数组(插件在 AddComponent 时 Awake 已经运行,由于当时字段为空,需要重新同步数组)。 + // 仅在 Shaker 实例首次创建/变更时重建这两个数组;之后每次命中复用,避免每击两次数组分配。 + if (freshShaker || ss.shakers == null || ss.shakers.Length != 2 || ss.sum == null || ss.sum.Length != 2) + { + ss.shakers = new Shaker[] { ss.positionShake, ss.rotationShake }; + ss.sum = new Vector3[2]; + } + // 启动震动 ss.StartShake(); } diff --git a/Assets/scripts/gamePlay_gameplay/globalNoteEffect.cs b/Assets/scripts/gamePlay_gameplay/globalNoteEffect.cs index 9222c5b0..a9b04d1d 100644 --- a/Assets/scripts/gamePlay_gameplay/globalNoteEffect.cs +++ b/Assets/scripts/gamePlay_gameplay/globalNoteEffect.cs @@ -213,9 +213,20 @@ public class globalNoteEffect : MonoBehaviour if (shake.timeSettings.fadeOutCurve == null || shake.timeSettings.fadeOutCurve.length == 0) shake.timeSettings.fadeOutCurve = AnimationCurve.Linear(0f, 1f, 1f, 0f); - shake.shakers = new[] { shake.positionShake, shake.rotationShake }; - shake.sum = new Vector3[2]; - shake.StartShake(); + // 复用内部数组,仅在缺失/尺寸不符时重建,避免每次(近乎每音符)相机震动都分配两个数组。 + if (shake.shakers == null || shake.shakers.Length != 2) + shake.shakers = new[] { shake.positionShake, shake.rotationShake }; + if (shake.sum == null || shake.sum.Length != 2) + shake.sum = new Vector3[2]; + + try + { + shake.StartShake(); + } + catch (System.Exception ex) + { + Debug.LogWarning($"[globalNoteEffect] Camera shake failed (check shake asset references in Inspector): {ex.Message}"); + } } private void TriggerFlashBlink() diff --git a/Assets/scripts/gamePlay_gameplay/groundParticularController.cs b/Assets/scripts/gamePlay_gameplay/groundParticularController.cs index e66d4e81..67d5a09c 100644 --- a/Assets/scripts/gamePlay_gameplay/groundParticularController.cs +++ b/Assets/scripts/gamePlay_gameplay/groundParticularController.cs @@ -321,6 +321,22 @@ public class groundParticularController : MonoBehaviour Vector3 moveDir = (currentEndPos - currentStartPos).normalized; float totalDist = Vector3.Distance(currentStartPos, currentEndPos); + // 【性能】渐变色 PropertyBlock 只在发射时写一次:headColor=Evaluate(1)、tailColor=Evaluate(0)、 + // moveDir 三者对某颗粒子而言全程恒定(gradient 是配置、moveDir 发射即定、Evaluate 参数是固定的 + // 0/1),原先在 UpdateActiveParticles 每帧重写只是重复设同一组值。移到此处一次性写入,视觉完全等效, + // 且消除每帧逐粒子 Get/SetPropertyBlock + 两次 Gradient.Evaluate(避免破坏合批的每帧开销)。 + if (selectedConfig != null && selectedConfig.Value.useGradientOverride && renderer != null) + { + Color headColor = selectedConfig.Value.colorGradient.Evaluate(1f); + Color tailColor = selectedConfig.Value.colorGradient.Evaluate(0f); + + renderer.GetPropertyBlock(_sharedPropBlock); + _sharedPropBlock.SetColor(_headColorId, headColor); + _sharedPropBlock.SetColor(_tailColorId, tailColor); + _sharedPropBlock.SetVector(_moveDirId, moveDir); + renderer.SetPropertyBlock(_sharedPropBlock); + } + _activeParticles.Add(new ActiveParticle { gameObject = particle, @@ -389,7 +405,6 @@ public class groundParticularController : MonoBehaviour } float effectiveBPM = Mathf.Max(currentBPM, 60f); - MaterialPropertyBlock propBlock = _sharedPropBlock; // 倒序遍历便于移除 for (int i = _activeParticles.Count - 1; i >= 0; i--) @@ -408,18 +423,8 @@ public class groundParticularController : MonoBehaviour p.traveledDist += step; p.gameObject.transform.position += p.moveDir * step; - // 动态空间渐变色 - if (p.config != null && p.config.Value.useGradientOverride && p.renderer != null) - { - Color headColor = p.config.Value.colorGradient.Evaluate(1f); - Color tailColor = p.config.Value.colorGradient.Evaluate(0f); - - p.renderer.GetPropertyBlock(propBlock); - propBlock.SetColor(_headColorId, headColor); - propBlock.SetColor(_tailColorId, tailColor); - propBlock.SetVector(_moveDirId, p.moveDir); - p.renderer.SetPropertyBlock(propBlock); - } + // 渐变色 PropertyBlock 已在 Emit() 发射时一次性写入(headColor/tailColor/moveDir 全程恒定), + // 此处无需每帧重写——移除后视觉完全等效,消除每帧逐粒子 Get/SetPropertyBlock + 双 Evaluate 开销。 // 到达终点:回收 if (p.traveledDist >= p.totalDist) diff --git a/Assets/scripts/gamePlay_gameplay/network_submit/GameServerBridge.cs b/Assets/scripts/gamePlay_gameplay/network_submit/GameServerBridge.cs index 5adc4319..eda94f73 100644 --- a/Assets/scripts/gamePlay_gameplay/network_submit/GameServerBridge.cs +++ b/Assets/scripts/gamePlay_gameplay/network_submit/GameServerBridge.cs @@ -85,11 +85,33 @@ public class GameServerBridge : MonoBehaviour int chartScore = 0, idolScore = 0; long totalScore = 0; + float accuracyPercent = 0f; + int maxCombo = 0, perfectCount = 0, greatCount = 0, goodCount = 0, missCount = 0; + if (scoreManager != null) { chartScore = scoreManager.allSum_pmScore; idolScore = scoreManager.allSum_idolScore; totalScore = chartScore + idolScore; + + // 新增:采集准确率、连击、判定分布 + int totalNotes = scoreManager.countPerfect + scoreManager.countGreat + scoreManager.countGood + scoreManager.countMiss; + if (totalNotes > 0) + { + float weighted = scoreManager.countPerfect * 1f + + scoreManager.countGreat * 0.667f + + scoreManager.countGood * 0.333f; + accuracyPercent = (weighted / totalNotes) * 100f; + } + + maxCombo = InGamePerformanceManager.Instance != null + ? InGamePerformanceManager.Instance.HighestComboThisRun + : 0; + + perfectCount = scoreManager.countPerfect; + greatCount = scoreManager.countGreat; + goodCount = scoreManager.countGood; + missCount = scoreManager.countMiss; } string grade = GetGrade(totalScore); @@ -135,7 +157,8 @@ public class GameServerBridge : MonoBehaviour Debug.Log("╚══════════════════════════════════════════════════════╝"); string result = await nm.PushSettlement(songId, difficulty, chartScore, idolScore, - totalScore, grade, runtime, playedAt, hmac); + totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); // ── 结果日志 ── if (result == "OK") @@ -149,7 +172,8 @@ public class GameServerBridge : MonoBehaviour { Debug.LogWarning("[Bridge] ⏳ 服务器繁忙,稍后重试..."); _hasSubmitted = false; - await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac); + await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); } else if (result == "OPT_OUT") { @@ -162,7 +186,8 @@ public class GameServerBridge : MonoBehaviour Debug.LogWarning($"║ ❌ 上传失败!服务器返回: {result}"); Debug.LogWarning($"╚══════════════════════════════════════════════════════╝"); _hasSubmitted = false; - await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac); + await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); } else { @@ -181,14 +206,16 @@ public class GameServerBridge : MonoBehaviour /// 重试上传逻辑:3次重试,每次间隔20秒,失败后缓存到本地 /// private async Task RetrySubmission(string songId, string difficulty, int chartScore, int idolScore, - long totalScore, string grade, double runtime, string playedAt, string hmac) + long totalScore, string grade, double runtime, string playedAt, string hmac, + float accuracyPercent, int maxCombo, int perfectCount, int greatCount, int goodCount, int missCount) { _currentRetryCount++; if (_currentRetryCount >= 3) { Debug.LogWarning($"[Bridge] 已重试 {_currentRetryCount} 次仍失败,缓存到本地待下次启动重试"); - PendingScoreCache.CacheScore(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac); + PendingScoreCache.CacheScore(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); _currentRetryCount = 0; return; } @@ -200,7 +227,8 @@ public class GameServerBridge : MonoBehaviour if (nm == null) { Debug.LogWarning("[Bridge] NetworkManager 不存在,缓存到本地"); - PendingScoreCache.CacheScore(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac); + PendingScoreCache.CacheScore(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); _currentRetryCount = 0; return; } @@ -209,7 +237,8 @@ public class GameServerBridge : MonoBehaviour try { string result = await nm.PushSettlement(songId, difficulty, chartScore, idolScore, - totalScore, grade, runtime, playedAt, hmac); + totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); if (result == "OK") { @@ -219,13 +248,15 @@ public class GameServerBridge : MonoBehaviour else { Debug.LogWarning($"[Bridge] 第 {_currentRetryCount} 次重试失败: {result}"); - await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac); + await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); } } catch (Exception ex) { Debug.LogError($"[Bridge] 第 {_currentRetryCount} 次重试异常: {ex.Message}"); - await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac); + await RetrySubmission(songId, difficulty, chartScore, idolScore, totalScore, grade, runtime, playedAt, hmac, + accuracyPercent, maxCombo, perfectCount, greatCount, goodCount, missCount); } } diff --git a/Assets/scripts/gamePlay_gameplay/network_submit/MessageTypes.cs b/Assets/scripts/gamePlay_gameplay/network_submit/MessageTypes.cs index 9b17af70..db83857e 100644 --- a/Assets/scripts/gamePlay_gameplay/network_submit/MessageTypes.cs +++ b/Assets/scripts/gamePlay_gameplay/network_submit/MessageTypes.cs @@ -160,6 +160,14 @@ namespace GameServer.Client [JsonProperty("play_count")] public int play_count; [JsonProperty("keep_on_time")] public int keep_on_time; [JsonProperty("room_status_text")] public string room_status_text; + + // 新增:准确率、最大连击、判定分布(服务端可选返回,向后兼容) + [JsonProperty("accuracy_percent")] public float accuracy_percent; + [JsonProperty("max_combo")] public int max_combo; + [JsonProperty("perfect_count")] public int perfect_count; + [JsonProperty("great_count")] public int great_count; + [JsonProperty("good_count")] public int good_count; + [JsonProperty("miss_count")] public int miss_count; } [Serializable] @@ -188,6 +196,14 @@ namespace GameServer.Client [JsonProperty("runtime_seconds")] public double runtime_seconds; [JsonProperty("played_at")] public string played_at; [JsonProperty("hmac")] public string hmac; + + // 新增:准确率、最大连击、判定分布(可选,服务端向后兼容) + [JsonProperty("accuracy_percent")] public float accuracy_percent; + [JsonProperty("max_combo")] public int max_combo; + [JsonProperty("perfect_count")] public int perfect_count; + [JsonProperty("great_count")] public int great_count; + [JsonProperty("good_count")] public int good_count; + [JsonProperty("miss_count")] public int miss_count; } [Serializable] diff --git a/Assets/scripts/gamePlay_gameplay/network_submit/NetworkManager.cs b/Assets/scripts/gamePlay_gameplay/network_submit/NetworkManager.cs index 55db3227..7b15a99e 100644 --- a/Assets/scripts/gamePlay_gameplay/network_submit/NetworkManager.cs +++ b/Assets/scripts/gamePlay_gameplay/network_submit/NetworkManager.cs @@ -229,7 +229,13 @@ public class NetworkManager : MonoBehaviour score.grade, score.runtimeSeconds, score.playedAt, - hmac); + hmac, + score.accuracyPercent, + score.maxCombo, + score.perfectCount, + score.greatCount, + score.goodCount, + score.missCount); while (!task.IsCompleted) { @@ -724,7 +730,9 @@ public class NetworkManager : MonoBehaviour public async Task PushSettlement(string songId, string difficulty, int chartScore, int idolScore, long totalScore, string grade, - double runtimeSeconds, string playedAt, string hmac = "") + double runtimeSeconds, string playedAt, string hmac = "", + float accuracyPercent = 0f, int maxCombo = 0, + int perfectCount = 0, int greatCount = 0, int goodCount = 0, int missCount = 0) { if (OnlineModeSettings.IsLocalOnlyMode) { @@ -753,7 +761,14 @@ public class NetworkManager : MonoBehaviour grade = grade, runtime_seconds = runtimeSeconds, played_at = playedAt, - hmac = hmac + hmac = hmac, + // 新增字段 + accuracy_percent = accuracyPercent, + max_combo = maxCombo, + perfect_count = perfectCount, + great_count = greatCount, + good_count = goodCount, + miss_count = missCount }; ScoreAckResponse resp = await PostJson(BuildApiUrl("/api/submit"), req, CancellationToken.None); diff --git a/Assets/scripts/gamePlay_gameplay/network_submit/PendingScoreCache.cs b/Assets/scripts/gamePlay_gameplay/network_submit/PendingScoreCache.cs index fa01f445..c481dc67 100644 --- a/Assets/scripts/gamePlay_gameplay/network_submit/PendingScoreCache.cs +++ b/Assets/scripts/gamePlay_gameplay/network_submit/PendingScoreCache.cs @@ -26,6 +26,14 @@ namespace GameServer.Client public string hmac; public int retryCount; public string cachedAt; + + // 新增字段(向后兼容:旧缓存文件读取时默认为 0) + public float accuracyPercent; + public int maxCombo; + public int perfectCount; + public int greatCount; + public int goodCount; + public int missCount; } [Serializable] @@ -46,7 +54,13 @@ namespace GameServer.Client string grade, double runtimeSeconds, string playedAt, - string hmac) + string hmac, + float accuracyPercent = 0f, + int maxCombo = 0, + int perfectCount = 0, + int greatCount = 0, + int goodCount = 0, + int missCount = 0) { lock (FileLock) { @@ -62,6 +76,12 @@ namespace GameServer.Client existing.grade = grade; existing.runtimeSeconds = runtimeSeconds; existing.hmac = hmac; + existing.accuracyPercent = accuracyPercent; + existing.maxCombo = maxCombo; + existing.perfectCount = perfectCount; + existing.greatCount = greatCount; + existing.goodCount = goodCount; + existing.missCount = missCount; if (string.IsNullOrWhiteSpace(existing.cachedAt)) { existing.cachedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); @@ -81,7 +101,13 @@ namespace GameServer.Client playedAt = playedAt, hmac = hmac, retryCount = 0, - cachedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + cachedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + accuracyPercent = accuracyPercent, + maxCombo = maxCombo, + perfectCount = perfectCount, + greatCount = greatCount, + goodCount = goodCount, + missCount = missCount }); } @@ -122,7 +148,13 @@ namespace GameServer.Client runtimeSeconds = cached.runtimeSeconds, playedAt = cached.playedAt, hmac = cached.hmac, - retryCount = cached.retryCount + retryCount = cached.retryCount, + accuracyPercent = cached.accuracyPercent, + maxCombo = cached.maxCombo, + perfectCount = cached.perfectCount, + greatCount = cached.greatCount, + goodCount = cached.goodCount, + missCount = cached.missCount }); } @@ -289,5 +321,13 @@ namespace GameServer.Client public string playedAt; public string hmac; public int retryCount; + + // 新增字段 + public float accuracyPercent; + public int maxCombo; + public int perfectCount; + public int greatCount; + public int goodCount; + public int missCount; } } diff --git a/Assets/scripts/gamePlay_gameplay/player_effect_prefab/budeff/iBudeffPrefabController.cs b/Assets/scripts/gamePlay_gameplay/player_effect_prefab/budeff/iBudeffPrefabController.cs index 1cea8cc7..82fe306d 100644 --- a/Assets/scripts/gamePlay_gameplay/player_effect_prefab/budeff/iBudeffPrefabController.cs +++ b/Assets/scripts/gamePlay_gameplay/player_effect_prefab/budeff/iBudeffPrefabController.cs @@ -171,7 +171,8 @@ public class iBudeffPrefabController : MonoBehaviour if (enemy == null || buff == null) return; if (!Application.isPlaying) return; if (!IsTrackableEnemyBuff(buff)) return; - Debug.LogWarning($"[iBudeffPrefabController] Enemy buff applied: enemy={enemy.name} id={enemy.GetInstanceID()} buffId={buff.buffId} desc={buff.description} atkMult={buff.attackMultiplier} healMult={buff.healReceivedMultiplier} scoreMult={buff.scoreMultiplier}"); + if (GameConfig.verboseLogs) + Debug.LogWarning($"[iBudeffPrefabController] Enemy buff applied: enemy={enemy.name} id={enemy.GetInstanceID()} buffId={buff.buffId} desc={buff.description} atkMult={buff.attackMultiplier} healMult={buff.healReceivedMultiplier} scoreMult={buff.scoreMultiplier}"); RegisterEnemyEntry(enemy, GetGroupForEnemyBuff(buff), buff.buffId, 0f, GameplayNow); } @@ -189,7 +190,8 @@ public class iBudeffPrefabController : MonoBehaviour if (!Application.isPlaying) return null; var group = GetGroupForIconType(type); string id = Guid.NewGuid().ToString(); - Debug.LogWarning($"[iBudeffPrefabController] Enemy timed effect: enemy={enemy.name} id={enemy.GetInstanceID()} type={type} value={value} duration={duration} group={group}"); + if (GameConfig.verboseLogs) + Debug.LogWarning($"[iBudeffPrefabController] Enemy timed effect: enemy={enemy.name} id={enemy.GetInstanceID()} type={type} value={value} duration={duration} group={group}"); RegisterEnemyEntry(enemy, group, id, value, GameplayNow); return id; } @@ -652,7 +654,9 @@ public class iBudeffPrefabController : MonoBehaviour display.Add((Group.LuckyChance, PlayerBudeffIconType.ot_luckyChance, string.Empty, GetOrderKeyEnemy(s, Group.LuckyChance))); } - if (hasEntries) + // 诊断日志:仅在 verboseLogs 时才拼接(尤其 GetHierarchyPath 会遍历父级构造字符串)。 + // RefreshEnemy 在战斗中每次 buff/计时效果注册与注销都会触发,常开会持续产生字符串 GC。 + if (hasEntries && GameConfig.verboseLogs) { if (display.Count == 0) { diff --git a/Assets/scripts/gamePlay_gameplay/settlePrefab/JudgeOffsetHistogram.cs b/Assets/scripts/gamePlay_gameplay/settlePrefab/JudgeOffsetHistogram.cs new file mode 100644 index 00000000..d12db750 --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/settlePrefab/JudgeOffsetHistogram.cs @@ -0,0 +1,174 @@ +using UnityEngine; +using EasyChart; +using System.Collections.Generic; + +/// +/// 判定偏移直方图:用 EasyChart 可视化判定偏移分布。 +/// 从 ScoreManager.OffsetSamples 读取数据并绘制直方图。 +/// 供后续快速接入(需你指定在何处显示此组件)。 +/// +[RequireComponent(typeof(ChartElement))] +public class JudgeOffsetHistogram : MonoBehaviour +{ + [Header("数据源")] + [SerializeField] private ScoreManager scoreManager; + + [Header("直方图配置")] + [SerializeField] private int binCount = 20; // 分箱数(横轴分段) + [SerializeField] private float rangeMin = -200f; // ms + [SerializeField] private float rangeMax = 200f; // ms + [SerializeField] private Color barColor = new Color(0.2f, 0.6f, 1f, 0.8f); + + [Header("标签")] + [SerializeField] private string chartTitle = "Judge Offset Distribution"; + [SerializeField] private string xAxisLabel = "Offset (ms)"; + [SerializeField] private string yAxisLabel = "Count"; + + private ChartElement chartElement; + private ChartData chartData; + + private void Awake() + { + chartElement = GetComponent(); + if (chartElement == null) + { + Debug.LogError("[JudgeOffsetHistogram] ChartElement component not found!"); + return; + } + + // 自动查找 ScoreManager(如果未手动赋值) + if (scoreManager == null) + { + scoreManager = FindObjectOfType(); + } + } + + /// + /// 刷新直方图:从 ScoreManager.offsetSamples 读取数据并绘制。 + /// 在结算页显示时调用。 + /// + public void RefreshHistogram() + { + if (scoreManager == null) + { + Debug.LogWarning("[JudgeOffsetHistogram] ScoreManager not assigned."); + return; + } + + // 读取偏移样本(ms) + IReadOnlyList samples = scoreManager.OffsetSamples; + if (samples == null || samples.Count == 0) + { + Debug.LogWarning("[JudgeOffsetHistogram] No offset samples available."); + return; + } + + // 分箱统计 + int[] bins = new int[binCount]; + float binWidth = (rangeMax - rangeMin) / binCount; + + foreach (float offset in samples) + { + if (offset < rangeMin || offset >= rangeMax) continue; // 超出范围忽略 + + int binIndex = Mathf.FloorToInt((offset - rangeMin) / binWidth); + binIndex = Mathf.Clamp(binIndex, 0, binCount - 1); + bins[binIndex]++; + } + + // 构建 EasyChart ChartData + BuildChartData(bins, binWidth); + + // 应用到 ChartElement + if (chartElement != null && chartData != null) + { + chartElement.SetData(chartData); + Debug.Log($"[JudgeOffsetHistogram] Refreshed with {samples.Count} samples, {binCount} bins."); + } + } + + private void BuildChartData(int[] bins, float binWidth) + { + chartData = new ChartData(); + + // 配置坐标系为笛卡尔2D + chartData.CoordinateSystem = CoordinateSystemType.Cartesian2D; + chartData.XAxisId = AxisId.XBottom; + chartData.YAxisId = AxisId.YLeft; + + // X 轴配置(类目轴,显示偏移区间) + var xAxis = new AxisConfig + { + id = AxisId.XBottom, + axisType = AxisType.Category, + labels = new List() + }; + for (int i = 0; i < binCount; i++) + { + float binCenter = rangeMin + (i + 0.5f) * binWidth; + xAxis.labels.Add($"{binCenter:F0}"); + } + + // Y 轴配置(数值轴,显示计数) + var yAxis = new AxisConfig + { + id = AxisId.YLeft, + axisType = AxisType.Value, + minValue = 0f, + autoRangeMin = true, + autoRangeMax = true + }; + + chartData.Axes = new List { xAxis, yAxis }; + + // 创建柱状图系列 + var serie = new Serie + { + name = chartTitle, + type = SerieType.Bar, + seriesData = new List() + }; + + // 填充数据点 + for (int i = 0; i < binCount; i++) + { + serie.seriesData.Add(new SeriesData + { + x = i, + value = bins[i] + }); + } + + // 配置柱状图样式(可选) + var barSettings = new BarSettings(); + if (barSettings.textureFill == null) + { + barSettings.textureFill = new TextureFillSettings { color = barColor }; + } + else + { + barSettings.textureFill.color = barColor; + } + serie.settings = barSettings; + + chartData.Series = new List { serie }; + } + + /// + /// 清空直方图。 + /// + public void ClearHistogram() + { + if (chartElement != null) + { + chartElement.SetData(null); + } + } + + // 供 Inspector 测试用的按钮方法 + [ContextMenu("Test Refresh Histogram")] + private void TestRefresh() + { + RefreshHistogram(); + } +} diff --git a/Assets/scripts/gamePlay_gameplay/settlePrefab/JudgeOffsetHistogram.cs.meta b/Assets/scripts/gamePlay_gameplay/settlePrefab/JudgeOffsetHistogram.cs.meta new file mode 100644 index 00000000..53e8f4da --- /dev/null +++ b/Assets/scripts/gamePlay_gameplay/settlePrefab/JudgeOffsetHistogram.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a32f3722084d7f04488e389a81dc8f7e \ No newline at end of file diff --git a/Assets/scripts/gamePlay_gameplay/settlementController.cs b/Assets/scripts/gamePlay_gameplay/settlementController.cs index 5ab5b2f1..45c1a9db 100644 --- a/Assets/scripts/gamePlay_gameplay/settlementController.cs +++ b/Assets/scripts/gamePlay_gameplay/settlementController.cs @@ -98,6 +98,8 @@ public class settlementController : MonoBehaviour public Text earlyHitCount_Text; public Text lateHitCount_Text; public Text avgOffset_Text; + [Tooltip("显示中位数偏移 + 校准建议的文本(可选)")] + public Text offsetSuggestion_Text; [Header("Inspector")] [Tooltip("Settlement background music clip used during the settlement flow.")] @@ -671,6 +673,8 @@ public class settlementController : MonoBehaviour avgOffset_Text.text = targetAvgOffsetMs.ToString("F2") + " ms"; } + UpdateOffsetSuggestion(sm); + targetGameNoteRateText = gameNote_rate != null ? gameNote_rate.text : string.Empty; TrySubmitArenaRoomScore(); @@ -1259,82 +1263,169 @@ public class settlementController : MonoBehaviour // settlement: flash + scale 1.2 -> 1.0 yield return StartCoroutine(FlashCanvasGroupWithScale(settlementCanvasGroup, settleRoot, settleBaseScale * 1.2f, settleBaseScale, introFlashCount, introFlashDuration)); - // right/right_bottom: X 1799 -> 1677.75 + // Main settlement blocks enter together instead of waiting for each part in sequence. + bool hasMainEntryAnimations = false; if (rightBottomRt != null) - yield return StartCoroutine(AnimateAnchoredXAndFade(rightBottomRt, 1799f, 1677.75f, introMoveDuration, false)); - - // left/pics/quhuiImage flash + { + StartCoroutine(AnimateAnchoredXAndFade(rightBottomRt, 1799f, 1677.75f, introMoveDuration, false)); + hasMainEntryAnimations = true; + } if (quhuiImageTr != null) - yield return StartCoroutine(FlashInTransform(quhuiImageTr, introFlashCount, introFlashDuration)); - yield return StartCoroutine(FlashInTransform(thisSong_backPic != null ? thisSong_backPic.transform : null, 1, introStaticElementDuration)); - yield return StartCoroutine(FlashInTransform(songName_Text != null ? songName_Text.transform : null, 1, introStaticElementDuration)); - - // score_bottom + shenstars move in together - Coroutine scoreBottomIn = null; - Coroutine shenstarsIn = null; + { + StartCoroutine(FlashInTransform(quhuiImageTr, introFlashCount, introFlashDuration)); + hasMainEntryAnimations = true; + } + if (thisSong_backPic != null) + { + StartCoroutine(FlashInTransform(thisSong_backPic.transform, 1, introStaticElementDuration)); + hasMainEntryAnimations = true; + } + if (songName_Text != null) + { + StartCoroutine(FlashInTransform(songName_Text.transform, 1, introStaticElementDuration)); + hasMainEntryAnimations = true; + } if (scoreBottomRt != null) - scoreBottomIn = StartCoroutine(AnimateAnchoredXAndFade(scoreBottomRt, -1646.9f, 0f, introMoveDuration, true)); + { + StartCoroutine(AnimateAnchoredXAndFade(scoreBottomRt, -1646.9f, 0f, introMoveDuration, true)); + hasMainEntryAnimations = true; + } if (shenstarsRt != null) - shenstarsIn = StartCoroutine(AnimateAnchoredXAndFade(shenstarsRt, -1602.1f, -437.5785f, introMoveDuration, true)); - if (scoreBottomIn != null || shenstarsIn != null) - yield return WaitRealtime(introMoveDuration + 0.02f); - - // reward: Y -494.3 -> 0 with fade + { + StartCoroutine(AnimateAnchoredXAndFade(shenstarsRt, -1602.1f, -437.5785f, introMoveDuration, true)); + hasMainEntryAnimations = true; + } if (rewardRt != null) - yield return StartCoroutine(AnimateAnchoredYAndFade(rewardRt, -494.3f, 0f, introMoveDuration)); + { + StartCoroutine(AnimateAnchoredYAndFade(rewardRt, -494.3f, 0f, introMoveDuration)); + hasMainEntryAnimations = true; + } + if (hasMainEntryAnimations) + yield return WaitRealtime(Mathf.Max(introMoveDuration, introFlashDuration, introStaticElementDuration) + 0.02f); - // score numbers: pm -> idol -> accuracy - yield return StartCoroutine(AnimateIntText(pmScoreSum_Text, targetPmScore, introNumberDuration)); - yield return StartCoroutine(AnimateIntText(idolScoreSum_Text, targetIdolScore, introNumberDuration)); - yield return StartCoroutine(AnimateFloatPercentText(accuracy_Text, targetAccuracyPercent, introNumberDuration, "F1")); + bool hasScoreAnimations = false; + if (pmScoreSum_Text != null) + { + StartCoroutine(AnimateIntText(pmScoreSum_Text, targetPmScore, introNumberDuration)); + hasScoreAnimations = true; + } + if (idolScoreSum_Text != null) + { + StartCoroutine(AnimateIntText(idolScoreSum_Text, targetIdolScore, introNumberDuration)); + hasScoreAnimations = true; + } + if (accuracy_Text != null) + { + StartCoroutine(AnimateFloatPercentText(accuracy_Text, targetAccuracyPercent, introNumberDuration, "F1")); + hasScoreAnimations = true; + } + if (finalScore_Text != null) + { + StartCoroutine(AnimateIntText(finalScore_Text, targetTotalScore, introNumberDuration)); + hasScoreAnimations = true; + } + if (thisLevel_currentPercentage_Text != null) + { + StartCoroutine(AnimateFloatPercentText(thisLevel_currentPercentage_Text, targetTotalPercent, introNumberDuration, "F2")); + hasScoreAnimations = true; + } + if (thisLevel_progressBar_Image != null) + { + StartCoroutine(AnimateImageFill(thisLevel_progressBar_Image, (float)targetTotalScore / Mathf.Max(1, _1000000), introBarFillDuration)); + hasScoreAnimations = true; + } + if (finalLevel_img != null) + { + StartCoroutine(FlashInTransform(finalLevel_img.transform, 1, introStaticElementDuration)); + hasScoreAnimations = true; + } + if (personalRecord_Text != null) + { + StartCoroutine(FlashInTransform(personalRecord_Text.transform, 1, introStaticElementDuration)); + hasScoreAnimations = true; + } + if (gameNote_rate != null) + { + StartCoroutine(FlashInTransform(gameNote_rate.transform, 1, introStaticElementDuration)); + hasScoreAnimations = true; + } + if (hasScoreAnimations) + yield return WaitRealtime(Mathf.Max(introNumberDuration, introBarFillDuration, introStaticElementDuration) + 0.02f); - // total + percent after above, and flash once completed - Coroutine totalScoreIn = StartCoroutine(AnimateIntText(finalScore_Text, targetTotalScore, introNumberDuration)); - Coroutine totalPercentIn = StartCoroutine(AnimateFloatPercentText(thisLevel_currentPercentage_Text, targetTotalPercent, introNumberDuration, "F2")); - if (totalScoreIn != null || totalPercentIn != null) - yield return WaitRealtime(introNumberDuration + 0.02f); - yield return StartCoroutine(FlashInTransform(finalScore_Text != null ? finalScore_Text.transform : null, introFlashCount, introFlashDuration * 0.9f)); - yield return StartCoroutine(FlashInTransform(thisLevel_currentPercentage_Text != null ? thisLevel_currentPercentage_Text.transform : null, introFlashCount, introFlashDuration * 0.9f)); - yield return StartCoroutine(AnimateImageFill(thisLevel_progressBar_Image, (float)targetTotalScore / Mathf.Max(1, _1000000), introBarFillDuration)); - yield return StartCoroutine(FlashInTransform(finalLevel_img != null ? finalLevel_img.transform : null, 1, introStaticElementDuration)); - yield return StartCoroutine(FlashInTransform(personalRecord_Text != null ? personalRecord_Text.transform : null, 1, introStaticElementDuration)); - yield return StartCoroutine(FlashInTransform(gameNote_rate != null ? gameNote_rate.transform : null, 1, introStaticElementDuration)); + bool hasScoreFlashAnimations = false; + if (finalScore_Text != null) + { + StartCoroutine(FlashInTransform(finalScore_Text.transform, introFlashCount, introFlashDuration * 0.9f)); + hasScoreFlashAnimations = true; + } + if (thisLevel_currentPercentage_Text != null) + { + StartCoroutine(FlashInTransform(thisLevel_currentPercentage_Text.transform, introFlashCount, introFlashDuration * 0.9f)); + hasScoreFlashAnimations = true; + } + if (hasScoreFlashAnimations) + yield return WaitRealtime(introFlashDuration * 0.9f + 0.02f); - // hitStatus/status groups: flash in and count up numbers + // hitStatus/status groups: flash in and count up numbers together. int noteCountForBars = Mathf.Max(1, targetPerfectCount + targetGreatCount + targetGoodCount + targetMissCount); - yield return StartCoroutine(PlayStatusGroupIn(perfectGroup, perfectHitCount_Text, targetPerfectCount, perfectHitPercent_Text, targetPerfectPercent, perfect_barFill_Image, (float)targetPerfectCount / noteCountForBars)); - yield return StartCoroutine(PlayStatusGroupIn(greatGroup, greatHitCount_Text, targetGreatCount, greatHitPercent_Text, targetGreatPercent, great_barFill_Image, (float)targetGreatCount / noteCountForBars)); - yield return StartCoroutine(PlayStatusGroupIn(goodGroup, goodHitCount_Text, targetGoodCount, goodHitPercent_Text, targetGoodPercent, good_barFill_Image, (float)targetGoodCount / noteCountForBars)); - yield return StartCoroutine(PlayStatusGroupIn(missGroup, missHitCount_Text, targetMissCount, missHitPercent_Text, targetMissPercent, miss_barFill_Image, (float)targetMissCount / noteCountForBars)); + bool hasStatusAnimations = false; + StartCoroutine(PlayStatusGroupIn(perfectGroup, perfectHitCount_Text, targetPerfectCount, perfectHitPercent_Text, targetPerfectPercent, perfect_barFill_Image, (float)targetPerfectCount / noteCountForBars)); + hasStatusAnimations = true; + StartCoroutine(PlayStatusGroupIn(greatGroup, greatHitCount_Text, targetGreatCount, greatHitPercent_Text, targetGreatPercent, great_barFill_Image, (float)targetGreatCount / noteCountForBars)); + hasStatusAnimations = true; + StartCoroutine(PlayStatusGroupIn(goodGroup, goodHitCount_Text, targetGoodCount, goodHitPercent_Text, targetGoodPercent, good_barFill_Image, (float)targetGoodCount / noteCountForBars)); + hasStatusAnimations = true; + StartCoroutine(PlayStatusGroupIn(missGroup, missHitCount_Text, targetMissCount, missHitPercent_Text, targetMissPercent, miss_barFill_Image, (float)targetMissCount / noteCountForBars)); + hasStatusAnimations = true; + if (earlyHitCount_Text != null) + { + StartCoroutine(AnimateIntText(earlyHitCount_Text, targetEarlyCount, introNumberDuration * 0.7f)); + hasStatusAnimations = true; + } + if (lateHitCount_Text != null) + { + StartCoroutine(AnimateIntText(lateHitCount_Text, targetLateCount, introNumberDuration * 0.7f)); + hasStatusAnimations = true; + } + if (avgOffset_Text != null) + { + StartCoroutine(AnimateOffsetText(avgOffset_Text, targetAvgOffsetMs, introNumberDuration * 0.7f)); + hasStatusAnimations = true; + } + if (hasStatusAnimations) + yield return WaitRealtime(Mathf.Max(introFlashDuration + introNumberDuration, introFlashDuration + introBarFillDuration, introNumberDuration * 0.7f) + 0.04f); - yield return StartCoroutine(AnimateIntText(earlyHitCount_Text, targetEarlyCount, introNumberDuration * 0.7f)); - yield return StartCoroutine(AnimateIntText(lateHitCount_Text, targetLateCount, introNumberDuration * 0.7f)); - yield return StartCoroutine(AnimateOffsetText(avgOffset_Text, targetAvgOffsetMs, introNumberDuration * 0.7f)); - - // ally cards enter before MASK. + // Team, MVP and buttons enter together after the score/status area is visible. + bool hasFinalEntryAnimations = false; if (hasAllyCardEntry) - yield return StartCoroutine(settlementTeamLoader.PlayAllySlotEntryBeforeMask()); - - // heroDApic/MASK/lihui flash + { + StartCoroutine(settlementTeamLoader.PlayAllySlotEntryBeforeMask()); + hasFinalEntryAnimations = true; + } if (lihuiTr != null) - yield return StartCoroutine(FlashInTransform(lihuiTr, introFlashCount, introFlashDuration)); - - // MVP shows after MASK. + { + StartCoroutine(FlashInTransform(lihuiTr, introFlashCount, introFlashDuration)); + hasFinalEntryAnimations = true; + } if (shouldAnimateMvp) - yield return StartCoroutine(AnimateMvpEntryAfterMask(mvpTr, mvpBaseLocalPos)); - - // Fallback for scenes still using container slide-in instead of per-slot tween. + { + StartCoroutine(AnimateMvpEntryAfterMask(mvpTr, mvpBaseLocalPos)); + hasFinalEntryAnimations = true; + } if (!hasAllyCardEntry && teamDisplayingRt != null) - yield return StartCoroutine(AnimateAnchoredXAndFade(teamDisplayingRt, 2265f, 1490.09f, introMoveDuration, true)); - - // right/buttons: each button flashes in sequence + { + StartCoroutine(AnimateAnchoredXAndFade(teamDisplayingRt, 2265f, 1490.09f, introMoveDuration, true)); + hasFinalEntryAnimations = true; + } for (int i = 0; i < rightButtons.Count; i++) { Transform btn = rightButtons[i]; if (btn == null) continue; - yield return StartCoroutine(FlashInTransform(btn, introFlashCount, introFlashDuration)); - yield return WaitRealtime(introStepGap); + StartCoroutine(FlashInTransform(btn, introFlashCount, introFlashDuration)); + hasFinalEntryAnimations = true; } + if (hasFinalEntryAnimations) + yield return WaitRealtime(Mathf.Max(introMoveDuration, introFlashDuration, introMvpEnterDuration) + Mathf.Max(0.1f, introStepGap)); ApplySettlementIntroFinalState(); settlementCanvasGroup.alpha = 1f; @@ -1462,6 +1553,7 @@ public class settlementController : MonoBehaviour if (earlyHitCount_Text != null) { earlyHitCount_Text.text = targetEarlyCount.ToString(); SetCanvasAlpha(earlyHitCount_Text.transform, 1f); } if (lateHitCount_Text != null) { lateHitCount_Text.text = targetLateCount.ToString(); SetCanvasAlpha(lateHitCount_Text.transform, 1f); } if (avgOffset_Text != null) { avgOffset_Text.text = targetAvgOffsetMs.ToString("F2") + " ms"; SetCanvasAlpha(avgOffset_Text.transform, 1f); } + if (offsetSuggestion_Text != null) SetCanvasAlpha(offsetSuggestion_Text.transform, 1f); if (mvpTr != null && mvpTr.gameObject.activeInHierarchy) { @@ -1780,6 +1872,37 @@ public class settlementController : MonoBehaviour target.text = toValue.ToString(format) + "%"; } + // 结算页校准建议:用中位数(抗离群点)而非平均值判断玩家系统性偏差, + // 并给出 UserGlobalDelaySeconds 的建议值(仅显示,不自动写入,保持业务逻辑等效)。 + private void UpdateOffsetSuggestion(ScoreManager sm) + { + if (offsetSuggestion_Text == null) return; + if (sm == null || sm.offsetCount <= 0) + { + offsetSuggestion_Text.text = string.Empty; + return; + } + + // 正数 = 偏早(Early),负数 = 偏晚(Late)。见 ScoreManager.RecordOffset。 + float medianMs = sm.GetMedianOffsetMs(); + float currentDelay = PlayerPrefs.GetFloat("UserGlobalDelaySeconds", 0f); + + // 偏早 -> 音符应更早到达判定线 -> 减小延迟;偏晚 -> 增大延迟。 + float suggestedDelay = currentDelay - medianMs / 1000f; + + // 小于阈值视为已对齐,避免噪声导致反复微调。 + const float alignedThresholdMs = 8f; + if (Mathf.Abs(medianMs) < alignedThresholdMs) + { + offsetSuggestion_Text.text = $"中位偏移 {medianMs:F1} ms · 时机良好"; + return; + } + + string dir = medianMs > 0f ? "偏早" : "偏晚"; + offsetSuggestion_Text.text = + $"中位偏移 {medianMs:F1} ms({dir})· 建议全局延迟 {currentDelay * 1000f:F0} → {suggestedDelay * 1000f:F0} ms"; + } + private IEnumerator AnimateOffsetText(Text target, float toValue, float duration) { if (target == null) yield break; diff --git a/Assets/scripts/gamePlay_gameplay/trackFractureController.cs b/Assets/scripts/gamePlay_gameplay/trackFractureController.cs index 093b285b..59f7893e 100644 --- a/Assets/scripts/gamePlay_gameplay/trackFractureController.cs +++ b/Assets/scripts/gamePlay_gameplay/trackFractureController.cs @@ -51,8 +51,9 @@ public class trackFractureController : MonoBehaviour [SerializeField] private float moveDuration = 0.35f; [Header("Debug")] - [SerializeField] private bool enableDebugKeyTrigger = true; - [SerializeField] private bool debugLogs = true; + [Tooltip("Editor-only debug hotkeys (1-5) to trigger track fracture. Never active in builds.")] + [SerializeField] private bool enableDebugKeyTrigger = false; + [SerializeField] private bool debugLogs = false; private readonly FractureAndDrift[] fractureDrivers = new FractureAndDrift[TrackCount]; private readonly Renderer[] trackRenderers = new Renderer[TrackCount]; @@ -89,8 +90,12 @@ public class trackFractureController : MonoBehaviour } } +#if UNITY_EDITOR private void Update() { + // Editor-only debug hotkeys. Compiled out of player builds entirely so a serialized + // scene value can never leave number-key fracture triggers or per-frame Input polling + // active in a shipped game. if (!enableDebugKeyTrigger) { return; @@ -122,6 +127,7 @@ public class trackFractureController : MonoBehaviour TriggerTrackFracture(4); } } +#endif private void OnValidate() { diff --git a/Assets/scripts/gameplay_teamUI/teamUIController.cs b/Assets/scripts/gameplay_teamUI/teamUIController.cs index f1586ef1..9c9e333e 100644 --- a/Assets/scripts/gameplay_teamUI/teamUIController.cs +++ b/Assets/scripts/gameplay_teamUI/teamUIController.cs @@ -59,6 +59,39 @@ public class teamUIController : MonoBehaviour public float flashFadeDuration = 0.2f; private static readonly int SaturationID = Shader.PropertyToID("_Saturation"); + // Per-Image cached grayscale material instances. Previously each enemy-death / victory / + // ally-death event did `image.material = new Material(grayScaleMaterial)` and never + // destroyed the clone, leaking one material per event and giving each Image a unique + // material. Now we create at most one clone per Image, reuse it, and destroy all clones on teardown. + private readonly Dictionary _grayscaleMaterialInstances = new Dictionary(); + + private Material ApplyGrayscaleMaterial(Image image, float saturation) + { + if (image == null || grayScaleMaterial == null) return null; + if (!_grayscaleMaterialInstances.TryGetValue(image, out Material mat) || mat == null) + { + mat = new Material(grayScaleMaterial); + _grayscaleMaterialInstances[image] = mat; + } + image.material = mat; + mat.SetFloat(SaturationID, saturation); + return mat; + } + + private void DestroyGrayscaleMaterialInstances() + { + foreach (var kv in _grayscaleMaterialInstances) + { + if (kv.Value != null) Destroy(kv.Value); + } + _grayscaleMaterialInstances.Clear(); + } + + private void OnDestroy() + { + DestroyGrayscaleMaterialInstances(); + } + [Header("Runtime ally configuration")] [Tooltip("IDs for the 5 ally slots (top to bottom). These are used by PopulateAllySOsFromIds to resolve SOs into currentAllySOs.")] public List allySlotIds = new List { 0, 0, 0, 0, 0 }; @@ -1576,12 +1609,26 @@ public class teamUIController : MonoBehaviour /// /// Documentation text normalized. /// + // Precomputed adjacency for the fixed 5-slot layout. All callers only read/iterate the + // result (never mutate it), so returning shared static arrays avoids a new List + ToArray + // allocation on every call — this is invoked from many per-skill/per-event code paths. + private static readonly int[][] s_adjacentAllyIndices = + { + new[] { 1 }, // slot 0 -> {1} + new[] { 0, 2 }, // slot 1 -> {0,2} + new[] { 1, 3 }, // slot 2 -> {1,3} + new[] { 2, 4 }, // slot 3 -> {2,4} + new[] { 3 }, // slot 4 -> {3} + }; + private static readonly int[] s_adjacentAllyIndicesEmpty = new int[0]; + public int[] GetAdjacentAllyIndices(int slotIndex) { - var list = new List(); - if (slotIndex - 1 >= 0) list.Add(slotIndex - 1); - if (slotIndex + 1 <= 4) list.Add(slotIndex + 1); - return list.ToArray(); + if (slotIndex < 0 || slotIndex > 4) + { + return s_adjacentAllyIndicesEmpty; + } + return s_adjacentAllyIndices[slotIndex]; } /// @@ -1889,12 +1936,8 @@ public class teamUIController : MonoBehaviour : (so.enemy_Image != null ? so.enemy_Image : (so.enemy_Profile != null ? so.enemy_Profile : null)); currentEnemy_characterImage.sprite = sprite; - // Apply grayscale material - if (grayScaleMaterial != null) - { - currentEnemy_characterImage.material = new Material(grayScaleMaterial); - currentEnemy_characterImage.material.SetFloat(SaturationID, 0f); // 0 means grayscale - } + // Apply grayscale material (0 = grayscale). Reuses a cached clone per Image. + ApplyGrayscaleMaterial(currentEnemy_characterImage, 0f); // Start with alpha 0 for fade in Color c = currentEnemy_characterImage.color; @@ -2195,14 +2238,15 @@ public class teamUIController : MonoBehaviour { if (_victorySaturationCoroutine != null) StopCoroutine(_victorySaturationCoroutine); - // Assign material only when victory occurs + // Assign cached grayscale clone and immediately set saturation to 0 (grayscale). if (grayScaleMaterial != null) { - victorySaturationImage.material = new Material(grayScaleMaterial); + ApplyGrayscaleMaterial(victorySaturationImage, 0f); + } + else if (victorySaturationImage.material != null) + { + victorySaturationImage.material.SetFloat(SaturationID, 0f); } - - // Immediately set saturation to 0 (Grayscale) - victorySaturationImage.material.SetFloat(SaturationID, 0f); } } @@ -3059,11 +3103,11 @@ public class teamUIController : MonoBehaviour public IEnumerator AllyDeathGrayscale(Image characterImage) { if (characterImage == null || grayScaleMaterial == null) yield break; - - // Documentation text normalized. - Material instanceMat = new Material(grayScaleMaterial); - characterImage.material = instanceMat; - + + // Reuse a cached grayscale clone bound to this Image instead of leaking a new one per death. + Material instanceMat = ApplyGrayscaleMaterial(characterImage, 1f); + if (instanceMat == null) yield break; + float dur = 1.0f; float t = 0f; while (t < dur) diff --git a/Assets/scripts/settings/controllerSettings.cs b/Assets/scripts/settings/controllerSettings.cs index ebabf920..cd14c72d 100644 --- a/Assets/scripts/settings/controllerSettings.cs +++ b/Assets/scripts/settings/controllerSettings.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; +using UnityEngine.InputSystem; using UnityEngine.UI; public class controllerSettings : MonoBehaviour @@ -34,7 +35,7 @@ public class controllerSettings : MonoBehaviour private const int SecondaryTrack3Slot = 5; private const float NoteSpeedMin = 1f; - private const float NoteSpeedMax = 3f; + private const float NoteSpeedMax = 3.25f; private const string NoteSpeedPrefKey = "noteSpeedMultiplier"; private const string NoteSpeedDefaultVersionKey = "noteSpeedMultiplierDefaultVersion"; private const int NoteSpeedDefaultVersion = 2; @@ -51,6 +52,12 @@ public class controllerSettings : MonoBehaviour private Coroutine messageCoroutine; private Coroutine continuousChangeCoroutine; + // Input System interactive rebind: a throwaway InputAction listens for the next keyboard + // press and reports the bound control's path, which we convert back to KeyCode so the + // existing KeyCode-based storage and conflict-swap logic stay unchanged. + private InputAction rebindAction; + private InputActionRebindingExtensions.RebindingOperation rebindOperation; + private void Start() { RefreshButtonLabels(); @@ -77,26 +84,17 @@ public class controllerSettings : MonoBehaviour if (noteSpeed_IncreaseButton != null) noteSpeed_IncreaseButton.onClick.RemoveAllListeners(); if (noteSpeed_ResetButton != null) noteSpeed_ResetButton.onClick.RemoveAllListeners(); if (launch_delayTapper != null) launch_delayTapper.onClick.RemoveAllListeners(); + + CleanupRebindOperation(); } private void Update() { - if (isRebinding && Input.GetMouseButtonDown(0) && !IsPointerOverAnyKeyButton()) + // Clicking away from the key buttons cancels an in-progress rebind. Key capture itself + // is handled by the Input System RebindingOperation started in OnKeyButtonClicked. + if (isRebinding && Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame && !IsPointerOverAnyKeyButton()) { CancelRebind(); - return; - } - - if (!isRebinding) return; - - foreach (KeyCode kc in Enum.GetValues(typeof(KeyCode))) - { - if (kc >= KeyCode.Mouse0 && kc <= KeyCode.Mouse6) continue; - if (kc >= KeyCode.JoystickButton0 && kc <= KeyCode.Joystick8Button19) continue; - if (!Input.GetKeyDown(kc)) continue; - - HandleRebindKey(kc); - break; } } @@ -210,7 +208,8 @@ public class controllerSettings : MonoBehaviour { if (EventSystem.current == null) return false; - PointerEventData pointerData = new PointerEventData(EventSystem.current) { position = Input.mousePosition }; + Vector2 mousePos = Mouse.current != null ? Mouse.current.position.ReadValue() : Vector2.zero; + PointerEventData pointerData = new PointerEventData(EventSystem.current) { position = mousePos }; List results = new List(); EventSystem.current.RaycastAll(pointerData, results); if (results.Count == 0) return false; @@ -246,6 +245,68 @@ public class controllerSettings : MonoBehaviour blinkCoroutine = StartCoroutine(BlinkUnderscore(index)); ShowPersistentMessage("按下新按键", messageErrorColor); + + StartInteractiveRebind(); + } + + /// + /// Start an Input System interactive rebind that waits for the next keyboard press. + /// The captured control path is mapped back to KeyCode and fed into the existing + /// HandleRebindKey conflict-swap logic, keeping storage and behavior equivalent. + /// + private void StartInteractiveRebind() + { + CleanupRebindOperation(); + + rebindAction = new InputAction("Rebind", InputActionType.Button, expectedControlType: "Button"); + // PerformInteractiveRebinding needs an existing binding (index 0) to override. + rebindAction.AddBinding("/space"); + rebindOperation = rebindAction.PerformInteractiveRebinding(0) + .WithControlsExcluding("/leftButton") + .WithControlsExcluding("/rightButton") + .WithControlsExcluding("/middleButton") + .WithControlsExcluding("/position") + .WithControlsExcluding("/delta") + .WithCancelingThrough("/escape") + .OnCancel(_ => { CleanupRebindOperation(); CancelRebind(); }) + .OnComplete(op => + { + // selectedControl.name is the control name (e.g. "d", "space"); map to KeyCode. + KeyCode captured = ResolveKeyCodeFromControl(op.selectedControl); + CleanupRebindOperation(); + + if (captured == KeyCode.None) + { + // Unmappable key: treat like a cancel so we don't clobber the binding. + CancelRebind(); + return; + } + + HandleRebindKey(captured); + }) + .Start(); + } + + private static KeyCode ResolveKeyCodeFromControl(InputControl control) + { + if (control == null) return KeyCode.None; + // control.path -> "/Keyboard/d"; the last segment is the control name. + string name = control.name; // e.g. "d", "space", "leftShift" + return InputSystemKeyMap.PathToKeyCode("/" + name); + } + + private void CleanupRebindOperation() + { + if (rebindOperation != null) + { + rebindOperation.Dispose(); + rebindOperation = null; + } + if (rebindAction != null) + { + rebindAction.Dispose(); + rebindAction = null; + } } private IEnumerator BlinkUnderscore(int index) @@ -297,6 +358,7 @@ public class controllerSettings : MonoBehaviour previousKey = KeyCode.None; RefreshButtonLabels(); InputManager.Instance?.RefreshKeyLabels(); + GameplayInputActions.Instance?.RefreshBindings(); StopRebind(); } @@ -402,10 +464,13 @@ public class controllerSettings : MonoBehaviour private void CancelRebind() { + CleanupRebindOperation(); + if (previousKeyValid && IsValidBindingSlot(rebindingIndex)) { SetKeyForSlot(rebindingIndex, previousKey); InputManager.Instance?.RefreshKeyLabels(); + GameplayInputActions.Instance?.RefreshBindings(); ClearMessage(); ShowMessage("按键设置已保存", messageSuccessColor, 2f); } diff --git a/Assets/scripts/settings/graphicSettings.cs b/Assets/scripts/settings/graphicSettings.cs index 0b944937..0c537bd2 100644 --- a/Assets/scripts/settings/graphicSettings.cs +++ b/Assets/scripts/settings/graphicSettings.cs @@ -48,7 +48,11 @@ public class graphicSettings : MonoBehaviour } #endif - int screenMode = PlayerPrefs.GetInt(PrefKeyScreenMode, 0); + // 无存档(首次运行)时,跟随 Player Settings 里的启动全屏模式,而不是硬编码窗口化(0)。 + // 否则会把 ProjectSettings 的独占全屏强切成窗口化,且窗口化分支无兜底分辨率, + // 导致 Unity 回落到内建默认窗口尺寸 1280x720。 + int defaultScreenMode = MapFullScreenModeToIndex(Screen.fullScreenMode); + int screenMode = PlayerPrefs.GetInt(PrefKeyScreenMode, defaultScreenMode); screenMode = Mathf.Clamp(screenMode, 0, 2); try @@ -141,7 +145,7 @@ public class graphicSettings : MonoBehaviour screenMode_Dropdown.onValueChanged.AddListener(OnScreenModeChanged); } - private int MapFullScreenModeToIndex(FullScreenMode mode) + private static int MapFullScreenModeToIndex(FullScreenMode mode) { switch (mode) { @@ -492,6 +496,19 @@ public class graphicSettings : MonoBehaviour if (savedResIndex < 0) { + // 无任何分辨率存档(首次运行,savedResIndex == -2)。 + // 全屏模式下 ApplyScreenModeStatic 已用显示器原生分辨率铺满,无需处理。 + // 但窗口模式下 ApplyScreenModeStatic 只是沿用启动瞬间的窗口尺寸(常为引擎默认 1280x720), + // 这里兜底到 1920x1080(受当前显示器上限约束),避免默认落到 720p。 + if (Screen.fullScreenMode == FullScreenMode.Windowed) + { + const int FallbackW = 1920; + const int FallbackH = 1080; + Resolution cur = Screen.currentResolution; + int w = Mathf.Min(FallbackW, cur.width); + int h = Mathf.Min(FallbackH, cur.height); + Screen.SetResolution(w, h, FullScreenMode.Windowed, cur.refreshRateRatio); + } return; } diff --git a/Assets/settingsPrefab/settings.prefab b/Assets/settingsPrefab/settings.prefab index 8b5c4b29..9c4412bd 100644 --- a/Assets/settingsPrefab/settings.prefab +++ b/Assets/settingsPrefab/settings.prefab @@ -1157,8 +1157,8 @@ RectTransform: m_Children: [] m_Father: {fileID: 3750469658834313074} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0.5, y: 0} - m_AnchorMax: {x: 0.5, y: 1} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} m_AnchoredPosition: {x: -4.932953, y: 0.04300022} m_SizeDelta: {x: 9.276, y: -10.981} m_Pivot: {x: 0.5, y: 0.5} @@ -2382,10 +2382,10 @@ RectTransform: - {fileID: 1458926779420793789} m_Father: {fileID: 4488996718461695403} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 50, y: -29.905} - m_SizeDelta: {x: 100, y: 53.81} + 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.5, y: 0.5} --- !u!1 &476482371646726843 GameObject: @@ -4085,9 +4085,9 @@ RectTransform: - {fileID: 3565252424611534877} m_Father: {fileID: 1458926779420793789} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 143.531, y: -288.5035} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 287.062, y: 577.007} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &4669522290612931272 @@ -4372,9 +4372,9 @@ RectTransform: - {fileID: 3628139006501750830} m_Father: {fileID: 8766461818781574532} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 37.5, y: -40.647} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 75, y: 25} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &4092194183861197593 @@ -6162,7 +6162,7 @@ MonoBehaviour: m_HorizontalOverflow: 0 m_VerticalOverflow: 0 m_LineSpacing: 1 - m_Text: 300% + m_Text: 325% --- !u!1 &1283681621046949199 GameObject: m_ObjectHideFlags: 0 @@ -9004,7 +9004,7 @@ RectTransform: m_Father: {fileID: 4612692908673573349} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} - m_AnchorMax: {x: 0.5, y: 1} + m_AnchorMax: {x: 0, y: 0} m_AnchoredPosition: {x: -4.866028, y: 0} m_SizeDelta: {x: 0.2678, y: 0} m_Pivot: {x: 0.5, y: 0.5} @@ -10203,9 +10203,9 @@ RectTransform: - {fileID: 5677919278347252570} m_Father: {fileID: 3235975528605492365} 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_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 143.531, y: -123.442} m_SizeDelta: {x: 239.326, y: 202.884} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &9015731902752721 @@ -10356,9 +10356,9 @@ RectTransform: - {fileID: 1993102632480336636} m_Father: {fileID: 7940305075916134394} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 143.531, y: -288.5035} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 287.062, y: 577.007} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &7754548170922028492 @@ -10907,9 +10907,9 @@ RectTransform: - {fileID: 7606004701630170367} m_Father: {fileID: 8766461818781574532} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 117.5, y: -40.647} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 75, y: 25} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &6455303632975186511 @@ -11322,10 +11322,10 @@ RectTransform: - {fileID: 5577478176999051906} m_Father: {fileID: 4401682417640619457} 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_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 50, y: -29.905} + m_SizeDelta: {x: 100, y: 53.81} m_Pivot: {x: 0.5, y: 0.5} --- !u!1 &2739575426889752810 GameObject: @@ -13011,9 +13011,9 @@ RectTransform: - {fileID: 5361364361846224622} m_Father: {fileID: 8014678421012250251} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 143.531, y: -123.442} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 239.326, y: 202.884} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &6816909155137375626 @@ -14415,9 +14415,9 @@ RectTransform: - {fileID: 3695066363018228337} m_Father: {fileID: 8766461818781574532} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 357.5, y: -40.647} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 75, y: 25} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &8615737481279844346 @@ -14536,9 +14536,9 @@ RectTransform: - {fileID: 164611865312804273} m_Father: {fileID: 8766461818781574532} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 277.5, y: -40.647} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 75, y: 25} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &7144470906975732781 @@ -20171,9 +20171,9 @@ RectTransform: - {fileID: 1388702705639975626} m_Father: {fileID: 5353583328112775632} 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_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 143.531, y: -123.442} m_SizeDelta: {x: 239.326, y: 202.884} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &3700764488114152955 @@ -23150,9 +23150,9 @@ RectTransform: - {fileID: 4348514761162803685} m_Father: {fileID: 8766461818781574532} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 197.5, y: -40.647} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 75, y: 25} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &6034137552273001283 @@ -24179,7 +24179,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!224 &1526989865090828760 RectTransform: m_ObjectHideFlags: 0 @@ -26327,9 +26327,9 @@ RectTransform: - {fileID: 7635752838371499872} m_Father: {fileID: 1190845418340054258} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 143.531, y: -123.442} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 239.326, y: 202.884} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &7920883870577955196 @@ -29482,9 +29482,9 @@ RectTransform: - {fileID: 6766376656270633746} m_Father: {fileID: 6923107695070632416} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 143.531, y: -288.5035} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 287.062, y: 577.007} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &6856581429689078006 @@ -29790,9 +29790,9 @@ RectTransform: - {fileID: 3457684080048414872} m_Father: {fileID: 5577478176999051906} 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_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 143.531, y: -288.5035} m_SizeDelta: {x: 287.062, y: 577.007} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &3887289190612912326 @@ -32196,10 +32196,10 @@ RectTransform: - {fileID: 6328029241450585631} m_Father: {fileID: 4401682417640619457} 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_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 50, y: -132.015} + m_SizeDelta: {x: 100, y: 53.81} m_Pivot: {x: 0.5, y: 0.5} --- !u!1 &8150377638998498324 GameObject: @@ -33717,10 +33717,10 @@ RectTransform: - {fileID: 6923107695070632416} m_Father: {fileID: 4488996718461695403} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 50, y: -234.125} - m_SizeDelta: {x: 100, y: 53.81} + 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.5, y: 0.5} --- !u!1 &8589190703357931468 GameObject: @@ -34277,7 +34277,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 0 + m_IsActive: 1 --- !u!224 &5797815244864795742 RectTransform: m_ObjectHideFlags: 0 @@ -35016,9 +35016,9 @@ RectTransform: - {fileID: 5814603191070823992} m_Father: {fileID: 8893592763568600588} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 143.531, y: -123.442} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 239.326, y: 202.884} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &8945889850280685297 @@ -37120,10 +37120,10 @@ RectTransform: - {fileID: 7940305075916134394} m_Father: {fileID: 4488996718461695403} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 1} - m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 50, y: -132.015} - m_SizeDelta: {x: 100, y: 53.81} + 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.5, y: 0.5} --- !u!1 &9102457484257720068 GameObject: @@ -37463,9 +37463,9 @@ RectTransform: - {fileID: 8268395713883098484} m_Father: {fileID: 6328029241450585631} 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_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 143.531, y: -288.5035} m_SizeDelta: {x: 287.062, y: 577.007} m_Pivot: {x: 0.5, y: 0.5} --- !u!114 &1963444154664362314 diff --git a/ProjectSettings/AudioManager.asset b/ProjectSettings/AudioManager.asset index 27287fec..ceaa5aa4 100644 --- a/ProjectSettings/AudioManager.asset +++ b/ProjectSettings/AudioManager.asset @@ -9,11 +9,12 @@ AudioManager: Doppler Factor: 1 Default Speaker Mode: 2 m_SampleRate: 0 - m_DSPBufferSize: 1024 + m_DSPBufferSize: 256 m_VirtualVoiceCount: 512 m_RealVoiceCount: 32 + m_EnableOutputSuspension: 1 m_SpatializerPlugin: m_AmbisonicDecoderPlugin: m_DisableAudio: 0 m_VirtualizeEffects: 1 - m_RequestedDSPBufferSize: 0 + m_RequestedDSPBufferSize: 256 diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index a154e4e3..4089ace4 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -142,7 +142,7 @@ PlayerSettings: loadStoreDebugModeEnabled: 0 visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 - bundleVersion: 1.0.04 + bundleVersion: 1.0.05 preloadedAssets: - {fileID: 11400000, guid: 87fa00178d138a349884b45f12225fea, type: 2} metroInputSource: 0 diff --git a/README.md b/README.md index 662314a6..68ed56d5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ | CΩ | 612/(0) | 2025.4-2025.6 | 初版编队(已弃用) | 工作 | | kiroto曹益嘉 | 1085/(72) | 2025.5至今 | 拼UI和动画 | —— | | opposite | 26(0) | 2025.3-2025.3 | 定义 | 合作方解耦 | -| fdyx123 | 313582 | 2025.3至今 | 。。。 | —— | +| fdyx123 | 368707 | 2025.3至今 | 。。。 | —— | diff --git a/dabao.txt b/dabao.txt deleted file mode 100644 index e69de29b..00000000