存档重构,游戏内容解锁机制;教程完善(未完成)
This commit is contained in:
@@ -213,7 +213,7 @@ Material:
|
||||
- _Dst: 10
|
||||
- _DstBlend: 0
|
||||
- _DstBlendAlpha: 0
|
||||
- _EdgeValue: 0.75684065
|
||||
- _EdgeValue: 0.5578781
|
||||
- _EnvironmentReflections: 1
|
||||
- _FNLfanxiangkaiguan: 0
|
||||
- _Face: 1
|
||||
@@ -258,7 +258,7 @@ Material:
|
||||
- _Mask_scale: 1
|
||||
- _Metallic: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Opacity: 0.24315935
|
||||
- _Opacity: 0.44212192
|
||||
- _Parallax: 0.005
|
||||
- _Pass: 0
|
||||
- _QueueOffset: 0
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e00e6f90ab8233e46a41c5e33917c642
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9da5530d5ebfab24c8ecad68795e720f
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa102d640b98b5d4781710a3a3dd6983
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a0bb33169d95ec499136d59cb25918b
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 641c955d37d2fac4f87e00ac5c9d9bd8
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2690f45490c175045bbdc63395bf6278
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbd1fd9b3a70fad429d1eaaa5799c2a5
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3579d9cf4b75c564faa8fffc58a9f3f6
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0023a0858ba124646a55dfcb7231ed46
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1c0b77896049554fa4b635531caf741
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c0a0a980c9ba86345bc15411db88d34f
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2edbf4a9b9544774bbef617e92429664
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13ab599a7bda4e54fba3e92a13c9580a
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6f268949ccf3f34fa4d18e92501ed82
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 69bc3229216b1504ea3e28b5820bbb0d
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f665a06c5a2aa5499fa1c79ac058999
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a87ed432fe2d97498c0de5fae312e35
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c3bfbbeb9427b94099254e2e2768ad4
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5303861611f41c438a30be552da5de4
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9d68dd8913f05d4d9ce75e7b40c6044
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2243c8b4e1ab6914995699133f67ab5a
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a5e61a8b3421b944863d0946e32da0a
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89f0b84148d149d4d96b838d7ef60e92
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 19939ee2cdb76e0489b1b8cd4bed7f3d
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70777e8ce9f3c8d4a8182ca2f965cdb2
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba281a1a00c8ac54c914e0763299f637
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6fc8948257acee42b666d0bfe1d782c
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b5cb8698f2d9c14fadf8e2383441d37
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b460b52e6c1feae45b70b7ddc2c45bd6
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 57fcea0ed8b5eb347923c4c21fa31b57
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e9da72e7e3196146bf7d27450013734
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0904cdf24ddcd5042b024326476220d5
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 929783250050f8a448821b6ca1f2c578
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fcc4d2eb0af82e546ae75506872cf092
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd0a0171c5157b748afe763b89f71211
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4990f6ace6142c4bbbf41cdd80b0bd3
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1782b72cd0e99a54fac09382c482e3db
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 49d5bcbbd4cbd754b98cf3200197b0f1
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eefe45a405f061045be947217e30ed10
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e995dfe11e22d34d92432383d15c067
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec984c51d4ae2504184eeb292734c672
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e447b3d7d913d694ca35f74e30581840
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6584a66582083a1459dcf5e4e87f6d62
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0190b8bde50f12943926613d9a63c89a
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae2ce8ad295486349839288636aed1ed
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d69745226619e3241a8e04ce86aee6a6
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37e6a9374416bc946a55779c58d0d984
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Assets/FR2_Cache.asset
LFS
BIN
Assets/FR2_Cache.asset
LFS
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e45363610a59a4543a9793b3bf2be4aa
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de41a70ead12af544a4e99a925b269d2
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f597f19f656ba56eae4f6a3a7cc528f4
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 48e08dc33330d11e9d4a1b246c52e4f6
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed09910c0094cb27be8f3ca264680da3
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc355dd4cf1e6173beaeb22c2858cbe1
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -49,7 +49,7 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
updateMethod: 2
|
||||
_spline: {fileID: 0}
|
||||
_autoUpdate: 1
|
||||
_autoUpdate: 0
|
||||
_rotationModifier:
|
||||
blend: 1
|
||||
useClippedPercent: 0
|
||||
@@ -67,19 +67,19 @@ MonoBehaviour:
|
||||
useClippedPercent: 0
|
||||
keys: []
|
||||
_clipFromSample:
|
||||
position: {x: -717.52625, y: 487.53882, z: 0}
|
||||
up: {x: 0, y: 0, z: -1}
|
||||
forward: {x: 0.028753094, y: 0.9943591, z: -0.10209477}
|
||||
color: {r: 1, g: 1, b: 1, a: 1}
|
||||
size: 1
|
||||
position: {x: 0, y: 0, z: 0}
|
||||
up: {x: 0, y: 0, z: 0}
|
||||
forward: {x: 0, y: 0, z: 0}
|
||||
color: {r: 0, g: 0, b: 0, a: 0}
|
||||
size: 0
|
||||
percent: 0
|
||||
_clipToSample:
|
||||
position: {x: -689.4071, y: 502.93277, z: 4.664875}
|
||||
up: {x: 0, y: 0, z: -1}
|
||||
forward: {x: -0.39787814, y: 0.8626521, z: 0.3122889}
|
||||
color: {r: 1, g: 1, b: 1, a: 1}
|
||||
size: 1
|
||||
percent: 1
|
||||
position: {x: 0, y: 0, z: 0}
|
||||
up: {x: 0, y: 0, z: 0}
|
||||
forward: {x: 0, y: 0, z: 0}
|
||||
color: {r: 0, g: 0, b: 0, a: 0}
|
||||
size: 0
|
||||
percent: 0
|
||||
_loopSamples: 0
|
||||
_clipFrom: 0
|
||||
_clipTo: 1
|
||||
@@ -4919,14 +4919,30 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
serializationData:
|
||||
SerializedFormat: 0
|
||||
SerializedBytes: 29010b00000065006c0065006d0065006e00740047007500690064000000000000000000000000000000000001010d0000007300750062006d006f00640075006c0065004c006900730074002f00000000015e000000530079007300740065006d002e0043006f006c006c0065006300740069006f006e0073002e00470065006e0065007200690063002e004c00690073007400600031005b005b004900630068006e0069002e00520068007900740068006d00470061006d0065002e005300750062006d006f00640075006c00650042006100730065002c00200041007300730065006d0062006c0079002d004300530068006100720070005d005d002c0020006d00730063006f0072006c0069006200000000000600000000000000000705
|
||||
SerializedFormat: 2
|
||||
SerializedBytes:
|
||||
ReferencedUnityObjects: []
|
||||
SerializedBytesString:
|
||||
Prefab: {fileID: 0}
|
||||
PrefabModificationsReferencedUnityObjects: []
|
||||
PrefabModifications: []
|
||||
SerializationNodes: []
|
||||
SerializationNodes:
|
||||
- Name: elementGuid
|
||||
Entry: 2
|
||||
Data: 00000000000000000000000000000000
|
||||
- Name: submoduleList
|
||||
Entry: 7
|
||||
Data: 0|System.Collections.Generic.List`1[[Ichni.RhythmGame.SubmoduleBase,
|
||||
Assembly-CSharp]], mscorlib
|
||||
- Name:
|
||||
Entry: 12
|
||||
Data: 0
|
||||
- Name:
|
||||
Entry: 13
|
||||
Data:
|
||||
- Name:
|
||||
Entry: 8
|
||||
Data:
|
||||
elementName:
|
||||
tags: []
|
||||
parentElement: {fileID: 0}
|
||||
|
||||
@@ -10,7 +10,6 @@ GameObject:
|
||||
m_Component:
|
||||
- component: {fileID: 4937946904652960982}
|
||||
- component: {fileID: 459525017163665976}
|
||||
- component: {fileID: 8785710776160535657}
|
||||
m_Layer: 5
|
||||
m_Name: OutPort
|
||||
m_TagString: Untagged
|
||||
@@ -34,7 +33,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 1, y: 0.5}
|
||||
m_AnchorMax: {x: 1, y: 0.5}
|
||||
m_AnchoredPosition: {x: -15, y: -5}
|
||||
m_AnchoredPosition: {x: -30, y: -9.5}
|
||||
m_SizeDelta: {x: 25, y: 25}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &459525017163665976
|
||||
@@ -45,36 +44,6 @@ CanvasRenderer:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2549272938280010857}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &8785710776160535657
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2549272938280010857}
|
||||
m_Enabled: 0
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 0, b: 0, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 0}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 0
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!1 &2947357181438598616
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -205,7 +174,6 @@ GameObject:
|
||||
m_Component:
|
||||
- component: {fileID: 5784757909052703056}
|
||||
- component: {fileID: 3000483006876940928}
|
||||
- component: {fileID: 3450002000089659239}
|
||||
m_Layer: 5
|
||||
m_Name: InPort
|
||||
m_TagString: Untagged
|
||||
@@ -229,7 +197,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0.5}
|
||||
m_AnchorMax: {x: 0, y: 0.5}
|
||||
m_AnchoredPosition: {x: 35, y: -6}
|
||||
m_AnchoredPosition: {x: 72, y: -9.5}
|
||||
m_SizeDelta: {x: 25, y: 25}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &3000483006876940928
|
||||
@@ -240,36 +208,6 @@ CanvasRenderer:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5694744930588099522}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &3450002000089659239
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5694744930588099522}
|
||||
m_Enabled: 0
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 0, g: 0, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 0}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 0
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!1 &7991069327751894070
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -374,20 +312,23 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
@@ -415,7 +356,7 @@ GameObject:
|
||||
m_Component:
|
||||
- component: {fileID: 5350638854831129010}
|
||||
- component: {fileID: 6895756204793606274}
|
||||
- component: {fileID: 3620540326085937893}
|
||||
- component: {fileID: 1599109356054254412}
|
||||
m_Layer: 5
|
||||
m_Name: TutorialBlockUI
|
||||
m_TagString: Untagged
|
||||
@@ -441,9 +382,9 @@ RectTransform:
|
||||
- {fileID: 4937946904652960982}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 0, y: -540}
|
||||
m_AnchorMin: {x: 0, y: 0.5}
|
||||
m_AnchorMax: {x: 0, y: 0.5}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 600, y: 375}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &6895756204793606274
|
||||
@@ -454,7 +395,7 @@ CanvasRenderer:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 8279096577214194803}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &3620540326085937893
|
||||
--- !u!114 &1599109356054254412
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
@@ -463,15 +404,22 @@ MonoBehaviour:
|
||||
m_GameObject: {fileID: 8279096577214194803}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 821e50e1519236a46b03eb1f005acebe, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: 66b3d929576d6c54ca51069ae0e313e5, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
blockName:
|
||||
blockPosition: {x: 0, y: 0}
|
||||
m_EditorClassIdentifier: Assembly-CSharp::Ichni.Story.UI.TutorialBlockView
|
||||
serializationData:
|
||||
SerializedFormat: 2
|
||||
SerializedBytes:
|
||||
ReferencedUnityObjects: []
|
||||
SerializedBytesString:
|
||||
Prefab: {fileID: 0}
|
||||
PrefabModificationsReferencedUnityObjects: []
|
||||
PrefabModifications: []
|
||||
SerializationNodes: []
|
||||
blockId:
|
||||
state: 0
|
||||
blockRect: {fileID: 5350638854831129010}
|
||||
inPort: {fileID: 5784757909052703056}
|
||||
outPort: {fileID: 4937946904652960982}
|
||||
button: {fileID: 3931523710101296998}
|
||||
tutorialName:
|
||||
tutorialNameText: {fileID: 8391808794720387191}
|
||||
|
||||
BIN
Assets/Resources/Chapter0.asset
LFS
BIN
Assets/Resources/Chapter0.asset
LFS
Binary file not shown.
Binary file not shown.
@@ -16859,6 +16859,8 @@ MonoBehaviour:
|
||||
charterName:
|
||||
color: {r: 0, g: 0, b: 0, a: 0}
|
||||
isAvailable: 0
|
||||
ForceFullScreenJudge: 0
|
||||
chartRevision: 1
|
||||
songLength: 0
|
||||
bpm: 0
|
||||
chapterSwitch:
|
||||
@@ -20474,7 +20476,7 @@ MonoBehaviour:
|
||||
backgroundFrames: {fileID: 2007806589}
|
||||
framePositionOffset: -1
|
||||
songListController: {fileID: 886518565}
|
||||
playSongUI: {fileID: 0}
|
||||
playSongUI: {fileID: 2067971169}
|
||||
songInfoUI: {fileID: 1283588608}
|
||||
difficultySelectionContainer: {fileID: 1665548623}
|
||||
selectedSong:
|
||||
@@ -20501,6 +20503,8 @@ MonoBehaviour:
|
||||
charterName:
|
||||
color: {r: 0, g: 0, b: 0, a: 0}
|
||||
isAvailable: 0
|
||||
ForceFullScreenJudge: 0
|
||||
chartRevision: 1
|
||||
--- !u!4 &1506872042
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace Ichni.RhythmGame
|
||||
{
|
||||
targetGameCamera.perspectiveAngle = fieldOfView.value;
|
||||
targetGameCamera.RefreshFOV();
|
||||
Debug.Log(targetGameCamera.cam.fieldOfView + " " + fieldOfView.value);
|
||||
//Debug.Log(targetGameCamera.cam.fieldOfView + " " + fieldOfView.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ namespace Ichni
|
||||
CoreServices.TimeProvider = this;
|
||||
timeDurations = new List<TimeDurationSubmodule>();
|
||||
playingRecorder = new PlayingRecorder();
|
||||
playingRecorder.Initialize();
|
||||
updateScheduler = new ElementUpdateScheduler();
|
||||
CoreServices.UpdateScheduler = updateScheduler;
|
||||
}
|
||||
@@ -89,17 +90,47 @@ namespace Ichni
|
||||
|
||||
public partial class GameManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 将当前演奏结果写入独立的游戏记录存档。
|
||||
/// <para>由 SongPlayer 在实际歌曲结束后、打开 Summary 前调用。</para>
|
||||
/// <para>剧情树、选项和 Yarn 变量仍由 <see cref="StorySaveModule"/> 单独管理。</para>
|
||||
/// 当前 Difficulty 的 Chart Revision 会一并传入,从而在谱面改版时只清除不可比较的单谱面成绩。
|
||||
/// </summary>
|
||||
public bool SaveCurrentPlayRecord()
|
||||
{
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.SongSaveModule == null)
|
||||
{
|
||||
Debug.LogWarning("Cannot save play record because SongSaveModule is not initialized.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (InformationTransistor.instance == null ||
|
||||
InformationTransistor.instance.song == null ||
|
||||
InformationTransistor.instance.difficulty == null)
|
||||
{
|
||||
Debug.LogWarning("Cannot save play record because the selected song or difficulty is missing.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return GameSaveManager.instance.SongSaveModule.RecordPlayResult(
|
||||
InformationTransistor.instance.song.songName,
|
||||
InformationTransistor.instance.difficulty.saveDifficultyId,
|
||||
InformationTransistor.instance.difficulty.GetChartRevision(),
|
||||
playingRecorder);
|
||||
}
|
||||
|
||||
public static void RestartGame()
|
||||
{
|
||||
// 保留 InformationTransistor 中的歌曲和返回目标,使教程与普通歌曲的重开都回到正确页面。
|
||||
SceneManager.LoadScene("GameScene");
|
||||
Time.timeScale = 1f; // 确保重启时时间缩放恢复正常
|
||||
}
|
||||
|
||||
public static void ReturnToMenu()
|
||||
{
|
||||
//InformationTransistor.instance.isReturnedFromGame = true;
|
||||
SceneManager.LoadScene("MenuScene");
|
||||
Time.timeScale = 1f; // 确保返回时时间缩放恢复正常
|
||||
// 返回目标已在进入 GameScene 时写入 InformationTransistor;这里不再写互斥布尔值。
|
||||
SceneManager.LoadScene("MenuScene");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ using UnityEngine;
|
||||
|
||||
namespace Ichni.RhythmGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 单次演奏的运行时判定记录。
|
||||
/// 本类随 GameScene 重建,只负责本局结算;长期最佳成绩由 SongSaveModule 写入 ES3,
|
||||
/// 因此不要把此对象直接当作存档模型使用。
|
||||
/// </summary>
|
||||
public class PlayingRecorder
|
||||
{
|
||||
public int perfectCount;
|
||||
@@ -17,13 +22,28 @@ namespace Ichni.RhythmGame
|
||||
public int currentCombo;
|
||||
public int maxCombo;
|
||||
|
||||
/// <summary>本局判定偏差图数据,仅供当前 Summary 使用,不写入存档。</summary>
|
||||
public List<float> resultData = new List<float>();
|
||||
|
||||
public bool isFullCombo;
|
||||
public bool isAllPerfect;
|
||||
|
||||
/// <summary>
|
||||
/// 在进入一局新演奏时重置全部瞬时状态。
|
||||
/// 初始化为 100% / FC / AP 是为了让首个非 Perfect 判定能正确打破对应状态;
|
||||
/// 无有效判定的空局不会被 SongSaveModule 写入成绩。
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
{
|
||||
if (resultData == null)
|
||||
{
|
||||
resultData = new List<float>();
|
||||
}
|
||||
else
|
||||
{
|
||||
resultData.Clear();
|
||||
}
|
||||
|
||||
perfectCount = 0;
|
||||
goodCount = 0;
|
||||
badCount = 0;
|
||||
|
||||
@@ -170,7 +170,14 @@ namespace Ichni
|
||||
|
||||
if (in_type == AkCallbackType.AK_MusicSyncExit)
|
||||
{
|
||||
if (isFinished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isFinished = true;
|
||||
// 先持久化再展示结算,确保玩家即使在 Summary 停留期间退出也不会丢失本局成绩。
|
||||
GameManager.Instance.SaveCurrentPlayRecord();
|
||||
GameManager.Instance.summaryPageCanvas.SetUpSummary();
|
||||
GameManager.Instance.summaryPageCanvas.FadeIn();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ namespace Ichni.Menu
|
||||
public Sprite avatar;
|
||||
public Switch chapterSwitch;
|
||||
|
||||
/// <summary>
|
||||
/// 章节剧情入口与选曲入口共用的内容解锁规则。
|
||||
/// 留空表示章节从新档开始即可访问;需要锁定时请使用 <see cref="UnlockRequirement"/>,
|
||||
/// 并遵守 <see cref="UnlockSaveModule.KeyNamingRule"/> 中的下划线 Key 规则。
|
||||
/// </summary>
|
||||
[LabelText("Unlock Requirement")]
|
||||
public UnlockRequirement unlockRequirement = new UnlockRequirement();
|
||||
|
||||
[Searchable]
|
||||
public List<SongItemData> songs = new List<SongItemData>();
|
||||
|
||||
@@ -54,11 +62,6 @@ namespace Ichni.Menu
|
||||
|
||||
public partial class ChapterSelectionUnit
|
||||
{
|
||||
public List<string> GetRelatedSongNamesOfUnlockKey(string key)
|
||||
{
|
||||
return (from song in songs where song.storyUnlockKey == key select song.songName).ToList();
|
||||
}
|
||||
|
||||
public (int, int, int, int, int) GetChapterSaveInfo()
|
||||
{
|
||||
int beatmapCount = 0;
|
||||
@@ -72,9 +75,19 @@ namespace Ichni.Menu
|
||||
if (GameSaveManager.instance.SongSaveModule.songStatusSaves.TryGetValue(song.songName, out var songStatus))
|
||||
{
|
||||
bool thisSongFinished = false;
|
||||
foreach (BeatmapSave beatmapSave in songStatus.beatmapSaves)
|
||||
foreach (DifficultyData difficulty in song.difficultyDataList)
|
||||
{
|
||||
if (difficulty == null || difficulty.saveDifficultyId < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
beatmapCount++;
|
||||
if (!songStatus.TryGetBeatmapSave(difficulty.saveDifficultyId, out BeatmapSave beatmapSave))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!beatmapSave.IsEmpty())
|
||||
{
|
||||
thisSongFinished = true;
|
||||
@@ -131,17 +144,24 @@ namespace Ichni.Menu
|
||||
[FoldoutGroup("$songName")]
|
||||
public List<DifficultyData> difficultyDataList;
|
||||
|
||||
/// <summary>
|
||||
/// 歌曲自身的内容解锁规则。章节是否开放由 <see cref="ChapterSelectionUnit.unlockRequirement"/> 单独判断;
|
||||
/// 真正进入歌曲时两者必须同时满足。
|
||||
/// </summary>
|
||||
[FoldoutGroup("$songName")]
|
||||
public string storyUnlockKey;
|
||||
|
||||
[FoldoutGroup("$songName")]
|
||||
public string paymentUnlockKey;
|
||||
[LabelText("Unlock Requirement")]
|
||||
public UnlockRequirement unlockRequirement = new UnlockRequirement();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DifficultyData
|
||||
{
|
||||
public int difficultyIndex;
|
||||
/// <summary>
|
||||
/// 歌曲成绩的稳定 ID,不等同于选曲 UI 的难度列表位置。
|
||||
/// 发布后不要更改或复用既有 ID;新增难度必须使用从未分配过的非负 ID。
|
||||
/// </summary>
|
||||
[FormerlySerializedAs("difficultyIndex")]
|
||||
public int saveDifficultyId;
|
||||
public string difficultyName;
|
||||
public string displayDifficultyName;
|
||||
public int difficultyValue;
|
||||
@@ -149,14 +169,19 @@ namespace Ichni.Menu
|
||||
public Color color;
|
||||
public bool isAvailable;
|
||||
public bool ForceFullScreenJudge = false;
|
||||
|
||||
[MinValue(1)]
|
||||
[Tooltip("仅当 Note、Timing 或判定规则变化,导致成绩不可与旧谱面比较时递增;UI、文本、封面和音量调整不递增。")]
|
||||
public int chartRevision = 1;
|
||||
|
||||
public DifficultyData()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public DifficultyData(int difficultyIndex, string difficultyName, string displayDifficultyName, int difficultyValue, string charterName, Color color, bool ForceFullScreenJudge = false)
|
||||
public DifficultyData(int saveDifficultyId, string difficultyName, string displayDifficultyName, int difficultyValue, string charterName, Color color, bool ForceFullScreenJudge = false)
|
||||
{
|
||||
this.difficultyIndex = difficultyIndex;
|
||||
this.saveDifficultyId = saveDifficultyId;
|
||||
this.difficultyName = difficultyName;
|
||||
this.displayDifficultyName = displayDifficultyName;
|
||||
this.charterName = charterName;
|
||||
@@ -170,5 +195,13 @@ namespace Ichni.Menu
|
||||
{
|
||||
return string.IsNullOrEmpty(displayDifficultyName) ? difficultyName : displayDifficultyName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回合法的 Revision。配置侧始终至少使用 1;0 只会被视为未初始化值并被钳制。
|
||||
/// </summary>
|
||||
public int GetChartRevision()
|
||||
{
|
||||
return Mathf.Max(1, chartRevision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,26 @@ using UnityEngine.Serialization;
|
||||
|
||||
namespace Ichni
|
||||
{
|
||||
/// <summary>
|
||||
/// GameScene 返回 MenuScene 后应恢复的页面。
|
||||
/// 该枚举替代互斥的布尔标记,避免普通歌曲与教程返回状态同时残留。
|
||||
/// </summary>
|
||||
public enum MenuReturnDestination
|
||||
{
|
||||
None,
|
||||
SongSelection,
|
||||
Story
|
||||
}
|
||||
|
||||
public class InformationTransistor : MonoBehaviour
|
||||
{
|
||||
public static InformationTransistor instance;
|
||||
|
||||
public bool isReturnedFromGame;
|
||||
public bool isReturnedFromTutorial;
|
||||
/// <summary>
|
||||
/// 当前游戏运行结束后,MenuScene 应恢复的唯一目标页面。
|
||||
/// 运行期间保存在 DontDestroyOnLoad 对象中,不写入玩家存档。
|
||||
/// </summary>
|
||||
public MenuReturnDestination menuReturnDestination;
|
||||
|
||||
public ChapterSelectionUnit chapter;
|
||||
public SongItemData song;
|
||||
@@ -30,8 +44,7 @@ namespace Ichni
|
||||
{
|
||||
instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
isReturnedFromGame = false;
|
||||
isReturnedFromTutorial = false;
|
||||
menuReturnDestination = MenuReturnDestination.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -42,11 +55,48 @@ namespace Ichni
|
||||
public void SetInformation(ChapterSelectionUnit chapter,
|
||||
SongItemData song, DifficultyData difficulty)
|
||||
{
|
||||
// 保留旧入口,供现有选曲流程在播放过场前预先写入歌曲信息。
|
||||
// 真正进入 GameScene 时,MenuManager.EnterGame 会补充 SongSelection 返回目标。
|
||||
PrepareGameLaunch(chapter, song, difficulty, MenuReturnDestination.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一准备一次 GameScene 运行所需的上下文。
|
||||
/// 普通选曲和 TutorialBlock 都必须通过此入口写入,防止章节、歌曲、难度与返回目标来自不同旧运行。
|
||||
/// </summary>
|
||||
public bool PrepareGameLaunch(ChapterSelectionUnit chapter, SongItemData song,
|
||||
DifficultyData difficulty, MenuReturnDestination returnDestination)
|
||||
{
|
||||
if (chapter == null || song == null || difficulty == null)
|
||||
{
|
||||
Debug.LogWarning("[InformationTransistor] 缺少章节、歌曲或难度,无法准备 GameScene 上下文。");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.chapter = chapter;
|
||||
this.song = song;
|
||||
this.difficulty = difficulty;
|
||||
this.chapterSwitch = chapter.chapterSwitch;
|
||||
this.songSwitch = song.songSwitch;
|
||||
chapterSwitch = chapter.chapterSwitch;
|
||||
songSwitch = song.songSwitch;
|
||||
menuReturnDestination = returnDestination;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由现有选曲过场在真正激活 GameScene 前设置返回目标。
|
||||
/// </summary>
|
||||
public void SetMenuReturnDestination(MenuReturnDestination returnDestination)
|
||||
{
|
||||
menuReturnDestination = returnDestination;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MenuScene 已依据返回目标完成恢复后调用。
|
||||
/// 只清除一次性路由,不清除歌曲与难度,以免当前 UI 的运行时缓存被意外破坏。
|
||||
/// </summary>
|
||||
public void ClearMenuReturnDestination()
|
||||
{
|
||||
menuReturnDestination = MenuReturnDestination.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,29 +24,70 @@ namespace Ichni.Menu
|
||||
}
|
||||
}
|
||||
|
||||
public SongSelectionRecord GetRecordOfThisChapter()
|
||||
/// <summary>
|
||||
/// 取得当前 Chapter 的选曲缓存;缓存缺失时创建“第一首有效歌曲 + 难度位置 0”的默认记录。
|
||||
/// <para>此方法只保证缓存对象可用,不保证该歌曲或难度仍能游玩。歌曲失效与难度回退由选曲 UI 在
|
||||
/// 每次进入页面时完成,避免把运行期缓存变成持久化存档。</para>
|
||||
/// </summary>
|
||||
public bool TryGetRecordOfThisChapter(out SongSelectionRecord record)
|
||||
{
|
||||
record = null;
|
||||
ChapterSelectionUnit currentChapter = ChapterSelectionManager.instance.currentChapter;
|
||||
if (songSelectionRecords.TryGetValue(currentChapter, out SongSelectionRecord record))
|
||||
if (currentChapter == null || currentChapter.songs == null)
|
||||
{
|
||||
return record;
|
||||
return false;
|
||||
}
|
||||
else
|
||||
|
||||
if (songSelectionRecords.TryGetValue(currentChapter, out SongSelectionRecord cachedRecord) && cachedRecord != null)
|
||||
{
|
||||
return new SongSelectionRecord(currentChapter.songs[0], currentChapter.songs[0].difficultyDataList[0]);
|
||||
record = cachedRecord;
|
||||
return true;
|
||||
}
|
||||
|
||||
SongItemData firstValidSong = currentChapter.songs.Find(song => song != null);
|
||||
if (firstValidSong == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
record = new SongSelectionRecord(firstValidSong, 0);
|
||||
songSelectionRecords[currentChapter] = record;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将缓存修正为当前 Chapter 中实际存在的歌曲,并保留调用方提供的难度列表偏好位置。
|
||||
/// 这里只写内存缓存,玩家重启游戏后不会恢复该选择。
|
||||
/// </summary>
|
||||
public void SetRecordForChapter(ChapterSelectionUnit chapter, SongItemData song, int difficultyListIndex)
|
||||
{
|
||||
if (chapter == null || song == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
songSelectionRecords[chapter] = new SongSelectionRecord(song, difficultyListIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public class SongSelectionRecord
|
||||
{
|
||||
public SongItemData song;
|
||||
public int difficultyIndex;
|
||||
public int difficultyListIndex;
|
||||
|
||||
public SongSelectionRecord(SongItemData song, DifficultyData difficulty)
|
||||
/// <summary>
|
||||
/// 使用难度在歌曲列表中的位置构建缓存。
|
||||
/// 这是选曲 UI 的临时偏好,不等同于 DifficultyData.saveDifficultyId 的游戏记录稳定 ID。
|
||||
/// </summary>
|
||||
public SongSelectionRecord(SongItemData song, int difficultyListIndex)
|
||||
{
|
||||
this.song = song;
|
||||
difficultyIndex = song.difficultyDataList.IndexOf(difficulty);
|
||||
this.difficultyListIndex = difficultyListIndex;
|
||||
}
|
||||
|
||||
public SongSelectionRecord(SongItemData song, DifficultyData difficulty)
|
||||
: this(song, song?.difficultyDataList?.IndexOf(difficulty) ?? 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using AK.Wwise;
|
||||
using Ichni.Menu;
|
||||
using Ichni.Menu.UI;
|
||||
using Ichni.Story;
|
||||
using Ichni.Story.UI;
|
||||
using Ichni.UI;
|
||||
using Sirenix.OdinInspector;
|
||||
using SLSUtilities.WwiseAssistance;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.Serialization;
|
||||
@@ -21,6 +24,7 @@ namespace Ichni
|
||||
public LoginPage loginPage;
|
||||
public ChapterSelectionUIPage chapterSelectionUIPage;
|
||||
public StoryUIPage storyUIPage;
|
||||
public SelectionBoxUIPage selectionBoxUIPage;
|
||||
// dialogUIPage 已随旧对话系统移除;阶段 3 将接入新的 VN 对话页面引用
|
||||
public SongSelectionUIPage songSelectionUIPage;
|
||||
public TransitionUIPage transitionUIPage;
|
||||
@@ -35,6 +39,12 @@ namespace Ichni
|
||||
AsyncOperation asyncOperation;
|
||||
public bool isEnteringGame;
|
||||
|
||||
/// <summary>
|
||||
/// 当前 MenuScene 是否已经完成 GameScene 预加载且尚未处于切场中。
|
||||
/// TutorialFlowController 在写入“已处理”剧情变量前使用此状态进行最后一次启动前检查。
|
||||
/// </summary>
|
||||
public bool CanBeginGame => !isEnteringGame && asyncOperation != null;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
instance = this;
|
||||
@@ -44,25 +54,40 @@ namespace Ichni
|
||||
{
|
||||
isEnteringGame = false;
|
||||
|
||||
if (InformationTransistor.instance.isReturnedFromGame)
|
||||
InformationTransistor information = InformationTransistor.instance;
|
||||
MenuReturnDestination returnDestination = information != null
|
||||
? information.menuReturnDestination
|
||||
: MenuReturnDestination.None;
|
||||
|
||||
if (returnDestination == MenuReturnDestination.SongSelection &&
|
||||
information != null && information.chapter != null &&
|
||||
information.song != null && information.difficulty != null)
|
||||
{
|
||||
startUIPage.mainCanvasGroup.gameObject.SetActive(false);
|
||||
ChapterSelectionManager.instance.currentChapter =
|
||||
InformationTransistor.instance.chapter;
|
||||
information.chapter;
|
||||
AudioManager.SetSwitch(ChapterSelectionManager.instance.currentChapter.chapterSwitch);
|
||||
MenuInformationRecorder.instance.songSelectionRecords.Add(
|
||||
ChapterSelectionManager.instance.currentChapter,
|
||||
new SongSelectionRecord(InformationTransistor.instance.song, InformationTransistor.instance.difficulty));
|
||||
MenuInformationRecorder.instance.songSelectionRecords[
|
||||
ChapterSelectionManager.instance.currentChapter] =
|
||||
new SongSelectionRecord(information.song, information.difficulty);
|
||||
songSelectionUIPage.FadeIn();
|
||||
}
|
||||
else if (InformationTransistor.instance.isReturnedFromTutorial)
|
||||
else if (returnDestination == MenuReturnDestination.Story &&
|
||||
information != null && information.chapter != null)
|
||||
{
|
||||
startUIPage.mainCanvasGroup.gameObject.SetActive(false);
|
||||
ChapterSelectionManager.instance.currentChapter = InformationTransistor.instance.chapter;
|
||||
ChapterSelectionManager.instance.currentChapter = information.chapter;
|
||||
AudioManager.SetSwitch(ChapterSelectionManager.instance.currentChapter.chapterSwitch);
|
||||
|
||||
// MenuScene 会在教程游玩后重新创建,必须显式重建章节故事树,
|
||||
// 不能依赖旧场景中残留的 Block 实例。
|
||||
StoryManager.instance?.OpenChapter(ChapterSelectionManager.instance.currentChapter.chapterIndex);
|
||||
storyUIPage.FadeIn();
|
||||
}
|
||||
|
||||
// 返回目标是一次性运行态信息。无论是否能恢复页面,都不能让它在下次重进菜单时重复生效。
|
||||
information?.ClearMenuReturnDestination();
|
||||
|
||||
Application.targetFrameRate = SettingsManager.instance.gameSettings.targetFrame;
|
||||
asyncOperation = SceneManager.LoadSceneAsync("GameScene");
|
||||
asyncOperation.allowSceneActivation = false;
|
||||
@@ -73,14 +98,58 @@ namespace Ichni
|
||||
{
|
||||
public void EnterGame()
|
||||
{
|
||||
InformationTransistor.instance.isReturnedFromTutorial = false;
|
||||
InformationTransistor.instance.isReturnedFromGame = true;
|
||||
EnterGame(MenuReturnDestination.SongSelection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 激活已预加载的 GameScene,并写入唯一的菜单返回目标。
|
||||
/// 普通选曲使用 <see cref="MenuReturnDestination.SongSelection"/>,教程使用
|
||||
/// <see cref="MenuReturnDestination.Story"/>。
|
||||
/// </summary>
|
||||
public void EnterGame(MenuReturnDestination returnDestination)
|
||||
{
|
||||
InformationTransistor.instance?.SetMenuReturnDestination(returnDestination);
|
||||
EnterGameScene();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 TutorialBlock 等非选曲入口准备上下文并播放通用转场。
|
||||
/// 运行成功后会返回 true;返回 false 时不会修改剧情变量或存档,调用方应保留原状。
|
||||
/// </summary>
|
||||
public bool TryBeginGame(ChapterSelectionUnit chapter, SongItemData song,
|
||||
DifficultyData difficulty, MenuReturnDestination returnDestination, UIPageBase sourcePage)
|
||||
{
|
||||
if (!CanBeginGame)
|
||||
{
|
||||
Debug.LogWarning("[MenuManager] GameScene 未完成预加载或菜单已在切换,忽略启动请求。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (InformationTransistor.instance == null ||
|
||||
!InformationTransistor.instance.PrepareGameLaunch(chapter, song, difficulty, returnDestination))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
isEnteringGame = true;
|
||||
sourcePage?.FadeOut();
|
||||
transitionUIPage?.FadeIn();
|
||||
AudioManager.Post(AK.EVENTS.ENTERTOGAME);
|
||||
|
||||
Observable.Timer(TimeSpan.FromSeconds(0.6f)).Subscribe(_ => EnterGameScene());
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnterGameScene()
|
||||
{
|
||||
MenuInputManager.instance.gameInput.Menu.Disable();
|
||||
if (asyncOperation == null)
|
||||
{
|
||||
Debug.LogError("[MenuManager] GameScene 尚未完成预加载,无法进入游戏。");
|
||||
isEnteringGame = false;
|
||||
return;
|
||||
}
|
||||
|
||||
MenuInputManager.instance?.gameInput?.Menu.Disable();
|
||||
asyncOperation.allowSceneActivation = true;
|
||||
}
|
||||
}
|
||||
|
||||
8
Assets/Scripts/Menu/MessagePage.meta
Normal file
8
Assets/Scripts/Menu/MessagePage.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4029d987b6779e440b58bc5e9b457db1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,9 +9,9 @@ namespace Ichni.Story.UI
|
||||
/// 用于在剧情系统中管理并顺序展示 MessageBox 的独立 UI 页面。
|
||||
/// 支持动态实例化给定的 MessageBox Prefab,并在所有消息展示完毕后进行大页面的 FadeOut。
|
||||
/// </summary>
|
||||
public class StoryMessageBoxUIPage : UIPageBase
|
||||
public class MessageUIPage : UIPageBase
|
||||
{
|
||||
public static StoryMessageBoxUIPage instance;
|
||||
public static MessageUIPage instance;
|
||||
|
||||
[Header("Prefabs & Containers")]
|
||||
[Tooltip("默认使用的 MessageBox 预制体(请从 Project 中拖拽,而非场景中的实例)")]
|
||||
@@ -97,6 +97,7 @@ namespace Ichni.Story.UI
|
||||
// 唤醒大页面(执行 UIPageBase 的渐入并拦截底层射线)
|
||||
this.FadeIn(0.5f, false, ShowNextInQueue);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -9,6 +9,35 @@ namespace Ichni.Menu
|
||||
[CreateAssetMenu(fileName = "TutorialCollection", menuName = "Ichni/TutorialCollection")]
|
||||
public class TutorialCollection : SerializedScriptableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// 教程 Key 到教程曲目配置的映射。
|
||||
/// Key 必须使用小写英文、数字和下划线,例如 <c>chapter0_intro</c>;
|
||||
/// 不要使用章节展示名、曲名或点号作为 Key。
|
||||
/// </summary>
|
||||
public Dictionary<string, SongItemData> songs = new Dictionary<string, SongItemData>();
|
||||
|
||||
/// <summary>
|
||||
/// 按 TutorialBlock 配置的稳定 Key 查找教程曲目。
|
||||
/// 教程运行流程只通过此函数读取集合,避免各处直接访问字典而产生空引用或不一致的错误信息。
|
||||
/// </summary>
|
||||
public bool TryGetTutorialSong(string tutorialKey, out SongItemData song)
|
||||
{
|
||||
song = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tutorialKey))
|
||||
{
|
||||
Debug.LogWarning("[TutorialCollection] Tutorial Key 为空,无法查找教程曲目。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (songs == null || !songs.TryGetValue(tutorialKey, out song) || song == null)
|
||||
{
|
||||
Debug.LogWarning($"[TutorialCollection] 未找到 Tutorial Key '{tutorialKey}' 对应的教程曲目。");
|
||||
song = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,36 @@ namespace Ichni.Story
|
||||
[LabelText("Tutorial Name")]
|
||||
public string tutorialName;
|
||||
|
||||
/// <summary>
|
||||
/// 教程曲目在 <see cref="Ichni.Menu.TutorialCollection"/> 中的稳定 Key。
|
||||
/// 只用于运行时查找,不使用展示名称或章节名称作为逻辑匹配条件。
|
||||
/// 命名统一使用小写英文、数字和下划线,例如 <c>chapter0_intro</c>。
|
||||
/// </summary>
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Tutorial)]
|
||||
[LabelText("Tutorial Key")]
|
||||
public string tutorialKey;
|
||||
|
||||
/// <summary>
|
||||
/// 教程被玩家选择“游玩”或“跳过”后写入的全局剧情变量。
|
||||
/// 后续 Block 应通过 <see cref="VariableCondition"/> 检查该变量是否等于 1 来解锁。
|
||||
/// 命名统一使用小写英文、数字和下划线,例如
|
||||
/// <c>story_tutorial_chapter0_intro_resolved</c>。
|
||||
/// </summary>
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Tutorial)]
|
||||
[LabelText("Progress Variable")]
|
||||
public string tutorialProgressVariable;
|
||||
|
||||
/// <summary>
|
||||
/// 要启动的教程难度的稳定存档 ID,对应 <see cref="Ichni.Menu.DifficultyData.saveDifficultyId"/>。
|
||||
/// 不使用难度列表下标,避免调整难度排列后教程进入错误谱面。
|
||||
/// </summary>
|
||||
[FoldoutGroup("$blockId")]
|
||||
[ShowIf("blockType", StoryBlockType.Tutorial)]
|
||||
[LabelText("Tutorial Difficulty Save ID")]
|
||||
public int tutorialDifficultySaveId = -1;
|
||||
|
||||
/// <summary>
|
||||
/// 预览 / 调试用的显示标题:按类型取对应字段,缺省时回退到 blockId。
|
||||
/// </summary>
|
||||
|
||||
@@ -94,6 +94,20 @@ namespace Ichni.Story
|
||||
Variables.stringVariables[key] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除指定剧情变量在三种底层字典中的所有残留值。
|
||||
/// 仅删除内存值;需要持久化和故事树刷新时,应调用 <see cref="StoryProgress"/> 的统一接口。
|
||||
/// </summary>
|
||||
public static void Remove(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
|
||||
StoryVariablesSave vars = Variables;
|
||||
vars.floatVariables.Remove(key);
|
||||
vars.stringVariables.Remove(key);
|
||||
vars.boolVariables.Remove(key);
|
||||
}
|
||||
|
||||
public static void ClearAll()
|
||||
{
|
||||
StoryVariablesSave vars = Variables;
|
||||
|
||||
@@ -157,7 +157,7 @@ namespace Ichni.Story.Dialogue
|
||||
action?.Invoke();
|
||||
}
|
||||
|
||||
StoryMessageBoxUIPage.instance.FadeIn();
|
||||
MessageUIPage.instance.FadeIn();
|
||||
}
|
||||
|
||||
if (storyUIPage != null)
|
||||
|
||||
8
Assets/Scripts/NewStorySystem/Progress.meta
Normal file
8
Assets/Scripts/NewStorySystem/Progress.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90d61ae2c68e4a058826e1af1052cd3d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
115
Assets/Scripts/NewStorySystem/Progress/StoryProgress.cs
Normal file
115
Assets/Scripts/NewStorySystem/Progress/StoryProgress.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// 外部玩法系统修改剧情进度的统一入口。
|
||||
///
|
||||
/// StoryVariables 只负责读写内存中的变量字典,不会自动落盘或刷新故事树。
|
||||
/// 本类将“写变量、保存变量、重算 Block 状态”收口,避免教程、小游戏等系统重复实现
|
||||
/// 不完整的存档流程。
|
||||
/// </summary>
|
||||
public static class StoryProgress
|
||||
{
|
||||
/// <summary>
|
||||
/// 写入布尔剧情变量、保存到 StorySaveModule,并立即重新推导当前故事树。
|
||||
/// 可供教程以外的玩法系统使用。
|
||||
/// </summary>
|
||||
public static bool SetBoolAndRefresh(string variableName, bool value)
|
||||
{
|
||||
if (!TrySetBoolAndSave(variableName, value))
|
||||
return false;
|
||||
|
||||
StoryManager.instance?.treeController?.RefreshAllStates();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理一次 TutorialBlock 的选择结果。
|
||||
/// 无论选择“游玩”还是“跳过”,都会将其进度变量设为 true,并将教程节点标记为完成。
|
||||
/// 后续 Block 的解锁应依赖 <see cref="StoryBlockDefinition.tutorialProgressVariable"/>,
|
||||
/// 而不是依赖该节点的 completedBlockIds,以便和 Yarn 及其它剧情变量共用同一条件系统。
|
||||
/// </summary>
|
||||
public static bool ResolveTutorial(StoryBlockDefinition tutorialBlock)
|
||||
{
|
||||
if (tutorialBlock == null || tutorialBlock.blockType != StoryBlockType.Tutorial)
|
||||
{
|
||||
Debug.LogWarning("[StoryProgress] 无法处理空对象或非 Tutorial 类型的 Block。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tutorialBlock.blockId) ||
|
||||
string.IsNullOrWhiteSpace(tutorialBlock.tutorialProgressVariable))
|
||||
{
|
||||
Debug.LogWarning($"[StoryProgress] TutorialBlock '{tutorialBlock?.blockId}' 缺少 Block ID 或 Progress Variable,拒绝解锁。");
|
||||
return false;
|
||||
}
|
||||
|
||||
StoryTreeController treeController = StoryManager.instance?.treeController;
|
||||
if (treeController == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryProgress] StoryTreeController 未就绪,拒绝写入教程进度。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TrySetBoolAndSave(tutorialBlock.tutorialProgressVariable, true))
|
||||
return false;
|
||||
|
||||
// completedBlockIds 只负责教程节点自身的 Completed 外观与不可重复点击;
|
||||
// 后续节点应在 StoryData 中以 VariableCondition 读取上面的剧情变量。
|
||||
treeController.OnBlockCompleted(tutorialBlock.blockId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除一个章节中所有 TutorialBlock 声明的进度变量。
|
||||
/// 目前供 StoryTreeController 的章节进度调试重置使用,确保变量条件与 completedBlockIds 一起回到初始状态。
|
||||
/// </summary>
|
||||
public static void ClearTutorialProgressForChapter(StoryData storyData)
|
||||
{
|
||||
if (storyData?.blocks == null ||
|
||||
GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
foreach (StoryBlockDefinition block in storyData.blocks)
|
||||
{
|
||||
if (block == null || block.blockType != StoryBlockType.Tutorial ||
|
||||
string.IsNullOrWhiteSpace(block.tutorialProgressVariable))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!StoryVariables.HasVariable(block.tutorialProgressVariable))
|
||||
continue;
|
||||
|
||||
StoryVariables.Remove(block.tutorialProgressVariable);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
GameSaveManager.instance.StorySaveModule.SaveVariables();
|
||||
}
|
||||
|
||||
private static bool TrySetBoolAndSave(string variableName, bool value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableName))
|
||||
{
|
||||
Debug.LogWarning("[StoryProgress] 剧情变量名为空,拒绝写入。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.StorySaveModule == null)
|
||||
{
|
||||
Debug.LogWarning("[StoryProgress] StorySaveModule 未就绪,拒绝写入剧情变量。");
|
||||
return false;
|
||||
}
|
||||
|
||||
StoryVariables.SetBool(variableName, value);
|
||||
GameSaveManager.instance.StorySaveModule.SaveVariables();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2e5a902d1d14be4a2e1f06c2353d4a1
|
||||
139
Assets/Scripts/NewStorySystem/Progress/TutorialFlowController.cs
Normal file
139
Assets/Scripts/NewStorySystem/Progress/TutorialFlowController.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using Ichni.Menu;
|
||||
using Ichni.Menu.UI;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Story
|
||||
{
|
||||
/// <summary>
|
||||
/// TutorialBlock 的菜单层流程协调器。
|
||||
/// 负责验证配置、生成通用 SelectionBox 选项、写入剧情进度,并在“游玩”时发起 GameScene 运行。
|
||||
/// 不保存“教程成绩”或“教程是否通关”;本阶段规则是玩家确认游玩或跳过即视为已处理。
|
||||
/// </summary>
|
||||
public static class TutorialFlowController
|
||||
{
|
||||
/// <summary>
|
||||
/// 由 <see cref="UI.TutorialBlockView"/> 点击时调用。配置无效或 SelectionBox 页面未配置时只报错,不修改剧情进度。
|
||||
/// </summary>
|
||||
public static void RequestTutorial(StoryBlockDefinition tutorialBlock)
|
||||
{
|
||||
if (!TryResolveTutorialRunData(tutorialBlock, out ChapterSelectionUnit chapter,
|
||||
out SongItemData song, out DifficultyData difficulty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MenuManager menuManager = MenuManager.instance;
|
||||
SelectionBoxUIPage selectionBoxPage = menuManager?.selectionBoxUIPage;
|
||||
if (selectionBoxPage == null)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] MenuManager 未配置 SelectionBoxUIPage,教程不会被解锁。");
|
||||
return;
|
||||
}
|
||||
|
||||
// TutorialFlowController 只描述“有哪些选择、各自触发什么效果”;
|
||||
// 具体按钮由通用 SelectionBox 在运行时生成,后续其它系统可复用同一 UI。
|
||||
selectionBoxPage.Show(
|
||||
tutorialBlock.tutorialName,
|
||||
string.Empty,
|
||||
new List<SelectionBoxOption>
|
||||
{
|
||||
new("游玩教程", () => PlayTutorial(tutorialBlock, chapter, song, difficulty)),
|
||||
new("跳过教程", () => SkipTutorial(tutorialBlock))
|
||||
});
|
||||
}
|
||||
|
||||
private static void PlayTutorial(StoryBlockDefinition tutorialBlock, ChapterSelectionUnit chapter,
|
||||
SongItemData song, DifficultyData difficulty)
|
||||
{
|
||||
MenuManager menuManager = MenuManager.instance;
|
||||
if (menuManager == null || menuManager.isEnteringGame)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] 菜单正在切换,忽略教程游玩请求。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 先验证能否启动 GameScene,再写入“已处理”进度;避免无效配置导致错误解锁。
|
||||
if (!CanBeginTutorialGame(chapter, song, difficulty))
|
||||
return;
|
||||
|
||||
if (!StoryProgress.ResolveTutorial(tutorialBlock))
|
||||
return;
|
||||
|
||||
// 规则:玩家确认“游玩教程”的瞬间即解锁后续剧情,不等待教程结算。
|
||||
menuManager.TryBeginGame(chapter, song, difficulty, MenuReturnDestination.Story,
|
||||
menuManager.storyUIPage);
|
||||
}
|
||||
|
||||
private static void SkipTutorial(StoryBlockDefinition tutorialBlock)
|
||||
{
|
||||
// 规则:玩家确认“跳过教程”的瞬间即解锁后续剧情,并保留在故事树。
|
||||
StoryProgress.ResolveTutorial(tutorialBlock);
|
||||
}
|
||||
|
||||
private static bool CanBeginTutorialGame(ChapterSelectionUnit chapter, SongItemData song, DifficultyData difficulty)
|
||||
{
|
||||
if (MenuManager.instance == null || InformationTransistor.instance == null ||
|
||||
!MenuManager.instance.CanBeginGame)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] MenuManager、InformationTransistor 或 GameScene 预加载未就绪,无法进入教程。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chapter == null || song == null || difficulty == null)
|
||||
{
|
||||
Debug.LogWarning("[TutorialFlowController] 教程缺少章节、歌曲或难度配置,无法进入教程。");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 TutorialBlock 的 Key、教程曲目与稳定难度 ID。
|
||||
/// 任何一项缺失都会失败且不写入剧情变量,防止错误配置意外解锁后续内容。
|
||||
/// </summary>
|
||||
private static bool TryResolveTutorialRunData(StoryBlockDefinition tutorialBlock,
|
||||
out ChapterSelectionUnit chapter, out SongItemData song, out DifficultyData difficulty)
|
||||
{
|
||||
chapter = ChapterSelectionManager.instance?.currentChapter;
|
||||
song = null;
|
||||
difficulty = null;
|
||||
|
||||
if (tutorialBlock == null || tutorialBlock.blockType != StoryBlockType.Tutorial ||
|
||||
string.IsNullOrWhiteSpace(tutorialBlock.tutorialKey) ||
|
||||
string.IsNullOrWhiteSpace(tutorialBlock.tutorialProgressVariable) ||
|
||||
tutorialBlock.tutorialDifficultySaveId < 0)
|
||||
{
|
||||
Debug.LogWarning($"[TutorialFlowController] TutorialBlock '{tutorialBlock?.blockId}' 的 Tutorial Key、Progress Variable 或 Difficulty Save ID 未配置完整。");
|
||||
return false;
|
||||
}
|
||||
|
||||
TutorialCollection collection = ChapterSelectionManager.instance?.tutorialCollection;
|
||||
if (chapter == null || collection == null || !collection.TryGetTutorialSong(tutorialBlock.tutorialKey, out song))
|
||||
{
|
||||
Debug.LogWarning($"[TutorialFlowController] TutorialBlock '{tutorialBlock.blockId}' 无法解析章节或教程曲目。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (song.difficultyDataList == null)
|
||||
{
|
||||
Debug.LogWarning($"[TutorialFlowController] 教程 '{tutorialBlock.tutorialKey}' 没有难度列表。");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (DifficultyData candidate in song.difficultyDataList)
|
||||
{
|
||||
if (candidate != null && candidate.isAvailable &&
|
||||
candidate.saveDifficultyId == tutorialBlock.tutorialDifficultySaveId)
|
||||
{
|
||||
difficulty = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[TutorialFlowController] 教程 '{tutorialBlock.tutorialKey}' 中不存在可用的 Difficulty Save ID '{tutorialBlock.tutorialDifficultySaveId}'。");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a44d2b7e3f954b9b86f6beec1a7cb621
|
||||
@@ -15,6 +15,12 @@ namespace Ichni
|
||||
private const string ChapterStoryKey = "ChapterStory";
|
||||
private const string VariablesKey = "StoryVariables";
|
||||
|
||||
// 每个剧情存档文件都保存自己的 Schema Version;不要把它与歌曲成绩存档的 Version 混用。
|
||||
// 当前项目尚未正式发布,剧情存档统一从 v1 开始;非 v1 的测试存档会被直接重置。
|
||||
// 正式发布后升级 Schema 时,必须用非破坏性迁移替代当前的重置策略。
|
||||
private const string StorySaveSchemaVersionKey = "StorySaveSchemaVersion";
|
||||
private const int CurrentStorySaveSchemaVersion = 1;
|
||||
|
||||
// 已加载章节的内存缓存(chapterIndex -> 章节存档)
|
||||
private readonly Dictionary<string, ChapterStorySave> _chapterSaves =
|
||||
new Dictionary<string, ChapterStorySave>();
|
||||
@@ -28,31 +34,67 @@ namespace Ichni
|
||||
private static string VariablesPath =>
|
||||
Application.persistentDataPath + "/StorySaves/StoryVariables.json";
|
||||
|
||||
/// <summary>
|
||||
/// 补齐章节存档的可空集合,并以文件名对应的 chapterIndex 作为唯一归属。
|
||||
/// 当前用于保证新建 v1 存档的运行时集合非空;未来迁移逻辑应单独实现,不应依赖此方法隐式转换。
|
||||
/// </summary>
|
||||
private static ChapterStorySave NormalizeChapterSave(ChapterStorySave save, string chapterIndex)
|
||||
{
|
||||
save ??= new ChapterStorySave();
|
||||
save.chapterIndex = chapterIndex;
|
||||
save.completedBlockIds ??= new List<string>();
|
||||
save.selectedChoices ??= new Dictionary<string, int>();
|
||||
return save;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 补齐全局剧情变量字典,保证新建 v1 存档可被 Yarn 变量访问。
|
||||
/// </summary>
|
||||
private static StoryVariablesSave NormalizeVariables(StoryVariablesSave storyVariables)
|
||||
{
|
||||
storyVariables ??= new StoryVariablesSave();
|
||||
storyVariables.floatVariables ??= new Dictionary<string, float>();
|
||||
storyVariables.stringVariables ??= new Dictionary<string, string>();
|
||||
storyVariables.boolVariables ??= new Dictionary<string, bool>();
|
||||
return storyVariables;
|
||||
}
|
||||
|
||||
// ── 故事树 + 选项(按章节)────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 判断某章节是否尚无存档(首次进入)。
|
||||
/// 判断某章节是否尚无可用的 v1 存档(首次进入或旧测试存档待重置)。
|
||||
/// </summary>
|
||||
public bool IsNewChapter(string chapterIndex) =>
|
||||
!ES3.FileExists(GetChapterSavePath(chapterIndex));
|
||||
public bool IsNewChapter(string chapterIndex)
|
||||
{
|
||||
string path = GetChapterSavePath(chapterIndex);
|
||||
return !ES3.FileExists(path) || !ES3.KeyExists(ChapterStoryKey, path) ||
|
||||
!ES3.KeyExists(StorySaveSchemaVersionKey, path) ||
|
||||
ES3.Load<int>(StorySaveSchemaVersionKey, path) != CurrentStorySaveSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载指定章节的存档容器;无存档时返回一个新的空容器。加载结果会写入内存缓存。
|
||||
/// 加载指定章节的 v1 存档容器;无存档或非 v1 的预发布测试存档会直接重置为空容器。
|
||||
/// 加载结果会写入内存缓存。
|
||||
/// </summary>
|
||||
public ChapterStorySave LoadChapter(string chapterIndex)
|
||||
{
|
||||
string path = GetChapterSavePath(chapterIndex);
|
||||
bool hasExistingFile = ES3.FileExists(path);
|
||||
int loadedSchemaVersion = hasExistingFile && ES3.KeyExists(StorySaveSchemaVersionKey, path)
|
||||
? ES3.Load<int>(StorySaveSchemaVersionKey, path)
|
||||
: 0;
|
||||
|
||||
ChapterStorySave save;
|
||||
if (ES3.FileExists(path) && ES3.KeyExists(ChapterStoryKey, path))
|
||||
if (hasExistingFile && loadedSchemaVersion != CurrentStorySaveSchemaVersion)
|
||||
{
|
||||
save = ES3.Load<ChapterStorySave>(ChapterStoryKey, path);
|
||||
}
|
||||
else
|
||||
{
|
||||
save = new ChapterStorySave { chapterIndex = chapterIndex };
|
||||
Debug.LogWarning($"Resetting pre-release chapter story save '{chapterIndex}' from schema v{loadedSchemaVersion} to v{CurrentStorySaveSchemaVersion}.");
|
||||
ES3.DeleteFile(path);
|
||||
hasExistingFile = false;
|
||||
}
|
||||
|
||||
ChapterStorySave save = hasExistingFile && ES3.KeyExists(ChapterStoryKey, path)
|
||||
? ES3.Load<ChapterStorySave>(ChapterStoryKey, path)
|
||||
: new ChapterStorySave();
|
||||
save = NormalizeChapterSave(save, chapterIndex);
|
||||
_chapterSaves[chapterIndex] = save;
|
||||
return save;
|
||||
}
|
||||
@@ -62,8 +104,17 @@ namespace Ichni
|
||||
/// </summary>
|
||||
public void SaveChapter(ChapterStorySave save)
|
||||
{
|
||||
if (save == null || string.IsNullOrEmpty(save.chapterIndex))
|
||||
{
|
||||
Debug.LogWarning("Cannot save a chapter story record without a chapterIndex.");
|
||||
return;
|
||||
}
|
||||
|
||||
save = NormalizeChapterSave(save, save.chapterIndex);
|
||||
_chapterSaves[save.chapterIndex] = save;
|
||||
ES3.Save(ChapterStoryKey, save, GetChapterSavePath(save.chapterIndex));
|
||||
string path = GetChapterSavePath(save.chapterIndex);
|
||||
ES3.Save(ChapterStoryKey, save, path);
|
||||
ES3.Save(StorySaveSchemaVersionKey, CurrentStorySaveSchemaVersion, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -101,14 +152,22 @@ namespace Ichni
|
||||
/// </summary>
|
||||
public void LoadVariables()
|
||||
{
|
||||
if (ES3.FileExists(VariablesPath) && ES3.KeyExists(VariablesKey, VariablesPath))
|
||||
bool hasExistingFile = ES3.FileExists(VariablesPath);
|
||||
int loadedSchemaVersion = hasExistingFile && ES3.KeyExists(StorySaveSchemaVersionKey, VariablesPath)
|
||||
? ES3.Load<int>(StorySaveSchemaVersionKey, VariablesPath)
|
||||
: 0;
|
||||
|
||||
if (hasExistingFile && loadedSchemaVersion != CurrentStorySaveSchemaVersion)
|
||||
{
|
||||
variables = ES3.Load<StoryVariablesSave>(VariablesKey, VariablesPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
variables = new StoryVariablesSave();
|
||||
Debug.LogWarning($"Resetting pre-release global story variables from schema v{loadedSchemaVersion} to v{CurrentStorySaveSchemaVersion}.");
|
||||
ES3.DeleteFile(VariablesPath);
|
||||
hasExistingFile = false;
|
||||
}
|
||||
|
||||
variables = hasExistingFile && ES3.KeyExists(VariablesKey, VariablesPath)
|
||||
? ES3.Load<StoryVariablesSave>(VariablesKey, VariablesPath)
|
||||
: new StoryVariablesSave();
|
||||
variables = NormalizeVariables(variables);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -116,7 +175,9 @@ namespace Ichni
|
||||
/// </summary>
|
||||
public void SaveVariables()
|
||||
{
|
||||
variables = NormalizeVariables(variables);
|
||||
ES3.Save(VariablesKey, variables, VariablesPath);
|
||||
ES3.Save(StorySaveSchemaVersionKey, CurrentStorySaveSchemaVersion, VariablesPath);
|
||||
}
|
||||
|
||||
// ── 清档 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,13 +44,13 @@ namespace Ichni.Story
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除全部剧情存档(所有章节故事树 + 全局变量 + 剧情解锁 key)。
|
||||
/// 清除全部剧情存档(所有章节故事树 + 全局变量 + 由剧情授予的内容解锁 Key)。
|
||||
/// </summary>
|
||||
[Button]
|
||||
public void ClearAllStorySave()
|
||||
{
|
||||
GameSaveManager.instance.StorySaveModule.ClearAllStoryline();
|
||||
GameSaveManager.instance.SongSaveModule.ClearStoryKeys();
|
||||
GameSaveManager.instance.UnlockSaveModule.ClearAllKeys();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,12 @@ namespace Ichni.Story
|
||||
public void OnBlockCompleted(string blockId)
|
||||
{
|
||||
if (!_completed.Add(blockId))
|
||||
return; // 已完成,无需处理
|
||||
{
|
||||
// 剧情变量可能在 block 已完成后被外部系统修正;即使完成集合没有变化,
|
||||
// 也要重新推导依赖变量的后续 Block 状态。
|
||||
RefreshAllStates();
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshAllStates();
|
||||
SaveChapter();
|
||||
@@ -382,6 +387,9 @@ namespace Ichni.Story
|
||||
return;
|
||||
}
|
||||
|
||||
// TutorialBlock 的后续解锁由全局剧情变量驱动;只清空 completedBlockIds
|
||||
// 会造成“节点未完成但后续仍解锁”的错误测试结果,因此同时清除本章节声明的教程变量。
|
||||
StoryProgress.ClearTutorialProgressForChapter(_storyData);
|
||||
_completed.Clear();
|
||||
SaveChapter();
|
||||
BuildChapter(_chapterIndex);
|
||||
|
||||
@@ -4,7 +4,8 @@ using UnityEngine;
|
||||
namespace Ichni.Story.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 教程块视图(占位)。点击后将进入该章节的教程游玩,后续阶段接入。
|
||||
/// 教程块视图。点击后由 <see cref="TutorialFlowController"/> 显示确认窗口,
|
||||
/// 玩家选择游玩或跳过时都会写入教程剧情变量并刷新故事树。
|
||||
/// </summary>
|
||||
public class TutorialBlockView : StoryBlockView
|
||||
{
|
||||
@@ -21,8 +22,19 @@ namespace Ichni.Story.UI
|
||||
|
||||
protected override void OnClicked()
|
||||
{
|
||||
// 占位:后续接入教程进入流程
|
||||
Debug.Log($"[TutorialBlockView] 点击教程块 '{blockId}' (tutorial='{definition.tutorialName}')。占位实现,教程进入将在后续阶段接入。");
|
||||
TutorialFlowController.RequestTutorial(definition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 已处理的教程不再重复弹出“游玩 / 跳过”确认窗口。
|
||||
/// 教程重玩将来应由独立的教程入口提供,不复用剧情进度节点。
|
||||
/// </summary>
|
||||
public override void ApplyState(StoryBlockState newState)
|
||||
{
|
||||
base.ApplyState(newState);
|
||||
|
||||
if (button != null && newState == StoryBlockState.Completed)
|
||||
button.interactable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,39 +14,29 @@ namespace Ichni.Story.YarnFunctions
|
||||
// ── Yarn Commands ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 解锁一首新歌曲,并在当前这段对话结束(对话 UI 淡出)后,弹出 MessageBox 提示。
|
||||
/// 格式: <<unlock_song songUnlockKey>>
|
||||
/// 兼容既有 Yarn 脚本:授予歌曲相关的解锁 Key,并在当前对话结束后弹出提示。
|
||||
/// 格式: <<unlock_song unlock_key>>
|
||||
/// <para>Key 必须使用小写字母、数字和下划线,例如 <c>story_ch0_prologue_completed</c>。</para>
|
||||
/// 新内容若不需要歌曲提示,优先使用 <see cref="GrantUnlock"/>。
|
||||
/// </summary>
|
||||
/// <param name="songUnlockKey">要解锁的歌曲密钥/ID</param>
|
||||
/// <param name="songUnlockKey">要授予的稳定解锁 Key,而不是歌曲显示名称。</param>
|
||||
[YarnCommand("unlock_song")]
|
||||
public static void UnlockSong(string songUnlockKey)
|
||||
{
|
||||
if (GameSaveManager.instance == null || GameSaveManager.instance.SongSaveModule == null)
|
||||
if (!TryGrantUnlockKey(songUnlockKey, out bool wasNewlyGranted) || !wasNewlyGranted)
|
||||
{
|
||||
Debug.LogError("[StoryTreeCommands] 无法解锁歌曲:GameSaveManager 或其 SongSaveModule 未初始化!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 检查是否已经解锁
|
||||
if (GameSaveManager.instance.SongSaveModule.CheckStoryKey(songUnlockKey))
|
||||
{
|
||||
Debug.Log($"[StoryTreeCommands] 歌曲 {songUnlockKey} 已处于解锁状态。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 写入解锁状态并保存存档
|
||||
GameSaveManager.instance.SongSaveModule.storyUnlockKeys.Add(songUnlockKey);
|
||||
GameSaveManager.instance.SongSaveModule.SaveStoryUnlockKeys();
|
||||
Debug.Log($"[StoryTreeCommands] 已成功写入并保存歌曲解锁密钥:{songUnlockKey}");
|
||||
|
||||
// 3. 将 UI 弹窗提示加入到对话结束队列中
|
||||
// 将歌曲提示加入对话结束队列。弹窗本地化将由后续 Localization 阶段替换,
|
||||
// 这里不参与内容是否解锁的授权判断。
|
||||
if (StoryDialogueController.instance != null)
|
||||
{
|
||||
StoryDialogueController.instance.dialogueEndActions.Add(() =>
|
||||
{
|
||||
if (StoryMessageBoxUIPage.instance != null)
|
||||
if (MessageUIPage.instance != null)
|
||||
{
|
||||
StoryMessageBoxUIPage.instance.ShowUnlockMessage(songUnlockKey);
|
||||
MessageUIPage.instance.ShowUnlockMessage(songUnlockKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -60,6 +50,18 @@ namespace Ichni.Story.YarnFunctions
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通用内容解锁命令,可用于章节、歌曲、教程及未来内容。
|
||||
/// 格式: <<grant_unlock unlock_key>>。
|
||||
/// 本命令只授予并保存 Key,不绑定任何 UI 提示;需要提示时应由 Yarn 额外调用 <c>show_message</c>。
|
||||
/// </summary>
|
||||
/// <param name="unlockKey">符合 <see cref="UnlockSaveModule.KeyNamingRule"/> 的稳定解锁 Key。</param>
|
||||
[YarnCommand("grant_unlock")]
|
||||
public static void GrantUnlock(string unlockKey)
|
||||
{
|
||||
TryGrantUnlockKey(unlockKey, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在对话结束后,弹出一个自定义 MessageBox 提示。
|
||||
/// 会尝试使用 Unity Localization 进行翻译,如果找不到对应条目则回退显示原文本(Key)。
|
||||
@@ -76,10 +78,39 @@ namespace Ichni.Story.YarnFunctions
|
||||
Debug.Log($"[StoryTreeCommands] 准备在对话结束后显示自定义弹窗:标题='{translatedTitle}',内容='{translatedContent}'");
|
||||
StoryDialogueController.instance.dialogueEndActions.Add(() =>
|
||||
{
|
||||
StoryMessageBoxUIPage.instance.ShowCustomMessage(translatedTitle, translatedContent);
|
||||
MessageUIPage.instance.ShowCustomMessage(translatedTitle, translatedContent);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所有 Yarn 解锁命令共用的授予入口。它负责模块存在性检查、重复授予的幂等处理及立即 ES3 保存。
|
||||
/// </summary>
|
||||
/// <returns>模块已正常处理该 Key 时返回 true;Key 非法或模块尚未初始化时返回 false。</returns>
|
||||
private static bool TryGrantUnlockKey(string unlockKey, out bool wasNewlyGranted)
|
||||
{
|
||||
wasNewlyGranted = false;
|
||||
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
|
||||
if (unlockSaveModule == null)
|
||||
{
|
||||
Debug.LogError("[StoryTreeCommands] 无法授予解锁 Key:UnlockSaveModule 尚未初始化!");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (unlockSaveModule.HasKey(unlockKey))
|
||||
{
|
||||
Debug.Log($"[StoryTreeCommands] 解锁 Key 已存在:{unlockKey}");
|
||||
return true;
|
||||
}
|
||||
|
||||
wasNewlyGranted = unlockSaveModule.GrantKey(unlockKey);
|
||||
if (wasNewlyGranted)
|
||||
{
|
||||
Debug.Log($"[StoryTreeCommands] 已成功写入并保存解锁 Key:{unlockKey}");
|
||||
}
|
||||
|
||||
return wasNewlyGranted;
|
||||
}
|
||||
|
||||
// ── 内部辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string GetTranslatedText(string tableName, string key)
|
||||
|
||||
@@ -4,12 +4,27 @@ using UnityEngine;
|
||||
|
||||
namespace Ichni.RhythmGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个歌曲难度的最佳成绩。
|
||||
/// 这里只保存当前 Chart Revision 下可比较的最佳记录,不保存逐局历史、判定时间线或 Replay。
|
||||
/// </summary>
|
||||
public class BeatmapSave
|
||||
{
|
||||
/// <summary>历史最高 Accuracy,固定使用 0 到 1;显示时由 UI 乘以 100。</summary>
|
||||
public float accuracy;
|
||||
|
||||
/// <summary>当前 Revision 下是否曾达成 Full Combo。</summary>
|
||||
public bool isFullCombo;
|
||||
|
||||
/// <summary>当前 Revision 下是否曾达成 All Perfect。</summary>
|
||||
public bool isAllPerfect;
|
||||
|
||||
/// <summary>成绩对应的谱面修订号;0 表示尚未绑定 Revision 的新建或异常记录。</summary>
|
||||
public int chartRevision;
|
||||
|
||||
/// <summary>当前 Revision 下的历史最高连击数。</summary>
|
||||
public int maxCombo;
|
||||
|
||||
public BeatmapSave()
|
||||
{
|
||||
|
||||
@@ -22,9 +37,82 @@ namespace Ichni.RhythmGame
|
||||
this.isAllPerfect = isAllPerfect;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 令存档成绩匹配当前谱面 Revision。
|
||||
/// <para>仅当 Note、Timing 或判定规则改变、导致成绩不再可比较时才递增 Revision。</para>
|
||||
/// <para>未绑定 Revision 的记录会保留已有成绩并绑定到当前 Revision;之后出现真正的 Revision
|
||||
/// 变化时,才仅重置该谱面的可比成绩。</para>
|
||||
/// </summary>
|
||||
public bool EnsureChartRevision(int currentChartRevision)
|
||||
{
|
||||
currentChartRevision = Mathf.Max(1, currentChartRevision);
|
||||
|
||||
if (chartRevision <= 0)
|
||||
{
|
||||
chartRevision = currentChartRevision;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (chartRevision == currentChartRevision)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
accuracy = 0f;
|
||||
isFullCombo = false;
|
||||
isAllPerfect = false;
|
||||
maxCombo = 0;
|
||||
chartRevision = currentChartRevision;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合并一次完整演奏的成绩。
|
||||
/// <see cref="accuracy"/> 在持久化记录中始终使用 0 到 1 的归一化值,
|
||||
/// 而 <see cref="PlayingRecorder.accuracy"/> 的运行时显示值为 0 到 100。
|
||||
/// 写入规则是“最高 Accuracy / 最高 Max Combo / FC 与 AP 成就粘性保留”,而不是保存最近一次成绩。
|
||||
/// </summary>
|
||||
public bool ApplyPlayResult(PlayingRecorder recorder, int currentChartRevision)
|
||||
{
|
||||
if (recorder == null || recorder.totalCount <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool changed = EnsureChartRevision(currentChartRevision);
|
||||
float normalizedAccuracy = Mathf.Clamp01(recorder.accuracy / 100f);
|
||||
if (normalizedAccuracy > accuracy)
|
||||
{
|
||||
accuracy = normalizedAccuracy;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
bool achievedFullCombo = recorder.isFullCombo || recorder.isAllPerfect;
|
||||
if (achievedFullCombo && !isFullCombo)
|
||||
{
|
||||
isFullCombo = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (recorder.isAllPerfect && !isAllPerfect)
|
||||
{
|
||||
isAllPerfect = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (recorder.maxCombo > maxCombo)
|
||||
{
|
||||
maxCombo = recorder.maxCombo;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>用于章节完成度统计;Revision 本身不代表玩家已经游玩过该谱面。</summary>
|
||||
public bool IsEmpty()
|
||||
{
|
||||
return accuracy == 0f && !isFullCombo && !isAllPerfect;
|
||||
return accuracy == 0f && !isFullCombo && !isAllPerfect && maxCombo == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Menu;
|
||||
@@ -9,11 +8,19 @@ using UnityEngine;
|
||||
|
||||
namespace Ichni
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局存档入口。
|
||||
/// <para><see cref="SongSaveModule"/> 负责可重复游玩的歌曲成绩与谱面记录;</para>
|
||||
/// <para><see cref="UnlockSaveModule"/> 负责章节、歌曲及未来内容共用的 Offline 解锁 Key;</para>
|
||||
/// <para><see cref="StorySaveModule"/> 负责章节故事树、对话选项和 Yarn 变量。</para>
|
||||
/// 两套数据使用不同文件和不同生命周期,修改任一侧时不要把另一侧的字段或迁移规则混入其中。
|
||||
/// </summary>
|
||||
public class GameSaveManager : SerializedMonoBehaviour
|
||||
{
|
||||
public static GameSaveManager instance;
|
||||
|
||||
public SongSaveModule SongSaveModule;
|
||||
public UnlockSaveModule UnlockSaveModule;
|
||||
public StorySaveModule StorySaveModule;
|
||||
|
||||
private void Awake()
|
||||
@@ -31,10 +38,15 @@ namespace Ichni
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 菜单与章节 UI 会在启动后立刻读取歌曲记录,因此游戏记录必须先加载。
|
||||
SongSaveModule = new SongSaveModule();
|
||||
SongSaveModule.LoadSongStatuses();
|
||||
SongSaveModule.LoadStoryUnlockKeys();
|
||||
|
||||
// 内容解锁既不属于歌曲成绩也不属于 Yarn 剧情树。菜单在首次显示前必须先得到它的状态。
|
||||
UnlockSaveModule = new UnlockSaveModule();
|
||||
UnlockSaveModule.LoadUnlockKeys();
|
||||
|
||||
// 剧情变量属于独立存档,不参与歌曲成绩的 Schema 或 Chart Revision 迁移。
|
||||
StorySaveModule = new StorySaveModule();
|
||||
StorySaveModule.LoadVariables();
|
||||
}
|
||||
@@ -42,106 +54,82 @@ namespace Ichni
|
||||
|
||||
public partial class SongSaveModule
|
||||
{
|
||||
public HashSet<string> storyUnlockKeys;
|
||||
public HashSet<string> paymentUnlockKeys;
|
||||
// ES3 数据 Key。一旦发布,不要重命名;重命名等同于修改存档格式,需要 Schema Migration。
|
||||
private const string SongSavesKey = "SongSaves";
|
||||
|
||||
// 与歌曲记录存放在同一文件。当前项目尚未正式发布,不兼容的旧版本会直接重置为新的 v1 记录。
|
||||
private const string SongSaveSchemaVersionKey = "SongSaveSchemaVersion";
|
||||
|
||||
// v1 使用 Dictionary<saveDifficultyId, BeatmapSave> 保存成绩,彻底与选曲 UI 的列表位置分离。
|
||||
// 谱面内容改变应递增 Chart Revision,而不是此值。正式发布后升级 Schema 时必须恢复非破坏性迁移策略。
|
||||
private const int CurrentSongSaveSchemaVersion = 1;
|
||||
|
||||
public Dictionary<string, SongStatusSave> songStatusSaves;
|
||||
// 歌曲成绩与 Schema Version 的唯一持久化文件;剧情存档不使用此路径。
|
||||
private string songSavePath => Application.persistentDataPath + "/GameSaves/SongSaves.json";
|
||||
private string storyUnlockKeysPath => Application.persistentDataPath + "/GameSaves/UnlockKeys.json";
|
||||
private string paymentUnlockKeysPath => Application.persistentDataPath + "/GameSaves/PaymentUnlockKeys.json";
|
||||
public SongSaveModule()
|
||||
{
|
||||
songStatusSaves = new Dictionary<string, SongStatusSave>();
|
||||
storyUnlockKeys = new HashSet<string>();
|
||||
paymentUnlockKeys = new HashSet<string>();
|
||||
//Debug.Log("Song save path: " + songSavePath);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class SongSaveModule
|
||||
{
|
||||
public void SaveStoryUnlockKeys()
|
||||
/// <summary>
|
||||
/// 使已加载的歌曲记录与当前 Chapter 配置保持一致。
|
||||
/// 会为新增难度建立以稳定 saveDifficultyId 为 Key 的记录,并检查每个谱面的 Chart Revision。
|
||||
/// 已删除难度的旧 Key 不自动删除:它不会参与当前 UI 或完成度统计,且可在内容恢复时保留历史成绩。
|
||||
/// Revision 不一致时只重置该谱面的可比成绩,不回退歌曲完成状态、剧情进度或解锁状态。
|
||||
/// </summary>
|
||||
private static void EnsureSongStatusMatchesDefinition(SongStatusSave songStatus, SongItemData song)
|
||||
{
|
||||
ES3.Save("UnlockKeys", storyUnlockKeys, storyUnlockKeysPath);
|
||||
songStatus.additionalInfo ??= string.Empty;
|
||||
songStatus.beatmapSavesByDifficultyId ??= new Dictionary<int, BeatmapSave>();
|
||||
|
||||
foreach (DifficultyData difficulty in song.difficultyDataList)
|
||||
{
|
||||
if (difficulty == null || difficulty.saveDifficultyId < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
public void LoadStoryUnlockKeys()
|
||||
{
|
||||
if (ES3.FileExists(storyUnlockKeysPath))
|
||||
{
|
||||
storyUnlockKeys = ES3.Load<HashSet<string>>("UnlockKeys", storyUnlockKeysPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
storyUnlockKeys = new HashSet<string>();
|
||||
SaveStoryUnlockKeys();
|
||||
BeatmapSave beatmapSave = songStatus.GetOrCreateBeatmapSave(difficulty.saveDifficultyId);
|
||||
beatmapSave.EnsureChartRevision(difficulty.GetChartRevision());
|
||||
}
|
||||
}
|
||||
|
||||
public bool CheckStoryKey(string key)
|
||||
/// <summary>
|
||||
/// 为新加入首发内容的歌曲创建空记录,并立即按当前难度定义初始化 Revision。
|
||||
/// </summary>
|
||||
private static SongStatusSave CreateSongStatus(SongItemData song)
|
||||
{
|
||||
return key == string.Empty || storyUnlockKeys.Contains(key);
|
||||
SongStatusSave songStatus = new SongStatusSave(false, string.Empty, new Dictionary<int, BeatmapSave>());
|
||||
EnsureSongStatusMatchesDefinition(songStatus, song);
|
||||
return songStatus;
|
||||
}
|
||||
|
||||
public void ClearStoryKeys()
|
||||
{
|
||||
storyUnlockKeys.Clear();
|
||||
SaveStoryUnlockKeys();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class SongSaveModule
|
||||
{
|
||||
public void SavePaymentUnlockKeys()
|
||||
{
|
||||
ES3.Save("UnlockKeys", paymentUnlockKeys, paymentUnlockKeysPath);
|
||||
}
|
||||
|
||||
public void LoadPaymentUnlockKeys()
|
||||
{
|
||||
if (ES3.FileExists(paymentUnlockKeysPath))
|
||||
{
|
||||
paymentUnlockKeys = ES3.Load<HashSet<string>>("UnlockKeys", paymentUnlockKeysPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentUnlockKeys = new HashSet<string>();
|
||||
SavePaymentUnlockKeys();
|
||||
}
|
||||
}
|
||||
|
||||
public bool CheckPaymentKey(string key)
|
||||
{
|
||||
return key == string.Empty || paymentUnlockKeys.Contains(key);
|
||||
}
|
||||
|
||||
public void ClearPaymentKeys()
|
||||
{
|
||||
paymentUnlockKeys.Clear();
|
||||
SavePaymentUnlockKeys();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class SongSaveModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 首次启动、尚无歌曲存档时调用。以当前 Chapter 配置生成完整的空记录集合。
|
||||
/// </summary>
|
||||
private void InitializeSongStatuses()
|
||||
{
|
||||
songStatusSaves = new Dictionary<string, SongStatusSave>();
|
||||
foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters)
|
||||
{
|
||||
foreach (SongItemData song in chapter.songs)
|
||||
{
|
||||
SongStatusSave songStatus = new SongStatusSave(false, string.Empty, new List<BeatmapSave>());
|
||||
foreach (DifficultyData difficulty in song.difficultyDataList)
|
||||
{
|
||||
songStatus.beatmapSaves.Add(new BeatmapSave(0f,false, false));
|
||||
}
|
||||
|
||||
songStatusSaves.Add(song.songName, songStatus);
|
||||
songStatusSaves[song.songName] = CreateSongStatus(song);
|
||||
}
|
||||
}
|
||||
|
||||
SaveSongStatuses();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每次加载后执行的内容对账与 Revision 检查。
|
||||
/// 这是新增/删除歌曲、增加难度或递增 Chart Revision 后自动修正旧存档的入口。
|
||||
/// </summary>
|
||||
private void CheckSongStatuses()
|
||||
{
|
||||
foreach (ChapterSelectionUnit chapter in ChapterSelectionManager.instance.chapters)
|
||||
@@ -150,27 +138,17 @@ namespace Ichni
|
||||
{
|
||||
if (songStatusSaves.TryGetValue(song.songName, out SongStatusSave songStatus))
|
||||
{
|
||||
int difficultiesCount = song.difficultyDataList.Count;
|
||||
if (songStatus.beatmapSaves.Count < difficultiesCount)
|
||||
if (songStatus == null)
|
||||
{
|
||||
for (int i = songStatus.beatmapSaves.Count; i < difficultiesCount; i++)
|
||||
{
|
||||
songStatus.beatmapSaves.Add(new BeatmapSave(0f, false, false));
|
||||
}
|
||||
}
|
||||
else if (songStatus.beatmapSaves.Count > difficultiesCount)
|
||||
{
|
||||
songStatus.beatmapSaves.RemoveRange(difficultiesCount, songStatus.beatmapSaves.Count - difficultiesCount);
|
||||
songStatusSaves[song.songName] = CreateSongStatus(song);
|
||||
continue;
|
||||
}
|
||||
|
||||
EnsureSongStatusMatchesDefinition(songStatus, song);
|
||||
}
|
||||
else
|
||||
{
|
||||
songStatus = new SongStatusSave(false, string.Empty, new List<BeatmapSave>());
|
||||
foreach (DifficultyData difficulty in song.difficultyDataList)
|
||||
{
|
||||
songStatus.beatmapSaves.Add(new BeatmapSave(0f, false, false));
|
||||
}
|
||||
songStatusSaves.Add(song.songName, songStatus);
|
||||
songStatusSaves[song.songName] = CreateSongStatus(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,16 +157,86 @@ namespace Ichni
|
||||
}
|
||||
|
||||
[Button]
|
||||
/// <summary>
|
||||
/// 将歌曲记录与当前 Schema Version 一起写入 ES3。
|
||||
/// 不要单独保存成绩而遗漏 Version;否则下次启动会把同一份数据错误识别为旧 Schema。
|
||||
/// </summary>
|
||||
public void SaveSongStatuses()
|
||||
{
|
||||
ES3.Save("SongSaves", songStatusSaves, songSavePath);
|
||||
ES3.Save(SongSavesKey, songStatusSaves, songSavePath);
|
||||
ES3.Save(SongSaveSchemaVersionKey, CurrentSongSaveSchemaVersion, songSavePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将一次已完成演奏写入游戏记录存档。
|
||||
/// 每个谱面保留当前 Chart Revision 下的最高 Accuracy、最高 Max Combo,以及曾达成的 FC/AP;
|
||||
/// 后续较差成绩不会覆盖既有纪录。本函数不修改剧情树、Yarn 变量或歌曲解锁。
|
||||
/// </summary>
|
||||
/// <param name="songName">SongStatusSave 的稳定歌曲 Key。</param>
|
||||
/// <param name="saveDifficultyId">存档 Key,必须对应 DifficultyData.saveDifficultyId。</param>
|
||||
/// <param name="currentChartRevision">当前谱面的 Revision;不一致时由 BeatmapSave 重置该谱面成绩。</param>
|
||||
/// <param name="recorder">仅本局有效的运行时判定记录。</param>
|
||||
public bool RecordPlayResult(string songName, int saveDifficultyId, int currentChartRevision, PlayingRecorder recorder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(songName))
|
||||
{
|
||||
Debug.LogWarning("Cannot save play result without a song name.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (saveDifficultyId < 0)
|
||||
{
|
||||
Debug.LogWarning($"Cannot save play result for '{songName}' with an invalid save difficulty ID: {saveDifficultyId}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (recorder == null || recorder.totalCount <= 0)
|
||||
{
|
||||
Debug.LogWarning($"Skipped saving play result for '{songName}' because no judgments were recorded.");
|
||||
return false;
|
||||
}
|
||||
|
||||
songStatusSaves ??= new Dictionary<string, SongStatusSave>();
|
||||
if (!songStatusSaves.TryGetValue(songName, out SongStatusSave songStatus) || songStatus == null)
|
||||
{
|
||||
songStatus = new SongStatusSave(false, string.Empty, new Dictionary<int, BeatmapSave>());
|
||||
songStatusSaves[songName] = songStatus;
|
||||
}
|
||||
|
||||
songStatus.additionalInfo ??= string.Empty;
|
||||
BeatmapSave beatmapSave = songStatus.GetOrCreateBeatmapSave(saveDifficultyId);
|
||||
|
||||
bool recordChanged = beatmapSave.ApplyPlayResult(recorder, currentChartRevision);
|
||||
bool completionChanged = !songStatus.isCompleted;
|
||||
songStatus.isCompleted = true;
|
||||
|
||||
SaveSongStatuses();
|
||||
return recordChanged || completionChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取歌曲记录并执行内容对账和 Chart Revision 检查。
|
||||
/// 当前为正式发布前的重置策略:任何非 v1 的旧测试存档都会直接被新 v1 结构覆盖,
|
||||
/// 因此开发者需要自行接受成绩清空。正式发布后不得沿用此策略,应改回显式迁移。
|
||||
/// </summary>
|
||||
public void LoadSongStatuses()
|
||||
{
|
||||
if (ES3.FileExists(songSavePath))
|
||||
if (ES3.FileExists(songSavePath) && ES3.KeyExists(SongSavesKey, songSavePath))
|
||||
{
|
||||
songStatusSaves = ES3.Load<Dictionary<string, SongStatusSave>>("SongSaves", songSavePath);
|
||||
int loadedSchemaVersion = ES3.KeyExists(SongSaveSchemaVersionKey, songSavePath)
|
||||
? ES3.Load<int>(SongSaveSchemaVersionKey, songSavePath)
|
||||
: 0;
|
||||
|
||||
if (loadedSchemaVersion != CurrentSongSaveSchemaVersion)
|
||||
{
|
||||
Debug.LogWarning($"Resetting pre-release song save schema v{loadedSchemaVersion} to v{CurrentSongSaveSchemaVersion}.");
|
||||
ES3.DeleteFile(songSavePath);
|
||||
InitializeSongStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
songStatusSaves = ES3.Load<Dictionary<string, SongStatusSave>>(SongSavesKey, songSavePath) ??
|
||||
new Dictionary<string, SongStatusSave>();
|
||||
CheckSongStatuses();
|
||||
}
|
||||
else
|
||||
@@ -198,21 +246,38 @@ namespace Ichni
|
||||
}
|
||||
|
||||
[Button]
|
||||
/// <summary>
|
||||
/// 仅清除每张谱面的可比成绩(Accuracy、FC、AP、Max Combo)。
|
||||
/// 保留 Chart Revision、歌曲完成状态、剧情进度与解锁状态,适合测试成绩而不破坏流程存档。
|
||||
/// </summary>
|
||||
public void ClearBeatmapRecords()
|
||||
{
|
||||
foreach (var songStatus in songStatusSaves.Values)
|
||||
foreach (SongStatusSave songStatus in songStatusSaves.Values)
|
||||
{
|
||||
foreach (var beatmapSave in songStatus.beatmapSaves)
|
||||
if (songStatus?.beatmapSavesByDifficultyId == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (BeatmapSave beatmapSave in songStatus.beatmapSavesByDifficultyId.Values)
|
||||
{
|
||||
if (beatmapSave == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
beatmapSave.accuracy = 0.0f;
|
||||
beatmapSave.isFullCombo = false;
|
||||
beatmapSave.isAllPerfect = false;
|
||||
beatmapSave.maxCombo = 0;
|
||||
}
|
||||
}
|
||||
|
||||
SaveSongStatuses();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按歌曲 Key 获取内存中的游戏记录;该方法不触发磁盘读取或创建新记录。
|
||||
/// </summary>
|
||||
public SongStatusSave GetSongStatusSave(string songName)
|
||||
{
|
||||
if (songStatusSaves.TryGetValue(songName, out SongStatusSave save))
|
||||
|
||||
@@ -2,24 +2,69 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Ichni.RhythmGame
|
||||
{
|
||||
/// <summary>
|
||||
/// 单首歌曲的持久化容器。
|
||||
/// <see cref="isCompleted"/> 只表示玩家曾完成过该歌曲;具体难度的成绩、Revision 与 Max Combo
|
||||
/// 由 <see cref="beatmapSavesByDifficultyId"/> 中对应稳定难度 ID 的 <see cref="BeatmapSave"/> 管理。
|
||||
/// </summary>
|
||||
public class SongStatusSave
|
||||
{
|
||||
/// <summary>至少有一次有效结算完成;谱面 Revision 变化不会回退该流程状态。</summary>
|
||||
public bool isCompleted;
|
||||
|
||||
/// <summary>预留的歌曲级扩展字段;赋予新语义前须评估 Schema Version 是否需要递增。</summary>
|
||||
public string additionalInfo;
|
||||
public List<BeatmapSave> beatmapSaves;
|
||||
|
||||
/// <summary>
|
||||
/// 以 <c>DifficultyData.saveDifficultyId</c> 为 Key 的谱面成绩。
|
||||
/// Key 不依赖选曲列表位置,因此重排、隐藏或删除中间难度不会让其他难度的成绩错位。
|
||||
/// 被内容定义移除的 Key 暂时保留在存档中,但 UI 与章节完成度只读取当前仍存在的难度。
|
||||
/// </summary>
|
||||
public Dictionary<int, BeatmapSave> beatmapSavesByDifficultyId = new();
|
||||
|
||||
public SongStatusSave()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public SongStatusSave(bool isCompleted, string additionalInfo, List<BeatmapSave> beatmapSaves)
|
||||
public SongStatusSave(bool isCompleted, string additionalInfo, Dictionary<int, BeatmapSave> beatmapSavesByDifficultyId)
|
||||
{
|
||||
this.isCompleted = isCompleted;
|
||||
this.additionalInfo = additionalInfo;
|
||||
this.beatmapSaves = beatmapSaves;
|
||||
this.beatmapSavesByDifficultyId = beatmapSavesByDifficultyId;
|
||||
}
|
||||
|
||||
/// <summary>只读查询当前定义的难度成绩;不会因 UI 展示而创建新的存档记录。</summary>
|
||||
public bool TryGetBeatmapSave(int saveDifficultyId, out BeatmapSave beatmapSave)
|
||||
{
|
||||
beatmapSave = null;
|
||||
return saveDifficultyId >= 0 && beatmapSavesByDifficultyId != null &&
|
||||
beatmapSavesByDifficultyId.TryGetValue(saveDifficultyId, out beatmapSave) && beatmapSave != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或创建指定稳定难度 ID 的成绩容器。
|
||||
/// 仅存档写入/内容对账可调用此方法;选曲 UI 应使用 <see cref="TryGetBeatmapSave"/>。
|
||||
/// </summary>
|
||||
public BeatmapSave GetOrCreateBeatmapSave(int saveDifficultyId)
|
||||
{
|
||||
if (saveDifficultyId < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
beatmapSavesByDifficultyId ??= new Dictionary<int, BeatmapSave>();
|
||||
if (!beatmapSavesByDifficultyId.TryGetValue(saveDifficultyId, out BeatmapSave beatmapSave) || beatmapSave == null)
|
||||
{
|
||||
beatmapSave = new BeatmapSave(0f, false, false);
|
||||
beatmapSavesByDifficultyId[saveDifficultyId] = beatmapSave;
|
||||
}
|
||||
|
||||
return beatmapSave;
|
||||
}
|
||||
}
|
||||
}
|
||||
388
Assets/Scripts/Saving/UnlockSaveModule.cs
Normal file
388
Assets/Scripts/Saving/UnlockSaveModule.cs
Normal file
@@ -0,0 +1,388 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Ichni.Menu;
|
||||
using Sirenix.OdinInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni
|
||||
{
|
||||
/// <summary>
|
||||
/// Offline 内容解锁的独立持久化模块。
|
||||
/// <para>本模块只保存“玩家已经获得了哪些解锁 Key”,不保存歌曲成绩、剧情树或 Yarn 变量。</para>
|
||||
/// <para>章节、歌曲、教程及未来其它内容都通过 <see cref="UnlockRequirement"/> 查询本模块,
|
||||
/// 因而新增内容类型时不需要再建立一份平行的 Key 存档。</para>
|
||||
/// <para>首发为纯 Offline:本地 Key 可被玩家修改,不把它作为反作弊或付费凭证使用。</para>
|
||||
/// </summary>
|
||||
public sealed class UnlockSaveModule
|
||||
{
|
||||
// 保留既有 ES3 Key 与文件名,使此前仅保存 HashSet 的预发布 UnlockKeys.json 可以无损补写 Schema v1。
|
||||
private const string UnlockKeysKey = "UnlockKeys";
|
||||
private const string UnlockSaveSchemaVersionKey = "UnlockSaveSchemaVersion";
|
||||
private const int CurrentUnlockSaveSchemaVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 当前玩家已获得的所有稳定解锁 Key。只能通过 <see cref="GrantKey"/> / <see cref="ClearAllKeys"/>
|
||||
/// 修改,避免调用方漏存盘或绕过格式校验。
|
||||
/// </summary>
|
||||
private HashSet<string> grantedKeys = new HashSet<string>();
|
||||
|
||||
// 同一配置错误可能被 UI 每帧查询;仅记录一次,避免 Console 被重复警告淹没。
|
||||
private readonly HashSet<string> _reportedInvalidRules = new HashSet<string>();
|
||||
|
||||
private string UnlockSavePath => Application.persistentDataPath + "/GameSaves/UnlockKeys.json";
|
||||
|
||||
/// <summary>
|
||||
/// 解锁状态变化事件。当前菜单重新打开时会重建列表;未来若需要在同一页面即时刷新,
|
||||
/// UI 可订阅此事件后调用自己的刷新方法。
|
||||
/// </summary>
|
||||
public event Action UnlockStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 读取解锁 Key。旧预发布文件若只有 <c>UnlockKeys</c> 而没有 Schema,因数据布局相同会直接补写 v1;
|
||||
/// 其它不兼容 Schema 仍按当前预发布策略重置。
|
||||
/// </summary>
|
||||
public void LoadUnlockKeys()
|
||||
{
|
||||
grantedKeys = new HashSet<string>();
|
||||
|
||||
if (!ES3.FileExists(UnlockSavePath) || !ES3.KeyExists(UnlockKeysKey, UnlockSavePath))
|
||||
{
|
||||
SaveUnlockKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
int loadedSchemaVersion = ES3.KeyExists(UnlockSaveSchemaVersionKey, UnlockSavePath)
|
||||
? ES3.Load<int>(UnlockSaveSchemaVersionKey, UnlockSavePath)
|
||||
: 0;
|
||||
|
||||
// v0 是本项目此前的 HashSet<string> 直接存档,字段布局与 v1 一致,因此可以原样保留。
|
||||
if (loadedSchemaVersion != 0 && loadedSchemaVersion != CurrentUnlockSaveSchemaVersion)
|
||||
{
|
||||
Debug.LogWarning($"Resetting pre-release unlock save schema v{loadedSchemaVersion} to v{CurrentUnlockSaveSchemaVersion}.");
|
||||
ES3.DeleteFile(UnlockSavePath);
|
||||
SaveUnlockKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
grantedKeys = ES3.Load<HashSet<string>>(UnlockKeysKey, UnlockSavePath) ?? new HashSet<string>();
|
||||
RemoveInvalidKeysLoadedFromDisk();
|
||||
|
||||
// 包括从 v0 补写 v1、以及剔除无效 Key 后的回存。
|
||||
SaveUnlockKeys();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 立即写入全部已授予的 Key 与当前 Schema Version。
|
||||
/// 调用方不应单独操作 ES3,以保证 Key 集合与 Schema 始终成对更新。
|
||||
/// </summary>
|
||||
public void SaveUnlockKeys()
|
||||
{
|
||||
ES3.Save(UnlockKeysKey, grantedKeys, UnlockSavePath);
|
||||
ES3.Save(UnlockSaveSchemaVersionKey, CurrentUnlockSaveSchemaVersion, UnlockSavePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断玩家是否已持有一个有效 Key。空 Key 永远不代表已解锁;
|
||||
/// 无条件开放应通过 <see cref="UnlockRequirement.root"/> 为 null 表示。
|
||||
/// </summary>
|
||||
public bool HasKey(string key)
|
||||
{
|
||||
return IsValidKey(key) && grantedKeys.Contains(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 授予一个内容解锁 Key,并在首次授予时立即保存。
|
||||
/// 返回 true 表示本次确实新增了 Key;已存在或格式非法时返回 false。
|
||||
/// </summary>
|
||||
public bool GrantKey(string key)
|
||||
{
|
||||
if (!IsValidKey(key))
|
||||
{
|
||||
Debug.LogWarning($"Cannot grant invalid unlock key '{key}'. {KeyNamingRule}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!grantedKeys.Add(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SaveUnlockKeys();
|
||||
UnlockStateChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅供开发调试或“清空剧情进度”流程调用。它会删除所有由剧情授予的内容访问权,
|
||||
/// 但不会影响歌曲成绩、Chart Revision 或 Yarn 变量。
|
||||
/// </summary>
|
||||
public void ClearAllKeys()
|
||||
{
|
||||
if (grantedKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
grantedKeys.Clear();
|
||||
SaveUnlockKeys();
|
||||
UnlockStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一判断章节是否可访问。章节入口必须调用本方法,不能只依赖 Button.interactable 的视觉状态。
|
||||
/// </summary>
|
||||
public bool CanAccessChapter(ChapterSelectionUnit chapter)
|
||||
{
|
||||
if (chapter == null)
|
||||
{
|
||||
Debug.LogWarning("Cannot evaluate a null chapter unlock requirement.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsRequirementSatisfied(chapter.unlockRequirement, $"chapter '{chapter.chapterIndex}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统一判断歌曲本身是否可访问。它不包含章节判断;需要真正开始游玩时请使用 <see cref="CanEnterSong"/>。
|
||||
/// </summary>
|
||||
public bool CanAccessSong(SongItemData song)
|
||||
{
|
||||
if (song == null)
|
||||
{
|
||||
Debug.LogWarning("Cannot evaluate a null song unlock requirement.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsRequirementSatisfied(song.unlockRequirement, $"song '{song.songName}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所有进入歌曲的最终授权入口:章节与歌曲本身都必须开放。
|
||||
/// 标准 Play、快速点击以及未来 Story Song Block 都应调用此方法,避免入口之间出现绕过。
|
||||
/// </summary>
|
||||
public bool CanEnterSong(ChapterSelectionUnit chapter, SongItemData song)
|
||||
{
|
||||
return CanAccessChapter(chapter) && CanAccessSong(song);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 求值一个内容配置的解锁规则。null 规则或根节点为空均表示无条件开放;
|
||||
/// 已配置但不完整的条件节点会失败关闭(保持锁定)并只记录一次警告。
|
||||
/// </summary>
|
||||
public bool IsRequirementSatisfied(UnlockRequirement requirement, string ownerDescription)
|
||||
{
|
||||
return requirement == null || requirement.IsSatisfied(this, ownerDescription);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key 命名规则:只能使用小写英文字母、数字和下划线;必须以字母开头,长度 1–96。
|
||||
/// 推荐按“来源_章节_事件_状态”命名,例如 <c>story_ch0_prologue_completed</c>。
|
||||
/// 不得使用空格、点号、连字符、显示名称、翻译文本或会频繁改名的 Yarn 标题。
|
||||
/// </summary>
|
||||
public const string KeyNamingRule =
|
||||
"Unlock Key must use lowercase letters, digits and underscores, start with a letter, and be 1-96 characters.";
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Key 是否符合项目稳定命名约定。此方法不记录日志,便于 UI 的高频查询安全调用。
|
||||
/// </summary>
|
||||
public static bool IsValidKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key) || key.Length > 96 || key[0] < 'a' || key[0] > 'z')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int index = 1; index < key.Length; index++)
|
||||
{
|
||||
char character = key[index];
|
||||
bool isLowercaseLetter = character >= 'a' && character <= 'z';
|
||||
bool isDigit = character >= '0' && character <= '9';
|
||||
if (!isLowercaseLetter && !isDigit && character != '_')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由规则节点调用,确保无效配置只输出一次可定位的警告。
|
||||
/// </summary>
|
||||
internal void ReportInvalidRuleOnce(string ownerDescription, string detail)
|
||||
{
|
||||
string reportKey = ownerDescription + ":" + detail;
|
||||
if (_reportedInvalidRules.Add(reportKey))
|
||||
{
|
||||
Debug.LogWarning($"Invalid unlock requirement on {ownerDescription}: {detail}. {KeyNamingRule}");
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveInvalidKeysLoadedFromDisk()
|
||||
{
|
||||
List<string> invalidKeys = null;
|
||||
foreach (string key in grantedKeys)
|
||||
{
|
||||
if (!IsValidKey(key))
|
||||
{
|
||||
invalidKeys ??= new List<string>();
|
||||
invalidKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidKeys == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string key in invalidKeys)
|
||||
{
|
||||
grantedKeys.Remove(key);
|
||||
}
|
||||
|
||||
Debug.LogWarning($"Removed {invalidKeys.Count} invalid unlock key(s) while loading the pre-release save. {KeyNamingRule}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可配置在章节或歌曲上的解锁规则。根节点为空表示无条件开放;
|
||||
/// 若要锁定内容,请在 Inspector 中选择 Key / All Of / Any Of 节点并填写稳定 Key。
|
||||
/// </summary>
|
||||
[InlineProperty]
|
||||
[Serializable]
|
||||
public sealed class UnlockRequirement
|
||||
{
|
||||
[HideLabel]
|
||||
[SerializeReference]
|
||||
[InfoBox("留空表示无条件开放。Key 使用小写字母、数字和下划线,例如 story_ch0_prologue_completed。可组合 All Of(AND) / Any Of(OR)。")]
|
||||
public UnlockConditionNode root;
|
||||
|
||||
/// <summary>
|
||||
/// 使用当前玩家的 <see cref="UnlockSaveModule"/> 对规则树求值。
|
||||
/// </summary>
|
||||
internal bool IsSatisfied(UnlockSaveModule unlockSaveModule, string ownerDescription)
|
||||
{
|
||||
return root == null || root.Evaluate(unlockSaveModule, ownerDescription);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内容解锁条件树的基类。它只依赖已授予的 Key;不要把歌曲成绩、Yarn 变量或付费状态直接写入节点。
|
||||
/// 如需未来扩展条件来源,应在本树增加新的叶子节点,而不是让 UI 自行拼写判断。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public abstract class UnlockConditionNode
|
||||
{
|
||||
/// <summary>
|
||||
/// 对当前节点求值。返回 false 即表示内容保持锁定。
|
||||
/// </summary>
|
||||
internal abstract bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 叶子条件:玩家持有指定 Key 时满足。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("Key")]
|
||||
[HideReferenceObjectPicker]
|
||||
public sealed class UnlockKeyCondition : UnlockConditionNode
|
||||
{
|
||||
[HideLabel]
|
||||
[InfoBox("只能使用小写字母、数字和下划线;例如 story_ch0_prologue_completed。Key 为空会使该内容保持锁定。")]
|
||||
public string key;
|
||||
|
||||
internal override bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription)
|
||||
{
|
||||
if (!UnlockSaveModule.IsValidKey(key))
|
||||
{
|
||||
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, $"key '{key}' is empty or malformed");
|
||||
return false;
|
||||
}
|
||||
|
||||
return unlockSaveModule != null && unlockSaveModule.HasKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组合条件(AND):所有子条件都满足时开放。空列表或包含空节点视为配置错误并失败关闭。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("All Of (AND)")]
|
||||
[HideReferenceObjectPicker]
|
||||
public sealed class UnlockAllOfCondition : UnlockConditionNode
|
||||
{
|
||||
[HideLabel]
|
||||
[SerializeReference]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = true)]
|
||||
public List<UnlockConditionNode> conditions = new List<UnlockConditionNode>();
|
||||
|
||||
internal override bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription)
|
||||
{
|
||||
if (conditions == null || conditions.Count == 0)
|
||||
{
|
||||
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "All Of has no child conditions");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (UnlockConditionNode condition in conditions)
|
||||
{
|
||||
if (condition == null || !condition.Evaluate(unlockSaveModule, ownerDescription))
|
||||
{
|
||||
if (condition == null)
|
||||
{
|
||||
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "All Of contains a null child condition");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 组合条件(OR):任意子条件满足时开放。空列表或只包含空节点视为配置错误并失败关闭。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
[LabelText("Any Of (OR)")]
|
||||
[HideReferenceObjectPicker]
|
||||
public sealed class UnlockAnyOfCondition : UnlockConditionNode
|
||||
{
|
||||
[HideLabel]
|
||||
[SerializeReference]
|
||||
[ListDrawerSettings(ShowFoldout = true, DefaultExpandedState = true)]
|
||||
public List<UnlockConditionNode> conditions = new List<UnlockConditionNode>();
|
||||
|
||||
internal override bool Evaluate(UnlockSaveModule unlockSaveModule, string ownerDescription)
|
||||
{
|
||||
if (conditions == null || conditions.Count == 0)
|
||||
{
|
||||
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "Any Of has no child conditions");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasValidChild = false;
|
||||
foreach (UnlockConditionNode condition in conditions)
|
||||
{
|
||||
if (condition == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hasValidChild = true;
|
||||
if (condition.Evaluate(unlockSaveModule, ownerDescription))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidChild)
|
||||
{
|
||||
unlockSaveModule?.ReportInvalidRuleOnce(ownerDescription, "Any Of contains no valid child condition");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Saving/UnlockSaveModule.cs.meta
Normal file
11
Assets/Scripts/Saving/UnlockSaveModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35e319c0c2854f0fb9c59af946ee8318
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -24,6 +24,11 @@ namespace Ichni.UI
|
||||
public ChapterSelectionUnit connectedChapter;
|
||||
|
||||
public string chapterName;
|
||||
/// <summary>
|
||||
/// 当前章节的运行时锁定状态。仅用于 UI 表现;真正进入剧情或选曲前仍会重新调用
|
||||
/// <see cref="UnlockSaveModule.CanAccessChapter"/>,不能把本字段作为授权依据。
|
||||
/// </summary>
|
||||
public bool isLocked;
|
||||
public bool isExpanded;
|
||||
public bool isDuringAnimation;
|
||||
|
||||
@@ -59,6 +64,7 @@ namespace Ichni.UI
|
||||
{
|
||||
connectedChapter = chapter;
|
||||
expansionRipple.material = new Material(rippleMaterial);
|
||||
RefreshUnlockState();
|
||||
|
||||
expandButton.onClick.AddListener(() =>
|
||||
{
|
||||
@@ -84,6 +90,11 @@ namespace Ichni.UI
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CanEnterChapter())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChapterSelectionManager.instance.currentChapter = connectedChapter;
|
||||
AudioManager.SetSwitch(connectedChapter.chapterSwitch);
|
||||
ChapterSelectionManager.instance.chapterSelectionUIPage.FadeOut();
|
||||
@@ -98,6 +109,11 @@ namespace Ichni.UI
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CanEnterChapter())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChapterSelectionManager.instance.currentChapter = connectedChapter;
|
||||
AudioManager.SetSwitch(connectedChapter.chapterSwitch);
|
||||
ChapterSelectionManager.instance.chapterSelectionUIPage.FadeOut();
|
||||
@@ -113,6 +129,36 @@ namespace Ichni.UI
|
||||
beatmapProgressText.text = $"{finishedBeatmapCount}/{beatmapCount}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据统一内容解锁服务刷新章节入口的可交互状态。
|
||||
/// 当前 Prefab 尚无专用锁定美术,因此先禁用两个实际入口;后续补充锁图标时,
|
||||
/// 只需读取 <see cref="isLocked"/>,无需复制解锁判断。
|
||||
/// </summary>
|
||||
private void RefreshUnlockState()
|
||||
{
|
||||
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
|
||||
isLocked = unlockSaveModule == null || !unlockSaveModule.CanAccessChapter(connectedChapter);
|
||||
|
||||
if (enterStorylineButton != null)
|
||||
{
|
||||
enterStorylineButton.interactable = !isLocked;
|
||||
}
|
||||
|
||||
if (enterSongSelectionButton != null)
|
||||
{
|
||||
enterSongSelectionButton.interactable = !isLocked;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 章节的最终入口检查。即使 UI Button 状态被其它脚本意外恢复,也不能绕过解锁规则。
|
||||
/// </summary>
|
||||
private bool CanEnterChapter()
|
||||
{
|
||||
RefreshUnlockState();
|
||||
return !isLocked;
|
||||
}
|
||||
|
||||
private void Expand()
|
||||
{
|
||||
isExpanded = true;
|
||||
|
||||
114
Assets/Scripts/UI/Elements/SelectionBox.cs
Normal file
114
Assets/Scripts/UI/Elements/SelectionBox.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DG.Tweening;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace Ichni.Menu.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 可复用的多选弹窗元素。调用方提供标题、内容与任意数量的 <see cref="SelectionBoxOption"/>,
|
||||
/// 本类动态实例化对应按钮,并确保一次显示最多只会执行一个选项回调。
|
||||
/// </summary>
|
||||
public class SelectionBox : MonoBehaviour
|
||||
{
|
||||
[Header("Selection Box")]
|
||||
public CanvasGroup canvasGroup;
|
||||
public TMP_Text titleText;
|
||||
public TMP_Text contentText;
|
||||
public RectTransform optionContainer;
|
||||
public SelectionBoxButton optionButtonPrefab;
|
||||
|
||||
private readonly List<SelectionBoxButton> _generatedButtons = new();
|
||||
private bool _isResolved;
|
||||
|
||||
/// <summary>
|
||||
/// 选项被选择并且自身淡出结束后触发。页面容器使用它销毁本次生成的 SelectionBox。
|
||||
/// </summary>
|
||||
public event Action<SelectionBox> Closed;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
canvasGroup ??= GetComponent<CanvasGroup>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据运行时选项初始化弹窗。至少需要一个选项;失败时返回 false,且不会显示半配置的弹窗。
|
||||
/// </summary>
|
||||
public bool SetUp(string title, string content, IReadOnlyList<SelectionBoxOption> options)
|
||||
{
|
||||
if (canvasGroup == null || optionContainer == null || optionButtonPrefab == null ||
|
||||
options == null || options.Count == 0)
|
||||
{
|
||||
Debug.LogWarning("[SelectionBox] 缺少 CanvasGroup、选项容器、选项按钮预制体,或未提供任何选项。");
|
||||
return false;
|
||||
}
|
||||
|
||||
ClearGeneratedButtons();
|
||||
_isResolved = false;
|
||||
|
||||
if (titleText != null) titleText.text = title ?? string.Empty;
|
||||
if (contentText != null) contentText.text = content ?? string.Empty;
|
||||
|
||||
foreach (SelectionBoxOption option in options)
|
||||
{
|
||||
SelectionBoxButton button = Instantiate(optionButtonPrefab, optionContainer);
|
||||
_generatedButtons.Add(button);
|
||||
button.SetUp(option.label, () => Select(option.action));
|
||||
}
|
||||
|
||||
gameObject.SetActive(true);
|
||||
canvasGroup.alpha = 0f;
|
||||
canvasGroup.interactable = true;
|
||||
canvasGroup.blocksRaycasts = true;
|
||||
canvasGroup.DOFade(1f, 0.2f).Play();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Select(UnityAction action)
|
||||
{
|
||||
if (_isResolved) return;
|
||||
_isResolved = true;
|
||||
|
||||
// 先禁用所有输入,再执行回调。回调可以触发存档、场景切换或打开下一层 UI,
|
||||
// 因而不能让同一帧内的其它按钮再次触发。
|
||||
canvasGroup.interactable = false;
|
||||
canvasGroup.blocksRaycasts = false;
|
||||
foreach (SelectionBoxButton button in _generatedButtons)
|
||||
{
|
||||
if (button?.button != null)
|
||||
button.button.interactable = false;
|
||||
}
|
||||
|
||||
action?.Invoke();
|
||||
canvasGroup.DOFade(0f, 0.2f).OnComplete(() => Closed?.Invoke(this)).Play();
|
||||
}
|
||||
|
||||
private void ClearGeneratedButtons()
|
||||
{
|
||||
foreach (SelectionBoxButton button in _generatedButtons)
|
||||
{
|
||||
if (button != null)
|
||||
Destroy(button.gameObject);
|
||||
}
|
||||
|
||||
_generatedButtons.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SelectionBox 的单个运行时选项。它不序列化到场景或存档;显示时由调用方临时创建。
|
||||
/// </summary>
|
||||
public readonly struct SelectionBoxOption
|
||||
{
|
||||
public readonly string label;
|
||||
public readonly UnityAction action;
|
||||
|
||||
public SelectionBoxOption(string label, UnityAction action)
|
||||
{
|
||||
this.label = label;
|
||||
this.action = action;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/UI/Elements/SelectionBox.cs.meta
Normal file
2
Assets/Scripts/UI/Elements/SelectionBox.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ac6b73d890a4a7aaed8e2fa64001764
|
||||
40
Assets/Scripts/UI/Elements/SelectionBoxButton.cs
Normal file
40
Assets/Scripts/UI/Elements/SelectionBoxButton.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Ichni.Menu.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// SelectionBox 动态生成的单个选项按钮。
|
||||
/// 预制体只需提供 Button 和 TMP 文本;运行时由 <see cref="SelectionBox"/> 写入显示文字与点击回调。
|
||||
/// </summary>
|
||||
public class SelectionBoxButton : MonoBehaviour
|
||||
{
|
||||
public Button button;
|
||||
public TMP_Text labelText;
|
||||
|
||||
/// <summary>
|
||||
/// 重新绑定按钮文字与本次 SelectionBox 的选择回调。
|
||||
/// 每次生成时都会清空旧监听,防止对象复用或预制体事件造成重复触发。
|
||||
/// </summary>
|
||||
public void SetUp(string label, UnityAction onSelected)
|
||||
{
|
||||
button ??= GetComponent<Button>();
|
||||
labelText ??= GetComponentInChildren<TMP_Text>(true);
|
||||
|
||||
if (labelText != null)
|
||||
labelText.text = label ?? string.Empty;
|
||||
|
||||
if (button == null)
|
||||
{
|
||||
Debug.LogWarning("[SelectionBoxButton] 选项预制体缺少 Button 组件。");
|
||||
return;
|
||||
}
|
||||
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(() => onSelected?.Invoke());
|
||||
button.interactable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/UI/Elements/SelectionBoxButton.cs.meta
Normal file
2
Assets/Scripts/UI/Elements/SelectionBoxButton.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2c45ae5390d4a4aa16e62b5704dbf0a
|
||||
73
Assets/Scripts/UI/Elements/SelectionBoxUIPage.cs
Normal file
73
Assets/Scripts/UI/Elements/SelectionBoxUIPage.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Generic;
|
||||
using Ichni.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Menu.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用 SelectionBox 的页面宿主,职责等同于 MessageBox 的页面容器:
|
||||
/// 显示时动态生成一个 SelectionBox,选择完成后统一淡出并销毁本次实例。
|
||||
/// </summary>
|
||||
public class SelectionBoxUIPage : UIPageBase
|
||||
{
|
||||
[Header("Selection Box Page")]
|
||||
public SelectionBox selectionBoxPrefab;
|
||||
public RectTransform selectionBoxContainer;
|
||||
|
||||
private SelectionBox _currentSelectionBox;
|
||||
private bool _isShowing;
|
||||
|
||||
/// <summary>当前是否已有一个 SelectionBox 等待玩家选择。</summary>
|
||||
public bool IsShowing => _isShowing;
|
||||
|
||||
/// <summary>
|
||||
/// 生成并显示一组运行时选项。页面显示期间拒绝重复请求,避免两个弹窗争夺输入。
|
||||
/// </summary>
|
||||
public bool Show(string title, string content, IReadOnlyList<SelectionBoxOption> options)
|
||||
{
|
||||
if (_isShowing)
|
||||
{
|
||||
Debug.LogWarning("[SelectionBoxUIPage] 已有 SelectionBox 显示中,忽略重复请求。");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mainCanvasGroup == null || selectionBoxPrefab == null || selectionBoxContainer == null)
|
||||
{
|
||||
Debug.LogWarning("[SelectionBoxUIPage] 缺少 CanvasGroup、SelectionBox Prefab 或容器,无法显示选项框。");
|
||||
return false;
|
||||
}
|
||||
|
||||
_currentSelectionBox = Instantiate(selectionBoxPrefab, selectionBoxContainer);
|
||||
_currentSelectionBox.Closed += HandleSelectionBoxClosed;
|
||||
if (!_currentSelectionBox.SetUp(title, content, options))
|
||||
{
|
||||
Destroy(_currentSelectionBox.gameObject);
|
||||
_currentSelectionBox = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
_isShowing = true;
|
||||
|
||||
// UIPageBase 默认淡入完成后才开启射线;选择框从出现瞬间就必须阻断底层输入。
|
||||
mainCanvasGroup.gameObject.SetActive(true);
|
||||
mainCanvasGroup.interactable = true;
|
||||
mainCanvasGroup.blocksRaycasts = true;
|
||||
FadeIn();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void HandleSelectionBoxClosed(SelectionBox selectionBox)
|
||||
{
|
||||
if (selectionBox != _currentSelectionBox) return;
|
||||
|
||||
FadeOut(0.2f, false, () =>
|
||||
{
|
||||
if (_currentSelectionBox != null)
|
||||
Destroy(_currentSelectionBox.gameObject);
|
||||
|
||||
_currentSelectionBox = null;
|
||||
_isShowing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/UI/Elements/SelectionBoxUIPage.cs.meta
Normal file
2
Assets/Scripts/UI/Elements/SelectionBoxUIPage.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8b607f8c5654e1eb3bf1f7ca33fd3a4
|
||||
@@ -3,6 +3,7 @@ using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DG.Tweening;
|
||||
using Ichni.RhythmGame;
|
||||
using Sirenix.OdinInspector;
|
||||
using SLSUtilities.WwiseAssistance;
|
||||
using TMPro;
|
||||
@@ -53,11 +54,21 @@ namespace Ichni.Menu
|
||||
{
|
||||
MenuManager.instance.songSelectionUIPage.selectedDifficulty = difficultyData;
|
||||
MenuManager.instance.songSelectionUIPage.songInfoUI.SetCharter(difficultyData.charterName);
|
||||
MenuManager.instance.songSelectionUIPage.songInfoUI.SetBeatmapInfo(
|
||||
MenuManager.instance.songSelectionUIPage.selectedSave.beatmapSaves[difficultyData.difficultyIndex]);
|
||||
if (MenuManager.instance.songSelectionUIPage.selectedSave.TryGetBeatmapSave(
|
||||
difficultyData.saveDifficultyId, out BeatmapSave beatmapSave))
|
||||
{
|
||||
MenuManager.instance.songSelectionUIPage.songInfoUI.SetBeatmapInfo(beatmapSave);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 当前内容尚未创建该难度的记录时,UI 只显示空成绩,不在展示路径修改存档。
|
||||
MenuManager.instance.songSelectionUIPage.songInfoUI.SetBeatmapInfo(new BeatmapSave(0f, false, false));
|
||||
}
|
||||
|
||||
MenuInformationRecorder.instance.songSelectionRecords[ChapterSelectionManager.instance.currentChapter] =
|
||||
new SongSelectionRecord(MenuManager.instance.songSelectionUIPage.selectedSong, difficultyData);
|
||||
MenuInformationRecorder.instance.SetRecordForChapter(
|
||||
ChapterSelectionManager.instance.currentChapter,
|
||||
MenuManager.instance.songSelectionUIPage.selectedSong,
|
||||
MenuManager.instance.songSelectionUIPage.selectedSong.difficultyDataList.IndexOf(difficultyData));
|
||||
|
||||
if (container.selectedButton == this)
|
||||
{
|
||||
@@ -67,7 +78,6 @@ namespace Ichni.Menu
|
||||
|
||||
MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.selectedButton?.Deselect();
|
||||
MenuManager.instance.songSelectionUIPage.difficultySelectionContainer.selectedButton = this;
|
||||
MenuInformationRecorder.instance.GetRecordOfThisChapter().difficultyIndex = difficultyData.difficultyIndex;
|
||||
|
||||
AudioManager.Post(AK.EVENTS.SELECTDIFFICULTY);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ichni.Menu
|
||||
@@ -9,28 +9,123 @@ namespace Ichni.Menu
|
||||
public List<DifficultySelectionButton> buttons;
|
||||
public DifficultySelectionButton selectedButton;
|
||||
|
||||
/// <summary>
|
||||
/// 在当前歌曲中解析一个可实际游玩的难度。
|
||||
/// <para>preferredListIndex 是 <see cref="SongSelectionRecord.difficultyListIndex"/>,即难度在当前歌曲
|
||||
/// List 中的位置;它不能替代 <see cref="DifficultyData.saveDifficultyId"/>,后者是游戏记录的稳定 ID。</para>
|
||||
/// <para>规则:只考虑已配置 Button 且 <c>isAvailable</c> 为 true 的难度,优先选择与目标位置距离最小者;
|
||||
/// 距离相同则选择位置较低者。因此目标难度不存在时,玩家会落到最接近、且偏低的可用难度。</para>
|
||||
/// </summary>
|
||||
private bool TryResolveAvailableDifficultyIndex(
|
||||
IReadOnlyList<DifficultyData> difficulties,
|
||||
int preferredListIndex,
|
||||
out int resolvedListIndex)
|
||||
{
|
||||
resolvedListIndex = -1;
|
||||
if (difficulties == null || buttons == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int candidateCount = Mathf.Min(difficulties.Count, buttons.Count);
|
||||
long bestDistance = long.MaxValue;
|
||||
|
||||
for (int index = 0; index < candidateCount; index++)
|
||||
{
|
||||
DifficultyData difficulty = difficulties[index];
|
||||
if (difficulty == null || !difficulty.isAvailable || buttons[index] == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long distance = Math.Abs((long)index - preferredListIndex);
|
||||
if (distance < bestDistance || (distance == bestDistance && index < resolvedListIndex))
|
||||
{
|
||||
bestDistance = distance;
|
||||
resolvedListIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedListIndex >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除当前的难度选择,并同时关闭标准的进入游戏按钮。
|
||||
/// SongSelectionTab 的快速进入入口也会单独检查 <c>selectedDifficulty</c>,避免绕过此 UI 状态。
|
||||
/// </summary>
|
||||
private void ClearDifficultySelection()
|
||||
{
|
||||
selectedButton?.Deselect();
|
||||
selectedButton = null;
|
||||
|
||||
SongSelectionUIPage songSelectionUIPage = MenuManager.instance.songSelectionUIPage;
|
||||
songSelectionUIPage.selectedDifficulty = null;
|
||||
songSelectionUIPage.playSongUI.SetEnterGameAvailable(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前歌曲刷新难度按钮,并将章节内存缓存解析为一个实际可玩的难度。
|
||||
/// 该方法用于首次进入、从游戏返回以及拖动切歌,所以所有自动难度回退都必须经过此处。
|
||||
/// </summary>
|
||||
public void SetUp(List<DifficultyData> difficulties)
|
||||
{
|
||||
int difficultyCount = difficulties.Count;
|
||||
int difficultyCount = difficulties?.Count ?? 0;
|
||||
int buttonCount = buttons?.Count ?? 0;
|
||||
|
||||
for (var i = 0; i < buttons.Count; i++)
|
||||
ClearDifficultySelection();
|
||||
|
||||
if (difficultyCount > buttonCount)
|
||||
{
|
||||
buttons[i].gameObject.SetActive(i < difficultyCount);
|
||||
Debug.LogError($"Song '{MenuManager.instance.songSelectionUIPage.selectedSong?.songName}' has {difficultyCount} difficulties but only {buttonCount} difficulty buttons. " +
|
||||
"Only difficulties with configured buttons can be selected.");
|
||||
}
|
||||
|
||||
for (int i = 0; i < difficultyCount; i++)
|
||||
for (int i = 0; i < buttonCount; i++)
|
||||
{
|
||||
buttons[i].SetUp(difficulties[i]);
|
||||
DifficultySelectionButton button = buttons[i];
|
||||
if (button == null)
|
||||
{
|
||||
Debug.LogError($"Difficulty button at index {i} is missing.");
|
||||
continue;
|
||||
}
|
||||
|
||||
SongSelectionRecord songSelectionRecord = MenuInformationRecorder.instance.GetRecordOfThisChapter();
|
||||
|
||||
if (songSelectionRecord.difficultyIndex >= difficultyCount)
|
||||
bool hasDifficulty = i < difficultyCount && difficulties[i] != null;
|
||||
button.gameObject.SetActive(hasDifficulty);
|
||||
if (hasDifficulty)
|
||||
{
|
||||
songSelectionRecord.difficultyIndex = difficultyCount - 1;
|
||||
// SetUp 会进一步按 isAvailable 隐藏不可用难度。
|
||||
button.SetUp(difficulties[i]);
|
||||
}
|
||||
}
|
||||
|
||||
buttons[songSelectionRecord.difficultyIndex % buttons.Count].Select();
|
||||
if (difficultyCount == 0)
|
||||
{
|
||||
Debug.LogWarning($"Song '{MenuManager.instance.songSelectionUIPage.selectedSong?.songName}' has no configured difficulties.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!MenuInformationRecorder.instance.TryGetRecordOfThisChapter(out SongSelectionRecord songSelectionRecord))
|
||||
{
|
||||
Debug.LogWarning("Cannot select a difficulty because the current chapter has no valid song selection record.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveAvailableDifficultyIndex(difficulties, songSelectionRecord.difficultyListIndex, out int resolvedListIndex))
|
||||
{
|
||||
Debug.LogWarning($"Song '{MenuManager.instance.songSelectionUIPage.selectedSong?.songName}' has no available difficulty to select.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Select 会把实际选中的难度写回章节缓存;之后继续切歌时,以这次真实选择作为新的偏好。
|
||||
buttons[resolvedListIndex].Select();
|
||||
|
||||
// 难度可用不等于内容已解锁。保持按钮状态与统一授权服务一致,
|
||||
// 而 PlaySongUI / 快速点击仍会在真正进入前再次检查,形成双层防御。
|
||||
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
|
||||
bool canEnterSong = unlockSaveModule != null && unlockSaveModule.CanEnterSong(
|
||||
ChapterSelectionManager.instance?.currentChapter,
|
||||
MenuManager.instance.songSelectionUIPage.selectedSong);
|
||||
MenuManager.instance.songSelectionUIPage.playSongUI.SetEnterGameAvailable(canEnterSong);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,38 @@ namespace Ichni.Menu
|
||||
enterGameButton.onClick.AddListener(EnterGame);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置标准 Play 按钮是否允许进入游戏。
|
||||
/// 当当前歌曲没有可用难度时由 DifficultySelectionContainer 关闭;歌曲锁定仍沿用原有的独立判断。
|
||||
/// </summary>
|
||||
public void SetEnterGameAvailable(bool isAvailable)
|
||||
{
|
||||
enterGameButton.interactable = isAvailable;
|
||||
}
|
||||
|
||||
private void EnterGame()
|
||||
{
|
||||
if (MenuManager.instance.songSelectionUIPage.songListController.selectedTab.isLocked)
|
||||
SongSelectionUIPage songSelectionUIPage = MenuManager.instance.songSelectionUIPage;
|
||||
SongSelectionTab selectedTab = songSelectionUIPage.songListController.selectedTab;
|
||||
|
||||
// 即使 Button 的 interactable 状态被错误地恢复,也不能把空难度传给 InformationTransistor。
|
||||
if (songSelectionUIPage.selectedSong == null || songSelectionUIPage.selectedDifficulty == null || selectedTab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 不能只读取 Tab 的旧显示状态。解锁 Key 可能刚被剧情授予,或 UI 状态被其它逻辑改写;
|
||||
// 标准 Play 与快速点击都会回到 UnlockSaveModule 的同一最终授权判断。
|
||||
UnlockSaveModule unlockSaveModule = GameSaveManager.instance?.UnlockSaveModule;
|
||||
if (unlockSaveModule == null || !unlockSaveModule.CanEnterSong(
|
||||
ChapterSelectionManager.instance?.currentChapter,
|
||||
songSelectionUIPage.selectedSong))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTab.RefreshUnlockState();
|
||||
|
||||
if (MenuManager.instance.isEnteringGame)
|
||||
{
|
||||
return;
|
||||
@@ -35,8 +60,8 @@ namespace Ichni.Menu
|
||||
|
||||
InformationTransistor.instance.SetInformation(
|
||||
ChapterSelectionManager.instance.currentChapter,
|
||||
MenuManager.instance.songSelectionUIPage.selectedSong,
|
||||
MenuManager.instance.songSelectionUIPage.selectedDifficulty);
|
||||
songSelectionUIPage.selectedSong,
|
||||
songSelectionUIPage.selectedDifficulty);
|
||||
AudioManager.Post(AK.EVENTS.ENTERTOGAME);
|
||||
SongSelectionManager.instance.StopPreviewSong();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user