프로젝트 소스 공개
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &-8186968272690591729
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 3eb4b772797da9440885e8bd939e9560, type: 3}
|
||||
m_Name: ColorCurves
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
master:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 2
|
||||
m_Loop: 0
|
||||
m_ZeroValue: 0
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
red:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 2
|
||||
m_Loop: 0
|
||||
m_ZeroValue: 0
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
green:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 2
|
||||
m_Loop: 0
|
||||
m_ZeroValue: 0
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
blue:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 2
|
||||
m_Loop: 0
|
||||
m_ZeroValue: 0
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 1
|
||||
outSlope: 1
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
hueVsHue:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 0
|
||||
m_Loop: 1
|
||||
m_ZeroValue: 0.5
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve: []
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
hueVsSat:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 0
|
||||
m_Loop: 1
|
||||
m_ZeroValue: 0.5
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve: []
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
satVsSat:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 0
|
||||
m_Loop: 0
|
||||
m_ZeroValue: 0.5
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve: []
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
lumVsSat:
|
||||
m_OverrideState: 1
|
||||
m_Value:
|
||||
<length>k__BackingField: 0
|
||||
m_Loop: 0
|
||||
m_ZeroValue: 0.5
|
||||
m_Range: 1
|
||||
m_Curve:
|
||||
serializedVersion: 2
|
||||
m_Curve: []
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
--- !u!114 &-7645455725216806467
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 29fa0085f50d5e54f8144f766051a691, type: 3}
|
||||
m_Name: FilmGrain
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
type:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
intensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
response:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.8
|
||||
texture:
|
||||
m_OverrideState: 1
|
||||
m_Value: {fileID: 0}
|
||||
--- !u!114 &-6951754464447674219
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 558a8e2b6826cf840aae193990ba9f2e, type: 3}
|
||||
m_Name: ShadowsMidtonesHighlights
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
shadows:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 1, y: 1, z: 1, w: 0}
|
||||
midtones:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 1, y: 1, z: 1, w: 0}
|
||||
highlights:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 1, y: 1, z: 1, w: 0}
|
||||
shadowsStart:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
shadowsEnd:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.3
|
||||
highlightsStart:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.55
|
||||
highlightsEnd:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
--- !u!114 &-4210620904635426390
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: cdfbdbb87d3286943a057f7791b43141, type: 3}
|
||||
m_Name: ChannelMixer
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
redOutRedIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 100
|
||||
redOutGreenIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
redOutBlueIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
greenOutRedIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
greenOutGreenIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 100
|
||||
greenOutBlueIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
blueOutRedIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
blueOutGreenIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
blueOutBlueIn:
|
||||
m_OverrideState: 1
|
||||
m_Value: 100
|
||||
--- !u!114 &-3978695733392391637
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 0b2db86121404754db890f4c8dfe81b2, type: 3}
|
||||
m_Name: Bloom
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
skipIterations:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
threshold:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.9
|
||||
intensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
scatter:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.7
|
||||
clamp:
|
||||
m_OverrideState: 1
|
||||
m_Value: 65472
|
||||
tint:
|
||||
m_OverrideState: 1
|
||||
m_Value: {r: 1, g: 1, b: 1, a: 1}
|
||||
highQualityFiltering:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
filter:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
downscale:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
maxIterations:
|
||||
m_OverrideState: 1
|
||||
m_Value: 6
|
||||
dirtTexture:
|
||||
m_OverrideState: 1
|
||||
m_Value: {fileID: 0}
|
||||
dimension: 1
|
||||
dirtIntensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &-3757877592200609216
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 70afe9e12c7a7ed47911bb608a23a8ff, type: 3}
|
||||
m_Name: SplitToning
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
shadows:
|
||||
m_OverrideState: 1
|
||||
m_Value: {r: 0.5, g: 0.5, b: 0.5, a: 1}
|
||||
highlights:
|
||||
m_OverrideState: 1
|
||||
m_Value: {r: 0.5, g: 0.5, b: 0.5, a: 1}
|
||||
balance:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &-3386560400091697801
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: c01700fd266d6914ababb731e09af2eb, type: 3}
|
||||
m_Name: DepthOfField
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
mode:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
gaussianStart:
|
||||
m_OverrideState: 1
|
||||
m_Value: 10
|
||||
gaussianEnd:
|
||||
m_OverrideState: 1
|
||||
m_Value: 30
|
||||
gaussianMaxRadius:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
highQualitySampling:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
focusDistance:
|
||||
m_OverrideState: 1
|
||||
m_Value: 10
|
||||
aperture:
|
||||
m_OverrideState: 1
|
||||
m_Value: 5.6
|
||||
focalLength:
|
||||
m_OverrideState: 1
|
||||
m_Value: 50
|
||||
bladeCount:
|
||||
m_OverrideState: 1
|
||||
m_Value: 5
|
||||
bladeCurvature:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
bladeRotation:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &-1083910812058648046
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 6bd486065ce11414fa40e631affc4900, type: 3}
|
||||
m_Name: ProbeVolumesOptions
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
normalBias:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.05
|
||||
viewBias:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.1
|
||||
scaleBiasWithMinProbeDistance:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
samplingNoise:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.1
|
||||
animateSamplingNoise:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
leakReductionMode:
|
||||
m_OverrideState: 1
|
||||
m_Value: 2
|
||||
minValidDotProductValue:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.1
|
||||
occlusionOnlyReflectionNormalization:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
intensityMultiplier:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
skyOcclusionIntensityMultiplier:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
worldOffset:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &-277880470225475626
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: ccf1aba9553839d41ae37dd52e9ebcce, type: 3}
|
||||
m_Name: MotionBlur
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
mode:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
quality:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
intensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
clamp:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.05
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d7fd9488000d3734a9e00ee676215985, type: 3}
|
||||
m_Name: DefaultVolumeProfile
|
||||
m_EditorClassIdentifier:
|
||||
components:
|
||||
- {fileID: 1247868777620691934}
|
||||
- {fileID: -277880470225475626}
|
||||
- {fileID: 2338690907228448291}
|
||||
- {fileID: 2335868966179733374}
|
||||
- {fileID: 3460444317416099510}
|
||||
- {fileID: 7933203426823181213}
|
||||
- {fileID: 5762530381802164056}
|
||||
- {fileID: -3386560400091697801}
|
||||
- {fileID: 156332030701414761}
|
||||
- {fileID: 9099548931283147545}
|
||||
- {fileID: -3757877592200609216}
|
||||
- {fileID: 2332282131777942337}
|
||||
- {fileID: -4210620904635426390}
|
||||
- {fileID: -6951754464447674219}
|
||||
- {fileID: 8549655192814258499}
|
||||
- {fileID: -7645455725216806467}
|
||||
- {fileID: -8186968272690591729}
|
||||
- {fileID: -3978695733392391637}
|
||||
- {fileID: -1083910812058648046}
|
||||
--- !u!114 &156332030701414761
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5485954d14dfb9a4c8ead8edb0ded5b1, type: 3}
|
||||
m_Name: LiftGammaGain
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
lift:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 1, y: 1, z: 1, w: 0}
|
||||
gamma:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 1, y: 1, z: 1, w: 0}
|
||||
gain:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 1, y: 1, z: 1, w: 0}
|
||||
--- !u!114 &1247868777620691934
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 97c23e3b12dc18c42a140437e53d3951, type: 3}
|
||||
m_Name: Tonemapping
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
mode:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
neutralHDRRangeReductionMode:
|
||||
m_OverrideState: 1
|
||||
m_Value: 2
|
||||
acesPreset:
|
||||
m_OverrideState: 1
|
||||
m_Value: 3
|
||||
hueShiftAmount:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
detectPaperWhite:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
paperWhite:
|
||||
m_OverrideState: 1
|
||||
m_Value: 300
|
||||
detectBrightnessLimits:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
minNits:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.005
|
||||
maxNits:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1000
|
||||
--- !u!114 &2332282131777942337
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e021b4c809a781e468c2988c016ebbea, type: 3}
|
||||
m_Name: ColorLookup
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
texture:
|
||||
m_OverrideState: 1
|
||||
m_Value: {fileID: 0}
|
||||
dimension: 1
|
||||
contribution:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &2335868966179733374
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: c5e1dc532bcb41949b58bc4f2abfbb7e, type: 3}
|
||||
m_Name: LensDistortion
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
intensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
xMultiplier:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
yMultiplier:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
center:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 0.5, y: 0.5}
|
||||
scale:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
--- !u!114 &2338690907228448291
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 221518ef91623a7438a71fef23660601, type: 3}
|
||||
m_Name: WhiteBalance
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
temperature:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
tint:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &3460444317416099510
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 81180773991d8724ab7f2d216912b564, type: 3}
|
||||
m_Name: ChromaticAberration
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
intensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &5762530381802164056
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 06437c1ff663d574d9447842ba0a72e4, type: 3}
|
||||
m_Name: ScreenSpaceLensFlare
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
intensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
tintColor:
|
||||
m_OverrideState: 1
|
||||
m_Value: {r: 1, g: 1, b: 1, a: 1}
|
||||
bloomMip:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
firstFlareIntensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
secondaryFlareIntensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
warpedFlareIntensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
warpedFlareScale:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 1, y: 1}
|
||||
samples:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
sampleDimmer:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.5
|
||||
vignetteEffect:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
startingPosition:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1.25
|
||||
scale:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1.5
|
||||
streaksIntensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
streaksLength:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.5
|
||||
streaksOrientation:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
streaksThreshold:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.25
|
||||
resolution:
|
||||
m_OverrideState: 1
|
||||
m_Value: 4
|
||||
chromaticAbberationIntensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.5
|
||||
--- !u!114 &7933203426823181213
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 66f335fb1ffd8684294ad653bf1c7564, type: 3}
|
||||
m_Name: ColorAdjustments
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
postExposure:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
contrast:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
colorFilter:
|
||||
m_OverrideState: 1
|
||||
m_Value: {r: 1, g: 1, b: 1, a: 1}
|
||||
hueShift:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
saturation:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &8549655192814258499
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 899c54efeace73346a0a16faa3afe726, type: 3}
|
||||
m_Name: Vignette
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
color:
|
||||
m_OverrideState: 1
|
||||
m_Value: {r: 0, g: 0, b: 0, a: 1}
|
||||
center:
|
||||
m_OverrideState: 1
|
||||
m_Value: {x: 0.5, y: 0.5}
|
||||
intensity:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
smoothness:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0.2
|
||||
rounded:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
--- !u!114 &9099548931283147545
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 3
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fb60a22f311433c4c962b888d1393f88, type: 3}
|
||||
m_Name: PaniniProjection
|
||||
m_EditorClassIdentifier:
|
||||
active: 1
|
||||
distance:
|
||||
m_OverrideState: 1
|
||||
m_Value: 0
|
||||
cropToFit:
|
||||
m_OverrideState: 1
|
||||
m_Value: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f9215ea0144899419cfbc0957140d3f
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2bcd2660ca9b64942af0de543d8d7100
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3}
|
||||
generateWrapperCode: 0
|
||||
wrapperCodePath:
|
||||
wrapperClassName:
|
||||
wrapperCodeNamespace:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2520ca8182cfd49cabc3a8e7a80706b6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2cac0cde16bcd41db91aae47d021c3cf
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 501e3597be9954100bdbb5e7f8a574e2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cfff49ae6cc144318ba88c227fc3e88b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,22 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d32cf7c32a3ed8347bac48ef5ed56d82, type: 3}
|
||||
m_Name: FMODStudioCache
|
||||
m_EditorClassIdentifier: FMODUnityEditor::FMODUnity.EventCache
|
||||
EditorBanks: []
|
||||
EditorEvents: []
|
||||
EditorParameters: []
|
||||
MasterBanks: []
|
||||
StringsBanks: []
|
||||
cacheVersion: 131860
|
||||
cacheTime: 0
|
||||
SerializableEventsDict: []
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b688e6cf157394eb994baa18eb318210
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 10238152fc3374e21808394d5539c5e9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,956 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &-8876659224941116795
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d92d0baf34dae0e4ea032a42bd6107c0, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformMac
|
||||
identifier: 52eb9df5db46521439908db3a29a1bbb
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &-6797349823870411825
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 97ba6cc2660c0ca498540d254701057a, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformAndroid
|
||||
identifier: 2fea114e74ecf3c4f920e1d5cc1c4c40
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &-3793632861967256216
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bc1f51bc35d549941904cf062bae93a3, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformIOS
|
||||
identifier: 0f8eb3f400726694eb47beb1a9f94ce8
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &-2900218176781211718
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 17eea195bdfbf014e91ba7620ee491f8, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformAppleTV
|
||||
identifier: e7a046c753c3c3d4aacc91f6597f310d
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &-2541522425307482493
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 335f0a4b26fb46942858ea029e030d2a, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformPlayInEditor
|
||||
identifier: playInEditor
|
||||
parentIdentifier:
|
||||
active: 1
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 1
|
||||
HasValue: 1
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 1
|
||||
HasValue: 1
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 48000
|
||||
HasValue: 1
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 1024
|
||||
HasValue: 1
|
||||
RealChannelCount:
|
||||
Value: 256
|
||||
HasValue: 1
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &-2094696130006404903
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 73549a74f689f0849a8271d9e908c514, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformLinux
|
||||
identifier: b7716510a1f36934c87976f3a81dbf3d
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: eef8d824ea7b63742966aaa0e94ac383, type: 3}
|
||||
m_Name: FMODStudioSettings
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.Settings
|
||||
switchSettingsMigration: 1
|
||||
HasSourceProject: 1
|
||||
HasPlatforms: 1
|
||||
sourceProjectPath:
|
||||
sourceBankPath:
|
||||
sourceBankPathUnformatted:
|
||||
BankRefreshCooldown: 5
|
||||
ShowBankRefreshWindow: 1
|
||||
AutomaticEventLoading: 1
|
||||
BankLoadType: 0
|
||||
AutomaticSampleLoading: 0
|
||||
EncryptionKey:
|
||||
ImportType: 0
|
||||
TargetAssetPath: FMODBanks
|
||||
TargetBankFolder:
|
||||
EventLinkage: 0
|
||||
SerializeGUIDsOnly: 0
|
||||
LoggingLevel: 2
|
||||
SpeakerModeSettings: []
|
||||
SampleRateSettings: []
|
||||
LiveUpdateSettings: []
|
||||
OverlaySettings: []
|
||||
BankDirectorySettings: []
|
||||
VirtualChannelSettings: []
|
||||
RealChannelSettings: []
|
||||
Plugins: []
|
||||
MasterBanks: []
|
||||
Banks: []
|
||||
BanksToLoad: []
|
||||
LiveUpdatePort: 9264
|
||||
EnableMemoryTracking: 0
|
||||
AndroidUseOBB: 0
|
||||
AndroidPatchBuild: 0
|
||||
MeterChannelOrdering: 0
|
||||
StopEventsOutsideMaxDistance: 0
|
||||
BoltUnitOptionsBuildPending: 0
|
||||
EnableErrorCallback: 0
|
||||
SharedLibraryUpdateStage: 0
|
||||
SharedLibraryTimeSinceStart: 0
|
||||
CurrentVersion: 131860
|
||||
HideSetupWizard: 0
|
||||
LastEventReferenceScanVersion: 131860
|
||||
Platforms:
|
||||
- {fileID: -2094696130006404903}
|
||||
- {fileID: 6584450883818306987}
|
||||
- {fileID: 3944497114675524469}
|
||||
- {fileID: -3793632861967256216}
|
||||
- {fileID: -8876659224941116795}
|
||||
- {fileID: 4116145689230507682}
|
||||
- {fileID: -2900218176781211718}
|
||||
- {fileID: 2006094704713410788}
|
||||
- {fileID: 3754044641852712671}
|
||||
- {fileID: -6797349823870411825}
|
||||
- {fileID: 5131407680144949201}
|
||||
- {fileID: -2541522425307482493}
|
||||
MigratedPlatforms: 0a00000000000000150000000b00000009000000050000001200000008000000060000000c0000000200000001000000
|
||||
--- !u!114 &2006094704713410788
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 9660e62d6232af242877f0cc2b90c63d, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformWindows
|
||||
identifier: 2c5177b11d81d824dbb064f9ac8527da
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &3754044641852712671
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 3abeb1429547a134480f4f2f1efc7e21, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformMobileLow
|
||||
identifier: c88d16e5272a4e241b0ef0ac2e53b73d
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &3944497114675524469
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: b87314b32fbe18943af496e4b47136c6, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformWebGL
|
||||
identifier: 46fbfdf3fc43db0458918377fd40293e
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &4116145689230507682
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 93a382382f106584e8f8f62412fee177, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformMobileHigh
|
||||
identifier: fd7c55dab0fce234b8c25f6ffca523c1
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &5131407680144949201
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 9587379c972e4a54da1949613f11e1c6, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformDefault
|
||||
identifier: default
|
||||
parentIdentifier:
|
||||
active: 1
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 9264
|
||||
HasValue: 1
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 1
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 1
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers:
|
||||
- b7716510a1f36934c87976f3a81dbf3d
|
||||
- de700ef3f37a49b58a57ae3addf01ad9
|
||||
- 46fbfdf3fc43db0458918377fd40293e
|
||||
- 0f8eb3f400726694eb47beb1a9f94ce8
|
||||
- 52eb9df5db46521439908db3a29a1bbb
|
||||
- fd7c55dab0fce234b8c25f6ffca523c1
|
||||
- e7a046c753c3c3d4aacc91f6597f310d
|
||||
- 2c5177b11d81d824dbb064f9ac8527da
|
||||
- c88d16e5272a4e241b0ef0ac2e53b73d
|
||||
- 2fea114e74ecf3c4f920e1d5cc1c4c40
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
--- !u!114 &6584450883818306987
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 8f591a193113347409efedcc618e83d6, type: 3}
|
||||
m_Name: FMODStudioSettingsPlatform
|
||||
m_EditorClassIdentifier: FMODUnity::FMODUnity.PlatformVisionOS
|
||||
identifier: de700ef3f37a49b58a57ae3addf01ad9
|
||||
parentIdentifier: default
|
||||
active: 0
|
||||
Properties:
|
||||
LiveUpdate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
LiveUpdatePort:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Overlay:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayPosition:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
OverlayFontSize:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Logging:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
SampleRate:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
BuildDirectory:
|
||||
Value:
|
||||
HasValue: 0
|
||||
SpeakerMode:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
VirtualChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
RealChannelCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferLength:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
DSPBufferCount:
|
||||
Value: 0
|
||||
HasValue: 0
|
||||
Plugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
StaticPlugins:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
CallbackHandler:
|
||||
Value: {fileID: 0}
|
||||
HasValue: 0
|
||||
OutputTypeName:
|
||||
threadAffinities:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
displaySortOrder: 0
|
||||
childIdentifiers: []
|
||||
codecChannels:
|
||||
Value: []
|
||||
HasValue: 0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a51d0bf46d0fd4e0a8224569e29b7cb3
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b9fcfc305c32f47c6894393884afbe27
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbee217e0a68d43ec9fcf523aa9b915d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc3b5549db61d4e829d9608adfb38452
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,143 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa998a7af36b743fdb4ab07c8a19741a
|
||||
TextureImporter:
|
||||
internalIDToNameTable:
|
||||
- first:
|
||||
213: -4050758962521307463
|
||||
second: ProjectQuintAppIcon_0
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 2
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites:
|
||||
- serializedVersion: 2
|
||||
name: ProjectQuintAppIcon_0
|
||||
rect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
y: 0
|
||||
width: 1024
|
||||
height: 1024
|
||||
alignment: 0
|
||||
pivot: {x: 0, y: 0}
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
customData:
|
||||
outline: []
|
||||
physicsShape: []
|
||||
tessellationDetail: -1
|
||||
bones: []
|
||||
spriteID: 9b600387f20d8c7c0800000000000000
|
||||
internalID: -4050758962521307463
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable:
|
||||
ProjectQuintAppIcon_0: -4050758962521307463
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39aa599a9465344fd9bbf8714fe9e66d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,90 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using ProjectQuint.Library;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectQuint.Editor
|
||||
{
|
||||
public static class ChartFeatureCalibrationReport
|
||||
{
|
||||
private const string PackPathVariable = "PROJECTQUINT_FEATURE_PACK_PATH";
|
||||
private const string ReportPathVariable = "PROJECTQUINT_FEATURE_REPORT_PATH";
|
||||
private const string CachePathVariable = "PROJECTQUINT_FEATURE_CACHE_PATH";
|
||||
|
||||
public static void Generate()
|
||||
{
|
||||
var packPath = Environment.GetEnvironmentVariable(PackPathVariable);
|
||||
var reportPath = Environment.GetEnvironmentVariable(ReportPathVariable);
|
||||
var cachePath = Environment.GetEnvironmentVariable(CachePathVariable);
|
||||
if (string.IsNullOrWhiteSpace(packPath) || !Directory.Exists(packPath))
|
||||
throw new DirectoryNotFoundException($"Set {PackPathVariable} to a BMS pack folder.");
|
||||
if (string.IsNullOrWhiteSpace(reportPath))
|
||||
throw new InvalidOperationException($"Set {ReportPathVariable} to the output TSV path.");
|
||||
if (string.IsNullOrWhiteSpace(cachePath))
|
||||
throw new InvalidOperationException($"Set {CachePathVariable} to a disposable index cache path.");
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var scanner = new SongLibraryScanner(cachePath, packId: "feature-calibration");
|
||||
var songs = scanner.Scan(Path.GetFullPath(packPath), CancellationToken.None);
|
||||
stopwatch.Stop();
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(
|
||||
"chart_path\tmd5\tkey_count\thas_scratch\tdifficulty\tnotes_raw\tpeak_raw\tchord_raw\tlong_raw\tsoflan_raw\ttech_raw\tnotes\tpeak\tchord\tlong\tsoflan\ttech\tanomalies\terror");
|
||||
foreach (var song in songs)
|
||||
{
|
||||
var feature = song.Features;
|
||||
builder.Append(Escape(song.ChartPath)).Append('\t')
|
||||
.Append(Escape(song.Metadata?.Md5)).Append('\t')
|
||||
.Append(song.Metadata?.KeyCount ?? 0).Append('\t')
|
||||
.Append(song.Metadata?.HasScratch == true ? 1 : 0).Append('\t')
|
||||
.Append(Escape(song.DifficultyLabel)).Append('\t');
|
||||
if (feature != null)
|
||||
{
|
||||
Append(builder, feature.NotesRaw).Append('\t');
|
||||
Append(builder, feature.PeakRaw).Append('\t');
|
||||
Append(builder, feature.ChordRaw).Append('\t');
|
||||
Append(builder, feature.LongRaw).Append('\t');
|
||||
Append(builder, feature.SofLanRaw).Append('\t');
|
||||
Append(builder, feature.TechRaw).Append('\t');
|
||||
Append(builder, feature.Notes).Append('\t');
|
||||
Append(builder, feature.Peak).Append('\t');
|
||||
Append(builder, feature.Chord).Append('\t');
|
||||
Append(builder, feature.Long).Append('\t');
|
||||
Append(builder, feature.SofLan).Append('\t');
|
||||
Append(builder, feature.Tech).Append('\t');
|
||||
builder.Append(feature.AnomalyCount).Append('\t');
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append("\t\t\t\t\t\t\t\t\t\t\t\t\t");
|
||||
}
|
||||
builder.AppendLine(Escape(song.FeatureAnalysisError));
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(reportPath));
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(reportPath, builder.ToString(), new UTF8Encoding(false));
|
||||
|
||||
var available = songs.Count(song => song.Features != null);
|
||||
UnityEngine.Debug.Log(
|
||||
$"[ProjectQuint Features] {available:N0}/{songs.Count:N0} chart profiles written to {reportPath} " +
|
||||
$"in {stopwatch.Elapsed.TotalSeconds:0.00}s; cache {scanner.CacheHits:N0} hits / " +
|
||||
$"{scanner.CacheMisses:N0} refreshes; failures {scanner.Failed:N0}.");
|
||||
}
|
||||
|
||||
private static StringBuilder Append(StringBuilder builder, double value) =>
|
||||
builder.Append(value.ToString("0.######", CultureInfo.InvariantCulture));
|
||||
|
||||
private static string Escape(string value) =>
|
||||
(value ?? string.Empty).Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ');
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 608820f0ced354ca8984048c7444040b
|
||||
@@ -0,0 +1,758 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Xml;
|
||||
using ProjectQuint.Core.Release;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Callbacks;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectQuint.Editor
|
||||
{
|
||||
public static class ProjectQuintBuild
|
||||
{
|
||||
private const string BuildPathEnvironmentVariable = "PROJECTQUINT_BUILD_PATH";
|
||||
private const string CompanyName = "Team Denia";
|
||||
private const string ProductName = "ProjectQuint";
|
||||
private const string BundleIdentifier = "com.teamdenia.projectquint";
|
||||
private const string Copyright = "© 2026 Team Denia";
|
||||
private const string CopyrightXml = "<string>© 2026 Team Denia</string>";
|
||||
|
||||
public static void BuildMacStable()
|
||||
{
|
||||
PlayerSettings.resizableWindow = true;
|
||||
ApplyReleaseVersion();
|
||||
RequireMetalDisplayLink();
|
||||
RequireDisabledUnityTelemetry();
|
||||
RequireProductIdentity();
|
||||
RequireMacNativeInputPlugin();
|
||||
RequireMacSparkleUpdater();
|
||||
ProjectQuintIconConfigurator.ApplyMacAppIcon();
|
||||
|
||||
var outputPath = Environment.GetEnvironmentVariable(BuildPathEnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Set {BuildPathEnvironmentVariable} to the exact .app output path before building.");
|
||||
}
|
||||
|
||||
outputPath = Path.GetFullPath(outputPath);
|
||||
if (!outputPath.EndsWith(".app", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"macOS build output must end in .app: {outputPath}");
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
throw new InvalidOperationException($"Build output has no parent directory: {outputPath}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var scenes = EditorBuildSettings.scenes
|
||||
.Where(scene => scene.enabled)
|
||||
.Select(scene => scene.path)
|
||||
.ToArray();
|
||||
if (scenes.Length == 0)
|
||||
throw new InvalidOperationException("No enabled scenes are configured in Build Settings.");
|
||||
|
||||
var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
|
||||
{
|
||||
scenes = scenes,
|
||||
locationPathName = outputPath,
|
||||
target = BuildTarget.StandaloneOSX,
|
||||
options = BuildOptions.None,
|
||||
});
|
||||
|
||||
var summary = report.summary;
|
||||
Debug.Log(
|
||||
$"[ProjectQuint Build] {summary.result}: {summary.totalErrors} errors, " +
|
||||
$"{summary.totalWarnings} warnings, {summary.totalSize} bytes, {summary.totalTime}.");
|
||||
if (summary.result != BuildResult.Succeeded || summary.totalErrors > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"ProjectQuint macOS build failed: {summary.result}, {summary.totalErrors} errors.");
|
||||
|
||||
ValidateBuiltMacNativeInputPlugin(outputPath);
|
||||
ValidateBuiltMacSparkleUpdater(outputPath);
|
||||
WriteAndValidateMacBundleIdentity(outputPath);
|
||||
WriteAndValidateSparkleConfiguration(outputPath);
|
||||
ApplyAndValidateMacPrivacyPolicy(outputPath);
|
||||
ValidateReleaseDependencySurface(outputPath);
|
||||
AdHocSignAndVerify(outputPath);
|
||||
}
|
||||
|
||||
private static void ApplyReleaseVersion()
|
||||
{
|
||||
PlayerSettings.bundleVersion = ProjectQuintReleaseVersion.ProductVersion;
|
||||
PlayerSettings.macOS.buildNumber = ProjectQuintReleaseVersion.BuildNumberText;
|
||||
Debug.Log(
|
||||
$"[ProjectQuint Build] Release version: " +
|
||||
$"{ProjectQuintReleaseVersion.DisplayVersionWithBuild}");
|
||||
}
|
||||
|
||||
private static void RequireMacNativeInputPlugin()
|
||||
{
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var sourceRoot = Path.Combine(projectRoot, "Native", "ProjectQuintInput");
|
||||
var bundleRoot = Path.Combine(
|
||||
Application.dataPath,
|
||||
"ProjectQuint",
|
||||
"Plugins",
|
||||
"macOS",
|
||||
"ProjectQuintInput.bundle");
|
||||
var binary = Path.Combine(bundleRoot, "Contents", "MacOS", "ProjectQuintInput");
|
||||
RequireFile(Path.Combine(sourceRoot, "project_quint_input.cpp"), "macOS native input source");
|
||||
RequireFile(Path.Combine(sourceRoot, "build-macos.sh"), "macOS native input build script");
|
||||
RequireFile(Path.Combine(bundleRoot, "Contents", "Info.plist"), "macOS native input Info.plist");
|
||||
RequireFile(binary, "macOS native input Universal binary");
|
||||
RunTool(
|
||||
"/usr/bin/lipo",
|
||||
new[] { binary, "-verify_arch", "arm64", "x86_64" },
|
||||
"macOS native input architecture verification");
|
||||
}
|
||||
|
||||
private static void ValidateBuiltMacNativeInputPlugin(string appPath)
|
||||
{
|
||||
var bundles = Directory.GetDirectories(
|
||||
appPath,
|
||||
"ProjectQuintInput.bundle",
|
||||
SearchOption.AllDirectories);
|
||||
if (bundles.Length != 1)
|
||||
throw new InvalidDataException(
|
||||
$"Expected exactly one ProjectQuintInput.bundle in the Player, found {bundles.Length}.");
|
||||
var binary = Path.Combine(bundles[0], "Contents", "MacOS", "ProjectQuintInput");
|
||||
RequireFile(binary, "built macOS native input binary");
|
||||
RunTool(
|
||||
"/usr/bin/lipo",
|
||||
new[] { binary, "-verify_arch", "arm64", "x86_64" },
|
||||
"built macOS native input architecture verification");
|
||||
Debug.Log($"[ProjectQuint Build] Included Universal native input polling backend: {bundles[0]}");
|
||||
}
|
||||
|
||||
private static void RequireMacSparkleUpdater()
|
||||
{
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var sourceRoot = Path.Combine(projectRoot, "Native", "ProjectQuintUpdater");
|
||||
var bundleRoot = Path.Combine(
|
||||
Application.dataPath,
|
||||
"ProjectQuint",
|
||||
"Plugins",
|
||||
"macOS",
|
||||
"ProjectQuintUpdater.bundle");
|
||||
var binary = Path.Combine(bundleRoot, "Contents", "MacOS", "ProjectQuintUpdater");
|
||||
var sparkleRoot = Path.Combine(projectRoot, "ThirdParty", "Sparkle", "2.9.4");
|
||||
var sparkleBinary = Path.Combine(
|
||||
sparkleRoot,
|
||||
"Sparkle.framework",
|
||||
"Versions",
|
||||
"B",
|
||||
"Sparkle");
|
||||
|
||||
RequireFile(Path.Combine(sourceRoot, "project_quint_updater.mm"), "Sparkle updater source");
|
||||
RequireFile(Path.Combine(sourceRoot, "build-macos.sh"), "Sparkle updater build script");
|
||||
RequireFile(Path.Combine(bundleRoot, "Contents", "Info.plist"), "Sparkle updater Info.plist");
|
||||
RequireFile(binary, "Sparkle updater Universal binary");
|
||||
RequireFile(sparkleBinary, "Sparkle 2.9.4 framework binary");
|
||||
RequireFile(Path.Combine(sparkleRoot, "LICENSE"), "Sparkle license");
|
||||
RunTool(
|
||||
"/usr/bin/lipo",
|
||||
new[] { binary, "-verify_arch", "arm64", "x86_64" },
|
||||
"Sparkle updater architecture verification");
|
||||
RunTool(
|
||||
"/usr/bin/lipo",
|
||||
new[] { sparkleBinary, "-verify_arch", "arm64", "x86_64" },
|
||||
"Sparkle framework architecture verification");
|
||||
var linkedLibraries = RunToolCapture(
|
||||
"/usr/bin/otool",
|
||||
new[] { "-L", binary },
|
||||
"Sparkle updater linkage verification");
|
||||
if (!linkedLibraries.Contains("@rpath/Sparkle.framework/Versions/B/Sparkle"))
|
||||
throw new InvalidDataException("ProjectQuintUpdater is not linked to Sparkle.framework.");
|
||||
var loadCommands = RunToolCapture(
|
||||
"/usr/bin/otool",
|
||||
new[] { "-l", binary },
|
||||
"Sparkle updater rpath verification");
|
||||
if (!loadCommands.Contains("@loader_path/../../../../Frameworks"))
|
||||
throw new InvalidDataException("ProjectQuintUpdater has no app Frameworks rpath.");
|
||||
}
|
||||
|
||||
private static void ValidateBuiltMacSparkleUpdater(string appPath)
|
||||
{
|
||||
var bundles = Directory.GetDirectories(
|
||||
appPath,
|
||||
"ProjectQuintUpdater.bundle",
|
||||
SearchOption.AllDirectories);
|
||||
if (bundles.Length != 1)
|
||||
throw new InvalidDataException(
|
||||
$"Expected exactly one ProjectQuintUpdater.bundle in the Player, found {bundles.Length}.");
|
||||
var binary = Path.Combine(bundles[0], "Contents", "MacOS", "ProjectQuintUpdater");
|
||||
RequireFile(binary, "built Sparkle updater binary");
|
||||
RunTool(
|
||||
"/usr/bin/lipo",
|
||||
new[] { binary, "-verify_arch", "arm64", "x86_64" },
|
||||
"built Sparkle updater architecture verification");
|
||||
Debug.Log($"[ProjectQuint Build] Included Universal Sparkle bridge: {bundles[0]}");
|
||||
}
|
||||
|
||||
private static void RequireProductIdentity()
|
||||
{
|
||||
var identifier = PlayerSettings.GetApplicationIdentifier(BuildTargetGroup.Standalone);
|
||||
if (!string.Equals(PlayerSettings.companyName, CompanyName, StringComparison.Ordinal) ||
|
||||
!string.Equals(PlayerSettings.productName, ProductName, StringComparison.Ordinal) ||
|
||||
!string.Equals(
|
||||
PlayerSettings.bundleVersion,
|
||||
ProjectQuintReleaseVersion.ProductVersion,
|
||||
StringComparison.Ordinal) ||
|
||||
!string.Equals(
|
||||
PlayerSettings.macOS.buildNumber,
|
||||
ProjectQuintReleaseVersion.BuildNumberText,
|
||||
StringComparison.Ordinal) ||
|
||||
!string.Equals(identifier, BundleIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ProjectQuint product identity is incomplete. Expected " +
|
||||
$"{CompanyName}/{ProductName}/{BundleIdentifier}/" +
|
||||
$"{ProjectQuintReleaseVersion.DisplayVersionWithBuild}, got " +
|
||||
$"{PlayerSettings.companyName}/{PlayerSettings.productName}/{identifier}/" +
|
||||
$"{PlayerSettings.bundleVersion} ({PlayerSettings.macOS.buildNumber}).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAndValidateMacBundleIdentity(string appPath)
|
||||
{
|
||||
var infoPlist = Path.Combine(appPath, "Contents", "Info.plist");
|
||||
RequireFile(infoPlist, "macOS application Info.plist");
|
||||
RunTool(
|
||||
"/usr/bin/plutil",
|
||||
new[] { "-replace", "CFBundleDisplayName", "-string", ProductName, infoPlist },
|
||||
"macOS display-name metadata");
|
||||
RunTool(
|
||||
"/usr/bin/plutil",
|
||||
new[] { "-replace", "NSHumanReadableCopyright", "-xml", CopyrightXml, infoPlist },
|
||||
"macOS copyright metadata");
|
||||
|
||||
RequirePlistValue(infoPlist, "CFBundleIdentifier", BundleIdentifier);
|
||||
RequirePlistValue(infoPlist, "CFBundleName", ProductName);
|
||||
RequirePlistValue(infoPlist, "CFBundleDisplayName", ProductName);
|
||||
RequirePlistValue(
|
||||
infoPlist,
|
||||
"CFBundleShortVersionString",
|
||||
ProjectQuintReleaseVersion.ProductVersion);
|
||||
RequirePlistValue(
|
||||
infoPlist,
|
||||
"CFBundleVersion",
|
||||
ProjectQuintReleaseVersion.BuildNumberText);
|
||||
RequireXmlPlistStringValue(infoPlist, "NSHumanReadableCopyright", Copyright);
|
||||
Debug.Log(
|
||||
$"[ProjectQuint Build] Product identity verified: {BundleIdentifier} " +
|
||||
$"{ProjectQuintReleaseVersion.DisplayVersionWithBuild}, {Copyright}");
|
||||
}
|
||||
|
||||
private static void WriteAndValidateSparkleConfiguration(string appPath)
|
||||
{
|
||||
var infoPlist = Path.Combine(appPath, "Contents", "Info.plist");
|
||||
RequireFile(infoPlist, "macOS application Info.plist");
|
||||
SetPlistValue(
|
||||
infoPlist,
|
||||
"SUFeedURL",
|
||||
"string",
|
||||
ProjectQuintReleaseVersion.SparkleFeedUrl);
|
||||
SetPlistValue(
|
||||
infoPlist,
|
||||
"SUPublicEDKey",
|
||||
"string",
|
||||
ProjectQuintReleaseVersion.SparklePublicEdKey);
|
||||
SetPlistValue(infoPlist, "SUEnableAutomaticChecks", "bool", "false");
|
||||
SetPlistValue(infoPlist, "SUAutomaticallyUpdate", "bool", "false");
|
||||
SetPlistValue(infoPlist, "SUAllowsAutomaticUpdates", "bool", "false");
|
||||
SetPlistValue(infoPlist, "SUVerifyUpdateBeforeExtraction", "bool", "true");
|
||||
SetPlistValue(infoPlist, "SUShowReleaseNotes", "bool", "true");
|
||||
|
||||
RequirePlistValue(
|
||||
infoPlist,
|
||||
"SUFeedURL",
|
||||
ProjectQuintReleaseVersion.SparkleFeedUrl);
|
||||
RequirePlistValue(
|
||||
infoPlist,
|
||||
"SUPublicEDKey",
|
||||
ProjectQuintReleaseVersion.SparklePublicEdKey);
|
||||
RequirePlistValue(infoPlist, "SUEnableAutomaticChecks", "false");
|
||||
RequirePlistValue(infoPlist, "SUAutomaticallyUpdate", "false");
|
||||
RequirePlistValue(infoPlist, "SUAllowsAutomaticUpdates", "false");
|
||||
RequirePlistValue(infoPlist, "SUVerifyUpdateBeforeExtraction", "true");
|
||||
RequirePlistValue(infoPlist, "SUShowReleaseNotes", "true");
|
||||
Debug.Log(
|
||||
$"[ProjectQuint Build] Sparkle feed and Ed25519 verification configured: " +
|
||||
ProjectQuintReleaseVersion.SparkleFeedUrl);
|
||||
}
|
||||
|
||||
private static void SetPlistValue(
|
||||
string plistPath,
|
||||
string key,
|
||||
string valueType,
|
||||
string value)
|
||||
{
|
||||
var document = new XmlDocument();
|
||||
document.Load(plistPath);
|
||||
var operation = FindXmlPlistValueNode(document, key) == null ? "-insert" : "-replace";
|
||||
RunTool(
|
||||
"/usr/bin/plutil",
|
||||
new[] { operation, key, "-" + valueType, value, plistPath },
|
||||
$"macOS {key} configuration");
|
||||
}
|
||||
|
||||
private static void RequirePlistValue(string plistPath, string key, string expected)
|
||||
{
|
||||
var actual = RunToolCapture(
|
||||
"/usr/bin/plutil",
|
||||
new[] { "-extract", key, "raw", "-o", "-", plistPath },
|
||||
$"macOS {key} verification").Trim();
|
||||
if (!string.Equals(actual, expected, StringComparison.Ordinal))
|
||||
throw new InvalidDataException(
|
||||
$"macOS {key} mismatch. Expected '{expected}', got '{actual}'.");
|
||||
}
|
||||
|
||||
private static void RequireXmlPlistStringValue(string plistPath, string key, string expected)
|
||||
{
|
||||
var document = new XmlDocument();
|
||||
document.Load(plistPath);
|
||||
var keys = document.SelectNodes("/plist/dict/key");
|
||||
if (keys != null)
|
||||
{
|
||||
foreach (XmlNode keyNode in keys)
|
||||
{
|
||||
if (!string.Equals(keyNode.InnerText, key, StringComparison.Ordinal))
|
||||
continue;
|
||||
var valueNode = keyNode.NextSibling;
|
||||
while (valueNode != null && valueNode.NodeType != XmlNodeType.Element)
|
||||
valueNode = valueNode.NextSibling;
|
||||
var actual = valueNode?.InnerText ?? string.Empty;
|
||||
if (string.Equals(actual, expected, StringComparison.Ordinal))
|
||||
return;
|
||||
throw new InvalidDataException(
|
||||
$"macOS {key} mismatch. Expected '{expected}', got '{actual}'.");
|
||||
}
|
||||
}
|
||||
throw new InvalidDataException($"macOS Info.plist is missing {key}.");
|
||||
}
|
||||
|
||||
private static void AdHocSignAndVerify(string appPath)
|
||||
{
|
||||
RunTool(
|
||||
"/usr/bin/codesign",
|
||||
new[] { "--force", "--deep", "--sign", "-", appPath },
|
||||
"ad-hoc code signing");
|
||||
RunTool(
|
||||
"/usr/bin/codesign",
|
||||
new[] { "--verify", "--deep", "--strict", appPath },
|
||||
"code signature verification");
|
||||
RunTool(
|
||||
"/usr/bin/codesign",
|
||||
new[]
|
||||
{
|
||||
"--verify",
|
||||
"--deep",
|
||||
"--strict",
|
||||
Path.Combine(appPath, "Contents", "Frameworks", "Sparkle.framework"),
|
||||
},
|
||||
"Sparkle framework signature verification");
|
||||
var updaterBundles = Directory.GetDirectories(
|
||||
appPath,
|
||||
"ProjectQuintUpdater.bundle",
|
||||
SearchOption.AllDirectories);
|
||||
if (updaterBundles.Length != 1)
|
||||
throw new InvalidDataException(
|
||||
$"Expected one signed ProjectQuintUpdater.bundle, found {updaterBundles.Length}.");
|
||||
RunTool(
|
||||
"/usr/bin/codesign",
|
||||
new[] { "--verify", "--strict", updaterBundles[0] },
|
||||
"Sparkle bridge signature verification");
|
||||
Debug.Log($"[ProjectQuint Build] Ad-hoc signature verified: {appPath}");
|
||||
}
|
||||
|
||||
private static void RunTool(string executable, string[] arguments, string operation)
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
Arguments = string.Join(" ", arguments.Select(QuoteProcessArgument)),
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(startInfo) ??
|
||||
throw new InvalidOperationException($"Could not start {operation}.");
|
||||
var standardOutput = process.StandardOutput.ReadToEnd();
|
||||
var standardError = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"ProjectQuint {operation} failed ({process.ExitCode}): " +
|
||||
$"{standardError}\n{standardOutput}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string QuoteProcessArgument(string argument) =>
|
||||
"\"" + (argument ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
|
||||
private static void RequireMetalDisplayLink()
|
||||
{
|
||||
var projectSettingsPath = Path.GetFullPath(
|
||||
Path.Combine(Application.dataPath, "..", "ProjectSettings", "ProjectSettings.asset"));
|
||||
var serializedSettings = File.ReadAllText(projectSettingsPath);
|
||||
if (!serializedSettings.Contains("metalUseMetalDisplayLink: 1"))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Use MetalDisplayLink (Mac Player only) must be enabled for stable macOS frame pacing.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireDisabledUnityTelemetry()
|
||||
{
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var projectSettings = File.ReadAllText(
|
||||
Path.Combine(projectRoot, "ProjectSettings", "ProjectSettings.asset"));
|
||||
var connectSettings = File.ReadAllText(
|
||||
Path.Combine(projectRoot, "ProjectSettings", "UnityConnectSettings.asset"));
|
||||
if (!projectSettings.Contains("submitAnalytics: 0") ||
|
||||
!connectSettings.Contains("UnityConnectSettings:\n m_ObjectHideFlags: 0\n" +
|
||||
" serializedVersion: 1\n m_Enabled: 0") ||
|
||||
!connectSettings.Contains("m_EngineDiagnosticsEnabled: 0") ||
|
||||
!connectSettings.Contains("m_EnableCloudDiagnosticsReporting: 0") ||
|
||||
!connectSettings.Contains("UnityAnalyticsSettings:\n m_Enabled: 0") ||
|
||||
!connectSettings.Contains("PerformanceReportingSettings:\n m_Enabled: 0"))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unity telemetry and diagnostics must remain disabled for a ProjectQuint release.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAndValidateMacPrivacyPolicy(string appPath)
|
||||
{
|
||||
var infoPlist = Path.Combine(appPath, "Contents", "Info.plist");
|
||||
RunTool(
|
||||
"/usr/bin/plutil",
|
||||
new[] { "-remove", "NSAppTransportSecurity", infoPlist },
|
||||
"macOS App Transport Security hardening");
|
||||
RequireXmlPlistMissingKey(infoPlist, "NSAppTransportSecurity");
|
||||
|
||||
var privacyManifest = Path.Combine(
|
||||
appPath,
|
||||
"Contents",
|
||||
"Resources",
|
||||
"PrivacyInfo.xcprivacy");
|
||||
RequireFile(privacyManifest, "macOS privacy manifest");
|
||||
var document = new XmlDocument();
|
||||
document.Load(privacyManifest);
|
||||
var collectedData = FindXmlPlistValueNode(document, "NSPrivacyCollectedDataTypes");
|
||||
if (collectedData != null &&
|
||||
(collectedData.Name != "array" ||
|
||||
collectedData.ChildNodes.Cast<XmlNode>().Any(
|
||||
node => node.NodeType == XmlNodeType.Element)))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"ProjectQuint declares no collected data, but the built privacy manifest is not empty.");
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
"[ProjectQuint Build] Privacy policy verified: no collected data and macOS ATS enforced.");
|
||||
}
|
||||
|
||||
private static void ValidateReleaseDependencySurface(string appPath)
|
||||
{
|
||||
var managedDirectory = Path.Combine(
|
||||
appPath,
|
||||
"Contents",
|
||||
"Resources",
|
||||
"Data",
|
||||
"Managed");
|
||||
if (!Directory.Exists(managedDirectory))
|
||||
throw new DirectoryNotFoundException(
|
||||
$"Built macOS Managed directory not found: {managedDirectory}");
|
||||
|
||||
var forbiddenAssemblyNames = new[]
|
||||
{
|
||||
"Unity.AI.MCP.Runtime.dll",
|
||||
"Unity.AI.Tracing.dll",
|
||||
"Unity.Multiplayer.Center.Common.dll",
|
||||
"Unity.VisualScripting.Antlr3.Runtime.dll",
|
||||
"Unity.VisualScripting.Core.dll",
|
||||
"Unity.VisualScripting.DocCodeExamples.dll",
|
||||
"Unity.VisualScripting.Flow.dll",
|
||||
"Unity.VisualScripting.State.dll",
|
||||
"Unity.2D.Animation.Runtime.dll",
|
||||
"Unity.2D.Common.Runtime.dll",
|
||||
"Unity.2D.IK.Runtime.dll",
|
||||
"Unity.2D.SpriteShape.Runtime.dll",
|
||||
"Unity.2D.Tilemap.Extras.dll",
|
||||
"FMODUnityHaptics.dll",
|
||||
"FMODUnityResonance.dll",
|
||||
};
|
||||
var includedForbiddenAssemblies = forbiddenAssemblyNames
|
||||
.Where(name => File.Exists(Path.Combine(managedDirectory, name)))
|
||||
.ToArray();
|
||||
|
||||
var pluginDirectory = Path.Combine(appPath, "Contents", "PlugIns");
|
||||
var forbiddenPluginNames = new[]
|
||||
{
|
||||
"fmod_haptics.bundle",
|
||||
"resonanceaudio.bundle",
|
||||
};
|
||||
var includedForbiddenPlugins = Directory.Exists(pluginDirectory)
|
||||
? forbiddenPluginNames
|
||||
.Where(name => Directory.Exists(Path.Combine(pluginDirectory, name)))
|
||||
.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
var debugArtifacts = Directory
|
||||
.EnumerateFiles(appPath, "*", SearchOption.AllDirectories)
|
||||
.Where(path =>
|
||||
path.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".mdb", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".debug", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(Path.GetFileName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var unexpected = includedForbiddenAssemblies
|
||||
.Concat(includedForbiddenPlugins)
|
||||
.Concat(debugArtifacts)
|
||||
.ToArray();
|
||||
if (unexpected.Length > 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Release build contains development-only or unused runtime files: " +
|
||||
string.Join(", ", unexpected) +
|
||||
". Build through Tools/build-mac-stable-release.sh.");
|
||||
}
|
||||
|
||||
RequireFile(
|
||||
Path.Combine(managedDirectory, "FMODUnity.dll"),
|
||||
"FMOD runtime assembly");
|
||||
RequireFile(
|
||||
Path.Combine(pluginDirectory, "fmodstudio.bundle", "Contents", "MacOS", "fmodstudio"),
|
||||
"FMOD Studio macOS runtime");
|
||||
RequireFile(
|
||||
Path.Combine(
|
||||
appPath,
|
||||
"Contents",
|
||||
"Frameworks",
|
||||
"Sparkle.framework",
|
||||
"Versions",
|
||||
"B",
|
||||
"Sparkle"),
|
||||
"Sparkle updater framework");
|
||||
Debug.Log(
|
||||
"[ProjectQuint Build] Release dependency surface verified: " +
|
||||
"no AI/MCP, Visual Scripting, unused 2D, multiplayer, " +
|
||||
"FMOD haptics/resonance or debug artifacts.");
|
||||
}
|
||||
|
||||
private static void RequireXmlPlistMissingKey(string plistPath, string key)
|
||||
{
|
||||
var document = new XmlDocument();
|
||||
document.Load(plistPath);
|
||||
if (FindXmlPlistValueNode(document, key) != null)
|
||||
throw new InvalidDataException($"macOS Info.plist must not contain {key}.");
|
||||
}
|
||||
|
||||
private static XmlNode FindXmlPlistValueNode(XmlDocument document, string key)
|
||||
{
|
||||
var keys = document.SelectNodes("/plist/dict/key");
|
||||
if (keys == null)
|
||||
return null;
|
||||
foreach (XmlNode keyNode in keys)
|
||||
{
|
||||
if (!string.Equals(keyNode.InnerText, key, StringComparison.Ordinal))
|
||||
continue;
|
||||
var valueNode = keyNode.NextSibling;
|
||||
while (valueNode != null && valueNode.NodeType != XmlNodeType.Element)
|
||||
valueNode = valueNode.NextSibling;
|
||||
return valueNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[PostProcessBuild(100)]
|
||||
public static void IncludeLegacyEncodingProvider(BuildTarget target, string outputPath)
|
||||
{
|
||||
if (target != BuildTarget.StandaloneOSX)
|
||||
return;
|
||||
|
||||
var sourcePath = Path.Combine(
|
||||
EditorApplication.applicationContentsPath,
|
||||
"Resources",
|
||||
"Scripting",
|
||||
"NetStandard",
|
||||
"EditorExtensions",
|
||||
"System.Text.Encoding.CodePages.dll");
|
||||
if (!File.Exists(sourcePath))
|
||||
throw new FileNotFoundException(
|
||||
"Unity's Shift-JIS encoding provider was not found; refusing to create a broken macOS build.",
|
||||
sourcePath);
|
||||
|
||||
var managedDirectory = Path.Combine(outputPath, "Contents", "Resources", "Data", "Managed");
|
||||
if (!Directory.Exists(managedDirectory))
|
||||
throw new DirectoryNotFoundException($"macOS Managed directory not found: {managedDirectory}");
|
||||
|
||||
var destinationPath = Path.Combine(managedDirectory, "System.Text.Encoding.CodePages.dll");
|
||||
File.Copy(sourcePath, destinationPath, true);
|
||||
Debug.Log($"[ProjectQuint Build] Included Shift-JIS encoding provider: {destinationPath}");
|
||||
}
|
||||
|
||||
[PostProcessBuild(110)]
|
||||
public static void IncludeBundledBgaConverter(BuildTarget target, string outputPath)
|
||||
{
|
||||
if (target != BuildTarget.StandaloneOSX)
|
||||
return;
|
||||
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var thirdPartyRoot = Path.Combine(projectRoot, "ThirdParty", "FFmpeg");
|
||||
var sourceBinary = Path.Combine(thirdPartyRoot, "macOS", "ffmpeg");
|
||||
var sourceArchive = Path.Combine(thirdPartyRoot, "Source", "ffmpeg-8.1.2.tar.xz");
|
||||
var expectedSourceHash =
|
||||
"464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c";
|
||||
RequireFile(sourceBinary, "Bundled macOS FFmpeg converter");
|
||||
RequireFile(sourceArchive, "Corresponding FFmpeg source archive");
|
||||
RequireFile(Path.Combine(thirdPartyRoot, "COPYING.LGPLv2.1"), "FFmpeg LGPL license");
|
||||
RequireFile(Path.Combine(thirdPartyRoot, "NOTICE.txt"), "FFmpeg attribution notice");
|
||||
RequireFile(Path.Combine(projectRoot, "Tools", "build-ffmpeg-macos.sh"), "FFmpeg build script");
|
||||
|
||||
var actualSourceHash = ComputeSha256(sourceArchive);
|
||||
if (!string.Equals(actualSourceHash, expectedSourceHash, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException(
|
||||
$"FFmpeg source archive hash mismatch. Expected {expectedSourceHash}, got {actualSourceHash}.");
|
||||
|
||||
RunTool(
|
||||
"/usr/bin/lipo",
|
||||
new[] { sourceBinary, "-verify_arch", "arm64", "x86_64" },
|
||||
"bundled FFmpeg architecture verification");
|
||||
var version = RunToolCapture(sourceBinary, new[] { "-version" }, "bundled FFmpeg version check");
|
||||
if (!version.Contains("ffmpeg version 8.1.2") ||
|
||||
!version.Contains("--enable-encoder=h264_videotoolbox") ||
|
||||
version.Contains("--enable-gpl") || version.Contains("--enable-nonfree") ||
|
||||
version.Contains("libx264"))
|
||||
throw new InvalidDataException(
|
||||
"Bundled FFmpeg must be the pinned LGPL 8.1.2 VideoToolbox build without GPL/nonfree components.");
|
||||
|
||||
var toolDirectory = Path.Combine(outputPath, "Contents", "Resources", "ProjectQuint", "Tools");
|
||||
Directory.CreateDirectory(toolDirectory);
|
||||
var destinationBinary = Path.Combine(toolDirectory, "ffmpeg");
|
||||
File.Copy(sourceBinary, destinationBinary, true);
|
||||
RunTool("/bin/chmod", new[] { "755", destinationBinary }, "bundled FFmpeg permission setup");
|
||||
|
||||
var noticeDirectory = Path.Combine(outputPath, "Contents", "Resources", "ThirdParty", "FFmpeg");
|
||||
Directory.CreateDirectory(noticeDirectory);
|
||||
CopyRequired(thirdPartyRoot, noticeDirectory, "README.md");
|
||||
CopyRequired(thirdPartyRoot, noticeDirectory, "NOTICE.txt");
|
||||
CopyRequired(thirdPartyRoot, noticeDirectory, "COPYING.LGPLv2.1");
|
||||
File.Copy(sourceArchive, Path.Combine(noticeDirectory, Path.GetFileName(sourceArchive)), true);
|
||||
File.Copy(
|
||||
Path.Combine(projectRoot, "Tools", "build-ffmpeg-macos.sh"),
|
||||
Path.Combine(noticeDirectory, "build-ffmpeg-macos.sh"),
|
||||
true);
|
||||
Debug.Log(
|
||||
$"[ProjectQuint Build] Included Universal LGPL FFmpeg BGA converter and corresponding source: " +
|
||||
destinationBinary);
|
||||
}
|
||||
|
||||
[PostProcessBuild(120)]
|
||||
public static void IncludeThirdPartyNotices(BuildTarget target, string outputPath)
|
||||
{
|
||||
if (target != BuildTarget.StandaloneOSX)
|
||||
return;
|
||||
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var sourceDirectory = Path.Combine(projectRoot, "ThirdParty", "Licenses");
|
||||
var destinationDirectory = Path.Combine(
|
||||
outputPath,
|
||||
"Contents",
|
||||
"Resources",
|
||||
"ThirdParty",
|
||||
"Licenses");
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
|
||||
CopyRequired(sourceDirectory, destinationDirectory, "THIRD-PARTY-NOTICES.txt");
|
||||
CopyRequired(sourceDirectory, destinationDirectory, "FMOD-NOTICE.txt");
|
||||
CopyRequired(sourceDirectory, destinationDirectory, "Pretendard-OFL-1.1.txt");
|
||||
CopyRequired(sourceDirectory, destinationDirectory, "Rajdhani-OFL-1.1.txt");
|
||||
CopyRequired(sourceDirectory, destinationDirectory, "Sparkle-MIT.txt");
|
||||
Debug.Log(
|
||||
$"[ProjectQuint Build] Included third-party notices and font licenses: " +
|
||||
destinationDirectory);
|
||||
}
|
||||
|
||||
[PostProcessBuild(130)]
|
||||
public static void IncludeSparkleFramework(BuildTarget target, string outputPath)
|
||||
{
|
||||
if (target != BuildTarget.StandaloneOSX)
|
||||
return;
|
||||
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var sparkleRoot = Path.Combine(projectRoot, "ThirdParty", "Sparkle", "2.9.4");
|
||||
var sourceFramework = Path.Combine(sparkleRoot, "Sparkle.framework");
|
||||
var destinationDirectory = Path.Combine(outputPath, "Contents", "Frameworks");
|
||||
var destinationFramework = Path.Combine(destinationDirectory, "Sparkle.framework");
|
||||
if (!Directory.Exists(sourceFramework))
|
||||
throw new DirectoryNotFoundException(
|
||||
$"Sparkle framework was not found: {sourceFramework}");
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
if (Directory.Exists(destinationFramework))
|
||||
Directory.Delete(destinationFramework, true);
|
||||
RunTool(
|
||||
"/usr/bin/ditto",
|
||||
new[] { sourceFramework, destinationFramework },
|
||||
"Sparkle framework copy");
|
||||
RequireFile(
|
||||
Path.Combine(destinationFramework, "Versions", "B", "Sparkle"),
|
||||
"copied Sparkle framework binary");
|
||||
Debug.Log($"[ProjectQuint Build] Included Sparkle 2.9.4: {destinationFramework}");
|
||||
}
|
||||
|
||||
private static void RequireFile(string path, string label)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException($"{label} was not found; refusing to create an incomplete build.", path);
|
||||
}
|
||||
|
||||
private static void CopyRequired(string sourceDirectory, string destinationDirectory, string fileName)
|
||||
{
|
||||
var source = Path.Combine(sourceDirectory, fileName);
|
||||
RequireFile(source, fileName);
|
||||
File.Copy(source, Path.Combine(destinationDirectory, fileName), true);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var sha256 = SHA256.Create();
|
||||
return string.Concat(sha256.ComputeHash(stream).Select(value => value.ToString("x2")));
|
||||
}
|
||||
|
||||
private static string RunToolCapture(string executable, string[] arguments, string operation)
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
Arguments = string.Join(" ", arguments.Select(QuoteProcessArgument)),
|
||||
};
|
||||
using var process = System.Diagnostics.Process.Start(startInfo) ??
|
||||
throw new InvalidOperationException($"Could not start {operation}.");
|
||||
var standardOutput = process.StandardOutput.ReadToEnd();
|
||||
var standardError = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"ProjectQuint {operation} failed ({process.ExitCode}): {standardError}\n{standardOutput}");
|
||||
return standardOutput + "\n" + standardError;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b0d7464bb03b24dd4b47b33d5bcddcbf
|
||||
@@ -0,0 +1,37 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectQuint.Editor
|
||||
{
|
||||
internal static class ProjectQuintIconConfigurator
|
||||
{
|
||||
internal const string IconAssetPath =
|
||||
"Assets/ProjectQuint/Art/AppIcon/ProjectQuintAppIcon.png";
|
||||
|
||||
[MenuItem("ProjectQuint/Apply macOS App Icon")]
|
||||
public static void ApplyMacAppIcon()
|
||||
{
|
||||
AssetDatabase.ImportAsset(IconAssetPath, ImportAssetOptions.ForceUpdate);
|
||||
var icon = AssetDatabase.LoadAssetAtPath<Texture2D>(IconAssetPath);
|
||||
if (icon == null)
|
||||
throw new InvalidOperationException($"ProjectQuint app icon is missing: {IconAssetPath}");
|
||||
|
||||
var target = NamedBuildTarget.Standalone;
|
||||
var iconCount = PlayerSettings.GetIconSizes(target, IconKind.Application).Length;
|
||||
if (iconCount <= 0)
|
||||
throw new InvalidOperationException("Unity reported no Standalone application icon slots.");
|
||||
|
||||
PlayerSettings.SetIcons(
|
||||
target,
|
||||
Enumerable.Repeat(icon, iconCount).ToArray(),
|
||||
IconKind.Application);
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log($"[ProjectQuint Icon] Applied {IconAssetPath} to {iconCount} Standalone icon slots.");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 262f464501a9e4e74b58ab78ccd699d7
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d3df3121b0094dfc8d2b7b52f6be9b9
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6248c2c2485984703b8814051c952fe3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 407742959808e452d95dc237db8e178b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 529df0bdea17b4c449d335536cf75642
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ProjectQuintInput</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.teamdenia.projectquint.input</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>ProjectQuintInput</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
BIN
Binary file not shown.
+115
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict/>
|
||||
<key>files2</key>
|
||||
<dict/>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c361e5c9916a44c1b72e671552512b9c
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ProjectQuintUpdater</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.teamdenia.projectquint.updater</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>ProjectQuintUpdater</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
BIN
Binary file not shown.
+115
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict/>
|
||||
<key>files2</key>
|
||||
<dict/>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 77996964903b549a3ba6124b7f2e91ac
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 17adf60357a0142419c85a863df17c9b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa19f129aed7947eba7998908f182719
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,741 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ProjectQuint.Core.Charts;
|
||||
|
||||
namespace ProjectQuint.Core.Analysis
|
||||
{
|
||||
public enum ChartFeatureAxis
|
||||
{
|
||||
Notes,
|
||||
Peak,
|
||||
Chord,
|
||||
Long,
|
||||
SofLan,
|
||||
Tech,
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class ChartFeatureVector
|
||||
{
|
||||
public const int CurrentAnalyzerVersion = 3;
|
||||
public const int CurrentCalibrationVersion = 4;
|
||||
public const float ReferenceValue = 100f;
|
||||
public const float MaximumValue = 200f;
|
||||
|
||||
public int AnalyzerVersion = CurrentAnalyzerVersion;
|
||||
public int CalibrationVersion = CurrentCalibrationVersion;
|
||||
public int KeyCount;
|
||||
public int TotalLaneCount;
|
||||
public int InputCount;
|
||||
public int AnomalyCount;
|
||||
public double ActiveDurationSeconds;
|
||||
public double NotesRaw;
|
||||
public double PeakRaw;
|
||||
public double ChordRaw;
|
||||
public double LongRaw;
|
||||
public double SofLanRaw;
|
||||
public double TechRaw;
|
||||
public float Notes;
|
||||
public float Peak;
|
||||
public float Chord;
|
||||
public float Long;
|
||||
public float SofLan;
|
||||
public float Tech;
|
||||
|
||||
public bool IsCurrent =>
|
||||
AnalyzerVersion == CurrentAnalyzerVersion &&
|
||||
CalibrationVersion == CurrentCalibrationVersion;
|
||||
|
||||
public float Value(ChartFeatureAxis axis) => axis switch
|
||||
{
|
||||
ChartFeatureAxis.Notes => Notes,
|
||||
ChartFeatureAxis.Peak => Peak,
|
||||
ChartFeatureAxis.Chord => Chord,
|
||||
ChartFeatureAxis.Long => Long,
|
||||
ChartFeatureAxis.SofLan => SofLan,
|
||||
ChartFeatureAxis.Tech => Tech,
|
||||
_ => 0f,
|
||||
};
|
||||
}
|
||||
|
||||
public static class ChartFeatureCalibration
|
||||
{
|
||||
public static ChartFeatureVector Apply(ChartFeatureVector vector)
|
||||
{
|
||||
if (vector == null)
|
||||
throw new ArgumentNullException(nameof(vector));
|
||||
if (vector.AnalyzerVersion != ChartFeatureVector.CurrentAnalyzerVersion)
|
||||
throw new InvalidOperationException("Raw chart features were produced by an incompatible analyzer.");
|
||||
|
||||
vector.Notes = Normalize(ChartFeatureAxis.Notes, vector.KeyCount, vector.NotesRaw);
|
||||
vector.Peak = Normalize(ChartFeatureAxis.Peak, vector.KeyCount, vector.PeakRaw);
|
||||
vector.Chord = Normalize(ChartFeatureAxis.Chord, vector.KeyCount, vector.ChordRaw);
|
||||
vector.Long = Normalize(ChartFeatureAxis.Long, vector.KeyCount, vector.LongRaw);
|
||||
vector.SofLan = Normalize(ChartFeatureAxis.SofLan, vector.KeyCount, vector.SofLanRaw);
|
||||
vector.Tech = Normalize(ChartFeatureAxis.Tech, vector.KeyCount, vector.TechRaw);
|
||||
vector.CalibrationVersion = ChartFeatureVector.CurrentCalibrationVersion;
|
||||
return vector;
|
||||
}
|
||||
|
||||
public static float Normalize(ChartFeatureAxis axis, int keyCount, double rawValue)
|
||||
{
|
||||
if (!(rawValue > 0) || double.IsNaN(rawValue) || double.IsInfinity(rawValue))
|
||||
return 0f;
|
||||
|
||||
keyCount = Math.Max(4, Math.Min(7, keyCount));
|
||||
var laneScale = keyCount / 5.0;
|
||||
var notesScale = keyCount switch
|
||||
{
|
||||
4 => 6.0 / 7.0,
|
||||
6 => 8.0 / 7.0,
|
||||
7 => 9.0 / 7.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
var peakScale = 1.0 + 0.125 * (keyCount - 5);
|
||||
double first;
|
||||
double second;
|
||||
double third;
|
||||
double fourth;
|
||||
double fifth;
|
||||
double sixth;
|
||||
switch (axis)
|
||||
{
|
||||
case ChartFeatureAxis.Notes:
|
||||
(first, second, third, fourth, fifth, sixth) =
|
||||
Scale((300.0, 600.0, 865.0, 1065.0, 1260.0, 1900.0), notesScale);
|
||||
break;
|
||||
case ChartFeatureAxis.Peak:
|
||||
(first, second, third, fourth, fifth, sixth) =
|
||||
Scale((9.5, 17.1, 23.9, 29.3, 34.8, 48.0), peakScale);
|
||||
break;
|
||||
case ChartFeatureAxis.Chord:
|
||||
(first, second, third, fourth, fifth, sixth) =
|
||||
Scale((36.0, 153.0, 317.0, 461.0, 579.0, 1500.0), laneScale);
|
||||
break;
|
||||
case ChartFeatureAxis.Long:
|
||||
(first, second, third, fourth, fifth, sixth) =
|
||||
(0.005, 0.03, 0.10, 0.20, 0.30, 0.60);
|
||||
break;
|
||||
case ChartFeatureAxis.SofLan:
|
||||
(first, second, third, fourth, fifth, sixth) =
|
||||
(0.15, 1.0, 6.0, 15.0, 30.0, 150.0);
|
||||
break;
|
||||
default:
|
||||
(first, second, third, fourth, fifth, sixth) =
|
||||
Scale((8.0, 21.0, 36.0, 67.0, 111.0, 200.0), laneScale);
|
||||
break;
|
||||
}
|
||||
|
||||
return (float)Piecewise(rawValue, first, second, third, fourth, fifth, sixth);
|
||||
}
|
||||
|
||||
private static (double, double, double, double, double, double) Scale(
|
||||
(double, double, double, double, double, double) values,
|
||||
double scale) =>
|
||||
(values.Item1 * scale,
|
||||
values.Item2 * scale,
|
||||
values.Item3 * scale,
|
||||
values.Item4 * scale,
|
||||
values.Item5 * scale,
|
||||
values.Item6 * scale);
|
||||
|
||||
private static double Piecewise(
|
||||
double value,
|
||||
double first,
|
||||
double second,
|
||||
double third,
|
||||
double fourth,
|
||||
double fifth,
|
||||
double sixth)
|
||||
{
|
||||
const double step = ChartFeatureVector.MaximumValue / 6.0;
|
||||
if (value <= first)
|
||||
return step * value / first;
|
||||
if (value <= second)
|
||||
return step + step * (value - first) / (second - first);
|
||||
if (value <= third)
|
||||
return step * 2.0 + step * (value - second) / (third - second);
|
||||
if (value <= fourth)
|
||||
return step * 3.0 + step * (value - third) / (fourth - third);
|
||||
if (value <= fifth)
|
||||
return step * 4.0 + step * (value - fourth) / (fifth - fourth);
|
||||
if (value <= sixth)
|
||||
return step * 5.0 + step * (value - fifth) / (sixth - fifth);
|
||||
return ChartFeatureVector.MaximumValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ChartFeatureAnalyzer
|
||||
{
|
||||
private const double SimultaneousEpsilonSeconds = 0.0005;
|
||||
private const double MinimumActiveDurationSeconds = 15.0;
|
||||
private const double PeakSampleStepSeconds = 0.050;
|
||||
private const double SofLanEventBeatEpsilon = 0.000000001;
|
||||
private const double SofLanEffectiveSpeedTolerance = 0.01;
|
||||
|
||||
public static ChartFeatureVector Analyze(PlayableChart chart)
|
||||
{
|
||||
if (chart == null)
|
||||
throw new ArgumentNullException(nameof(chart));
|
||||
|
||||
var keyCount = Math.Max(4, Math.Min(7, chart.Metadata?.KeyCount ?? 5));
|
||||
var totalLaneCount = Math.Max(1, chart.Metadata?.TotalLaneCount ?? keyCount);
|
||||
var groups = BuildOnsetGroups(chart.Notes, totalLaneCount, out var uniqueNotes, out var anomalies);
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
return new ChartFeatureVector
|
||||
{
|
||||
KeyCount = keyCount,
|
||||
TotalLaneCount = totalLaneCount,
|
||||
AnomalyCount = anomalies,
|
||||
ActiveDurationSeconds = MinimumActiveDurationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
var activeStart = groups[0].Time;
|
||||
var activeEnd = activeStart;
|
||||
foreach (var note in uniqueNotes)
|
||||
activeEnd = Math.Max(activeEnd, note.IsLong ? note.EndTime : note.StartTime);
|
||||
var activeDuration = Math.Max(MinimumActiveDurationSeconds, activeEnd - activeStart);
|
||||
activeEnd = activeStart + activeDuration;
|
||||
var inputCount = 0;
|
||||
foreach (var group in groups)
|
||||
inputCount += group.Count;
|
||||
|
||||
var notesRaw = 60.0 * inputCount / activeDuration;
|
||||
var peakRaw = CalculatePeak(groups, activeStart, activeEnd);
|
||||
var chordRaw = CalculateChord(groups, activeDuration, totalLaneCount);
|
||||
var longRaw = CalculateLong(uniqueNotes, groups, activeStart, activeEnd, totalLaneCount);
|
||||
var sofLanRaw = CalculateSofLan(chart, groups, activeDuration, inputCount);
|
||||
var techRaw = CalculateTech(
|
||||
uniqueNotes,
|
||||
groups,
|
||||
activeDuration,
|
||||
keyCount,
|
||||
chart.Metadata?.HasScratch == true);
|
||||
|
||||
return ChartFeatureCalibration.Apply(new ChartFeatureVector
|
||||
{
|
||||
KeyCount = keyCount,
|
||||
TotalLaneCount = totalLaneCount,
|
||||
InputCount = inputCount,
|
||||
AnomalyCount = anomalies,
|
||||
ActiveDurationSeconds = activeDuration,
|
||||
NotesRaw = notesRaw,
|
||||
PeakRaw = peakRaw,
|
||||
ChordRaw = chordRaw,
|
||||
LongRaw = longRaw,
|
||||
SofLanRaw = sofLanRaw,
|
||||
TechRaw = techRaw,
|
||||
});
|
||||
}
|
||||
|
||||
private static List<OnsetGroup> BuildOnsetGroups(
|
||||
IReadOnlyList<PlayableNote> source,
|
||||
int totalLaneCount,
|
||||
out List<PlayableNote> uniqueNotes,
|
||||
out int anomalyCount)
|
||||
{
|
||||
var sorted = new List<PlayableNote>();
|
||||
if (source != null)
|
||||
{
|
||||
foreach (var note in source)
|
||||
{
|
||||
if (note == null || !IsFinite(note.StartTime))
|
||||
continue;
|
||||
sorted.Add(note);
|
||||
}
|
||||
}
|
||||
sorted.Sort((left, right) =>
|
||||
{
|
||||
var time = left.StartTime.CompareTo(right.StartTime);
|
||||
return time != 0 ? time : left.Lane.CompareTo(right.Lane);
|
||||
});
|
||||
|
||||
uniqueNotes = new List<PlayableNote>(sorted.Count);
|
||||
var groups = new List<OnsetGroup>();
|
||||
anomalyCount = 0;
|
||||
OnsetGroup current = null;
|
||||
foreach (var note in sorted)
|
||||
{
|
||||
if (note.Lane < 0 || note.Lane >= totalLaneCount)
|
||||
{
|
||||
anomalyCount++;
|
||||
continue;
|
||||
}
|
||||
if (current == null || note.StartTime - current.Time > SimultaneousEpsilonSeconds)
|
||||
{
|
||||
current = new OnsetGroup(note.StartTime);
|
||||
groups.Add(current);
|
||||
}
|
||||
|
||||
var laneBit = 1 << note.Lane;
|
||||
if ((current.LaneMask & laneBit) != 0)
|
||||
{
|
||||
anomalyCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current.LaneMask |= laneBit;
|
||||
current.Count++;
|
||||
if (note.IsScratch)
|
||||
{
|
||||
current.HasScratch = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
current.KeyLaneMask |= laneBit;
|
||||
current.KeyCount++;
|
||||
}
|
||||
uniqueNotes.Add(note);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static double CalculatePeak(
|
||||
IReadOnlyList<OnsetGroup> groups,
|
||||
double activeStart,
|
||||
double activeEnd)
|
||||
{
|
||||
var sustained = RollingNpsQuantile(groups, activeStart, activeEnd, 2.0, 0.98);
|
||||
var burst = RollingNpsQuantile(groups, activeStart, activeEnd, 0.5, 0.99);
|
||||
return sustained * 0.60 + burst * 0.40;
|
||||
}
|
||||
|
||||
private static double RollingNpsQuantile(
|
||||
IReadOnlyList<OnsetGroup> groups,
|
||||
double activeStart,
|
||||
double activeEnd,
|
||||
double windowSeconds,
|
||||
double quantile)
|
||||
{
|
||||
var sampleCount = Math.Max(1,
|
||||
(int)Math.Ceiling((activeEnd - activeStart) / PeakSampleStepSeconds));
|
||||
var values = new double[sampleCount];
|
||||
var left = 0;
|
||||
var right = 0;
|
||||
var count = 0;
|
||||
for (var sample = 0; sample < sampleCount; sample++)
|
||||
{
|
||||
var windowStart = activeStart + sample * PeakSampleStepSeconds;
|
||||
var windowEnd = windowStart + windowSeconds;
|
||||
while (left < groups.Count && groups[left].Time < windowStart - SimultaneousEpsilonSeconds)
|
||||
count -= groups[left++].Count;
|
||||
while (right < groups.Count && groups[right].Time < windowEnd - SimultaneousEpsilonSeconds)
|
||||
count += groups[right++].Count;
|
||||
values[sample] = count / windowSeconds;
|
||||
}
|
||||
|
||||
Array.Sort(values);
|
||||
var index = Math.Max(0, Math.Min(values.Length - 1,
|
||||
(int)Math.Ceiling(values.Length * quantile) - 1));
|
||||
return values[index];
|
||||
}
|
||||
|
||||
private static double CalculateChord(
|
||||
IReadOnlyList<OnsetGroup> groups,
|
||||
double activeDuration,
|
||||
int laneCount)
|
||||
{
|
||||
var chordLoad = 0.0;
|
||||
var transitionLoad = 0.0;
|
||||
OnsetGroup previousChord = null;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Count < 2)
|
||||
continue;
|
||||
chordLoad += Math.Pow(group.Count - 1, 1.35);
|
||||
if (previousChord != null)
|
||||
{
|
||||
var interval = group.Time - previousChord.Time;
|
||||
if (interval >= 0 && interval <= 0.6)
|
||||
{
|
||||
var changedRatio = PopCount(group.LaneMask ^ previousChord.LaneMask) /
|
||||
(double)Math.Max(1, laneCount);
|
||||
transitionLoad += changedRatio * Math.Exp(-interval / 0.180);
|
||||
}
|
||||
}
|
||||
previousChord = group;
|
||||
}
|
||||
|
||||
var perMinute = 60.0 / activeDuration;
|
||||
return 0.75 * chordLoad * perMinute + 0.25 * transitionLoad * perMinute;
|
||||
}
|
||||
|
||||
private static double CalculateLong(
|
||||
IReadOnlyList<PlayableNote> notes,
|
||||
IReadOnlyList<OnsetGroup> groups,
|
||||
double activeStart,
|
||||
double activeEnd,
|
||||
int laneCount)
|
||||
{
|
||||
var byLane = new List<HoldInterval>[laneCount];
|
||||
for (var lane = 0; lane < laneCount; lane++)
|
||||
byLane[lane] = new List<HoldInterval>();
|
||||
foreach (var note in notes)
|
||||
{
|
||||
if (!note.IsLong || note.Lane < 0 || note.Lane >= laneCount || !IsFinite(note.EndTime))
|
||||
continue;
|
||||
var start = Math.Max(activeStart, note.StartTime);
|
||||
var end = Math.Min(activeEnd, note.EndTime);
|
||||
if (end > start + SimultaneousEpsilonSeconds)
|
||||
byLane[note.Lane].Add(new HoldInterval(start, end));
|
||||
}
|
||||
|
||||
var merged = new List<HoldInterval>();
|
||||
var heldLaneSeconds = 0.0;
|
||||
foreach (var intervals in byLane)
|
||||
{
|
||||
intervals.Sort((left, right) => left.Start.CompareTo(right.Start));
|
||||
for (var index = 0; index < intervals.Count; index++)
|
||||
{
|
||||
var start = intervals[index].Start;
|
||||
var end = intervals[index].End;
|
||||
while (index + 1 < intervals.Count &&
|
||||
intervals[index + 1].Start <= end + SimultaneousEpsilonSeconds)
|
||||
{
|
||||
index++;
|
||||
end = Math.Max(end, intervals[index].End);
|
||||
}
|
||||
var interval = new HoldInterval(start, end);
|
||||
merged.Add(interval);
|
||||
heldLaneSeconds += end - start;
|
||||
}
|
||||
}
|
||||
if (merged.Count == 0)
|
||||
return 0;
|
||||
|
||||
var events = new List<HoldSweepEvent>(merged.Count * 2);
|
||||
foreach (var interval in merged)
|
||||
{
|
||||
events.Add(new HoldSweepEvent(interval.Start, 1));
|
||||
events.Add(new HoldSweepEvent(interval.End, -1));
|
||||
}
|
||||
events.Sort((left, right) =>
|
||||
{
|
||||
var time = left.Time.CompareTo(right.Time);
|
||||
return time != 0 ? time : left.Delta.CompareTo(right.Delta);
|
||||
});
|
||||
|
||||
var held = 0;
|
||||
var previousTime = activeStart;
|
||||
var extraHeldSeconds = 0.0;
|
||||
foreach (var item in events)
|
||||
{
|
||||
var time = Math.Max(activeStart, Math.Min(activeEnd, item.Time));
|
||||
if (time > previousTime)
|
||||
extraHeldSeconds += Math.Max(0, held - 1) * (time - previousTime);
|
||||
held += item.Delta;
|
||||
previousTime = time;
|
||||
}
|
||||
|
||||
var overlayGroups = 0;
|
||||
held = 0;
|
||||
var eventIndex = 0;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
while (eventIndex < events.Count &&
|
||||
events[eventIndex].Time < group.Time - SimultaneousEpsilonSeconds)
|
||||
held += events[eventIndex++].Delta;
|
||||
if (held > 0)
|
||||
overlayGroups++;
|
||||
while (eventIndex < events.Count &&
|
||||
events[eventIndex].Time <= group.Time + SimultaneousEpsilonSeconds)
|
||||
held += events[eventIndex++].Delta;
|
||||
}
|
||||
|
||||
var duration = activeEnd - activeStart;
|
||||
var occupancy = heldLaneSeconds / (duration * Math.Max(1, laneCount));
|
||||
var concurrency = extraHeldSeconds /
|
||||
(duration * Math.Max(1, laneCount - 1));
|
||||
var overlay = overlayGroups / (double)Math.Max(1, groups.Count);
|
||||
return 0.45 * Clamp01(occupancy) +
|
||||
0.25 * Clamp01(concurrency) +
|
||||
0.30 * Clamp01(overlay);
|
||||
}
|
||||
|
||||
private static double CalculateSofLan(
|
||||
PlayableChart chart,
|
||||
IReadOnlyList<OnsetGroup> groups,
|
||||
double activeDuration,
|
||||
int inputCount)
|
||||
{
|
||||
var nodes = chart.TempoMap?.Nodes;
|
||||
if (nodes == null || nodes.Count == 0 || inputCount <= 0)
|
||||
return 0;
|
||||
|
||||
var meanNps = inputCount / activeDuration;
|
||||
var burden = 0.0;
|
||||
for (var index = 0; index < nodes.Count; index++)
|
||||
{
|
||||
var node = nodes[index];
|
||||
if (node.StopDuration > 0)
|
||||
{
|
||||
var localNps = CountInputs(
|
||||
groups,
|
||||
node.StopEndTime,
|
||||
node.StopEndTime + 2.0) / 2.0;
|
||||
burden += Math.Min(2.0, node.StopDuration) * Exposure(localNps, meanNps);
|
||||
}
|
||||
}
|
||||
|
||||
var scrollNodes = chart.ScrollMap?.Nodes;
|
||||
var currentBpm = nodes[0].Bpm;
|
||||
var currentScroll = scrollNodes != null && scrollNodes.Count > 0
|
||||
? scrollNodes[0].Multiplier
|
||||
: 1.0;
|
||||
var tempoIndex = 1;
|
||||
var scrollIndex = scrollNodes != null && scrollNodes.Count > 0 ? 1 : 0;
|
||||
// BPM and BMS #SCROLL can change at the same beat to deliberately
|
||||
// preserve visual speed. Merge those events before measuring the
|
||||
// transition so an inverse pair is not counted twice.
|
||||
while (tempoIndex < nodes.Count ||
|
||||
(scrollNodes != null && scrollIndex < scrollNodes.Count))
|
||||
{
|
||||
var tempoBeat = tempoIndex < nodes.Count
|
||||
? nodes[tempoIndex].Beat
|
||||
: double.PositiveInfinity;
|
||||
var scrollBeat = scrollNodes != null && scrollIndex < scrollNodes.Count
|
||||
? scrollNodes[scrollIndex].Beat
|
||||
: double.PositiveInfinity;
|
||||
var eventBeat = Math.Min(tempoBeat, scrollBeat);
|
||||
var previousBpm = currentBpm;
|
||||
var previousScroll = currentScroll;
|
||||
|
||||
while (tempoIndex < nodes.Count &&
|
||||
Math.Abs(nodes[tempoIndex].Beat - eventBeat) <= SofLanEventBeatEpsilon)
|
||||
{
|
||||
currentBpm = nodes[tempoIndex].Bpm;
|
||||
tempoIndex++;
|
||||
}
|
||||
while (scrollNodes != null &&
|
||||
scrollIndex < scrollNodes.Count &&
|
||||
Math.Abs(scrollNodes[scrollIndex].Beat - eventBeat) <=
|
||||
SofLanEventBeatEpsilon)
|
||||
{
|
||||
currentScroll = scrollNodes[scrollIndex].Multiplier;
|
||||
scrollIndex++;
|
||||
}
|
||||
|
||||
var change = EffectiveScrollSpeedChange(
|
||||
previousBpm,
|
||||
previousScroll,
|
||||
currentBpm,
|
||||
currentScroll);
|
||||
if (change > 0)
|
||||
{
|
||||
var eventTime = chart.TempoMap.BeatToTime(eventBeat);
|
||||
var localNps = CountInputs(groups, eventTime - 1.0, eventTime + 2.0) / 3.0;
|
||||
burden += Math.Min(4.0, change) * Exposure(localNps, meanNps);
|
||||
}
|
||||
}
|
||||
return burden * 60.0 / activeDuration;
|
||||
}
|
||||
|
||||
private static double EffectiveScrollSpeedChange(
|
||||
double previousBpm,
|
||||
double previousScroll,
|
||||
double currentBpm,
|
||||
double currentScroll)
|
||||
{
|
||||
const double zeroEpsilon = 0.000001;
|
||||
var previousSpeed = previousBpm * previousScroll;
|
||||
var currentSpeed = currentBpm * currentScroll;
|
||||
var previousIsZero = Math.Abs(previousSpeed) <= zeroEpsilon;
|
||||
var currentIsZero = Math.Abs(currentSpeed) <= zeroEpsilon;
|
||||
|
||||
if (previousIsZero && currentIsZero)
|
||||
return 0;
|
||||
if (previousIsZero || currentIsZero)
|
||||
return 2.5;
|
||||
if (Math.Sign(previousSpeed) != Math.Sign(currentSpeed))
|
||||
return 4.0;
|
||||
|
||||
var ratio = Math.Abs(currentSpeed / previousSpeed);
|
||||
if (!(ratio > 0) || double.IsNaN(ratio) || double.IsInfinity(ratio))
|
||||
return 4.0;
|
||||
if (ratio >= 1.0 / (1.0 + SofLanEffectiveSpeedTolerance) &&
|
||||
ratio <= 1.0 + SofLanEffectiveSpeedTolerance)
|
||||
return 0;
|
||||
return Math.Abs(Log2(ratio));
|
||||
}
|
||||
|
||||
private static double CalculateTech(
|
||||
IReadOnlyList<PlayableNote> notes,
|
||||
IReadOnlyList<OnsetGroup> groups,
|
||||
double activeDuration,
|
||||
int keyCount,
|
||||
bool hasScratch)
|
||||
{
|
||||
var laneTimes = new Dictionary<int, double>();
|
||||
var jack = 0.0;
|
||||
foreach (var note in notes)
|
||||
{
|
||||
if (laneTimes.TryGetValue(note.Lane, out var previous))
|
||||
{
|
||||
var interval = note.StartTime - previous;
|
||||
if (interval >= 0 && interval <= 0.200)
|
||||
jack += 1.0 - interval / 0.200;
|
||||
}
|
||||
laneTimes[note.Lane] = note.StartTime;
|
||||
}
|
||||
|
||||
var trill = 0.0;
|
||||
for (var index = 3; index < groups.Count; index++)
|
||||
{
|
||||
var a = groups[index - 3];
|
||||
var b = groups[index - 2];
|
||||
var c = groups[index - 1];
|
||||
var d = groups[index];
|
||||
if (a.Count != 1 || b.Count != 1 || c.Count != 1 || d.Count != 1 ||
|
||||
a.HasScratch || b.HasScratch || c.HasScratch || d.HasScratch ||
|
||||
a.LaneMask != c.LaneMask || b.LaneMask != d.LaneMask || a.LaneMask == b.LaneMask)
|
||||
continue;
|
||||
var first = b.Time - a.Time;
|
||||
var second = c.Time - b.Time;
|
||||
var third = d.Time - c.Time;
|
||||
if (first <= 0 || second <= 0 || third <= 0 ||
|
||||
first > 0.250 || second > 0.250 || third > 0.250)
|
||||
continue;
|
||||
trill += Math.Exp(-((first + second + third) / 3.0) / 0.150);
|
||||
}
|
||||
|
||||
var movement = 0.0;
|
||||
OnsetGroup previousKeys = null;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.KeyCount <= 0)
|
||||
continue;
|
||||
if (previousKeys != null)
|
||||
{
|
||||
var interval = group.Time - previousKeys.Time;
|
||||
if (interval >= 0 && interval <= 0.300)
|
||||
{
|
||||
var distance = Math.Abs(
|
||||
KeyCenter(group.KeyLaneMask, keyCount, hasScratch) -
|
||||
KeyCenter(previousKeys.KeyLaneMask, keyCount, hasScratch));
|
||||
movement += distance * Math.Exp(-interval / 0.180);
|
||||
}
|
||||
}
|
||||
previousKeys = group;
|
||||
}
|
||||
|
||||
var scratchCross = 0.0;
|
||||
for (var index = 0; index < groups.Count; index++)
|
||||
{
|
||||
if (!groups[index].HasScratch)
|
||||
continue;
|
||||
var maximumNearbyKeys = 0;
|
||||
for (var other = index - 1; other >= 0; other--)
|
||||
{
|
||||
if (groups[index].Time - groups[other].Time > 0.150)
|
||||
break;
|
||||
maximumNearbyKeys = Math.Max(maximumNearbyKeys, groups[other].KeyCount);
|
||||
}
|
||||
for (var other = index + 1; other < groups.Count; other++)
|
||||
{
|
||||
if (groups[other].Time - groups[index].Time > 0.150)
|
||||
break;
|
||||
maximumNearbyKeys = Math.Max(maximumNearbyKeys, groups[other].KeyCount);
|
||||
}
|
||||
maximumNearbyKeys = Math.Max(maximumNearbyKeys, groups[index].KeyCount);
|
||||
scratchCross += maximumNearbyKeys;
|
||||
}
|
||||
|
||||
var perMinute = 60.0 / activeDuration;
|
||||
return 0.35 * jack * perMinute +
|
||||
0.30 * trill * perMinute +
|
||||
0.20 * movement * perMinute +
|
||||
0.15 * scratchCross * perMinute;
|
||||
}
|
||||
|
||||
private static double KeyCenter(int laneMask, int keyCount, bool hasScratch)
|
||||
{
|
||||
var offset = hasScratch ? 1 : 0;
|
||||
var total = 0.0;
|
||||
var count = 0;
|
||||
for (var key = 0; key < keyCount; key++)
|
||||
{
|
||||
if ((laneMask & (1 << (key + offset))) == 0)
|
||||
continue;
|
||||
total += key / (double)Math.Max(1, keyCount - 1);
|
||||
count++;
|
||||
}
|
||||
return count > 0 ? total / count : 0.5;
|
||||
}
|
||||
|
||||
private static int CountInputs(IReadOnlyList<OnsetGroup> groups, double start, double end)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Time < start)
|
||||
continue;
|
||||
if (group.Time >= end)
|
||||
break;
|
||||
count += group.Count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static double Exposure(double localNps, double meanNps)
|
||||
{
|
||||
if (!(meanNps > 0))
|
||||
return 0.25;
|
||||
var ratio = Math.Max(0, Math.Min(3.0, localNps / meanNps));
|
||||
return 0.25 + 0.75 * ratio;
|
||||
}
|
||||
|
||||
private static int PopCount(int value)
|
||||
{
|
||||
var count = 0;
|
||||
var bits = (uint)value;
|
||||
while (bits != 0)
|
||||
{
|
||||
bits &= bits - 1;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static double Clamp01(double value) => Math.Max(0, Math.Min(1, value));
|
||||
private static double Log2(double value) => Math.Log(value) / Math.Log(2.0);
|
||||
private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
|
||||
|
||||
private sealed class OnsetGroup
|
||||
{
|
||||
public OnsetGroup(double time) => Time = time;
|
||||
|
||||
public double Time;
|
||||
public int LaneMask;
|
||||
public int KeyLaneMask;
|
||||
public int Count;
|
||||
public int KeyCount;
|
||||
public bool HasScratch;
|
||||
}
|
||||
|
||||
private readonly struct HoldInterval
|
||||
{
|
||||
public HoldInterval(double start, double end)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
}
|
||||
|
||||
public double Start { get; }
|
||||
public double End { get; }
|
||||
}
|
||||
|
||||
private readonly struct HoldSweepEvent
|
||||
{
|
||||
public HoldSweepEvent(double time, int delta)
|
||||
{
|
||||
Time = time;
|
||||
Delta = delta;
|
||||
}
|
||||
|
||||
public double Time { get; }
|
||||
public int Delta { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b0f6f0d468cb04ff397643fcc59325e7
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6196c448b624e48dbbc87bf93d7c2273
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Core.Bms
|
||||
{
|
||||
public readonly struct BmsFraction : IEquatable<BmsFraction>, IComparable<BmsFraction>
|
||||
{
|
||||
public BmsFraction(long numerator, long denominator)
|
||||
{
|
||||
if (denominator <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(denominator));
|
||||
|
||||
var divisor = GreatestCommonDivisor(Math.Abs(numerator), denominator);
|
||||
Numerator = numerator / divisor;
|
||||
Denominator = denominator / divisor;
|
||||
}
|
||||
|
||||
public long Numerator { get; }
|
||||
public long Denominator { get; }
|
||||
public double Value => (double)Numerator / Denominator;
|
||||
|
||||
public int CompareTo(BmsFraction other)
|
||||
{
|
||||
var left = (decimal)Numerator * other.Denominator;
|
||||
var right = (decimal)other.Numerator * Denominator;
|
||||
return left.CompareTo(right);
|
||||
}
|
||||
|
||||
public bool Equals(BmsFraction other) =>
|
||||
Numerator == other.Numerator && Denominator == other.Denominator;
|
||||
|
||||
public override bool Equals(object obj) => obj is BmsFraction other && Equals(other);
|
||||
public override int GetHashCode() => HashCode.Combine(Numerator, Denominator);
|
||||
|
||||
private static long GreatestCommonDivisor(long left, long right)
|
||||
{
|
||||
while (right != 0)
|
||||
{
|
||||
var remainder = left % right;
|
||||
left = right;
|
||||
right = remainder;
|
||||
}
|
||||
|
||||
return left == 0 ? 1 : left;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct BmsPosition : IEquatable<BmsPosition>, IComparable<BmsPosition>
|
||||
{
|
||||
public BmsPosition(int measure, BmsFraction fraction)
|
||||
{
|
||||
Measure = measure;
|
||||
Fraction = fraction;
|
||||
}
|
||||
|
||||
public int Measure { get; }
|
||||
public BmsFraction Fraction { get; }
|
||||
|
||||
public int CompareTo(BmsPosition other)
|
||||
{
|
||||
var measureComparison = Measure.CompareTo(other.Measure);
|
||||
return measureComparison != 0 ? measureComparison : Fraction.CompareTo(other.Fraction);
|
||||
}
|
||||
|
||||
public bool Equals(BmsPosition other) => Measure == other.Measure && Fraction.Equals(other.Fraction);
|
||||
public override bool Equals(object obj) => obj is BmsPosition other && Equals(other);
|
||||
public override int GetHashCode() => HashCode.Combine(Measure, Fraction);
|
||||
}
|
||||
|
||||
public sealed class BmsMetadata
|
||||
{
|
||||
public string ChartPath { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = "Untitled";
|
||||
public string Subtitle { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = "Unknown Artist";
|
||||
public string SubArtist { get; set; } = string.Empty;
|
||||
public string Genre { get; set; } = string.Empty;
|
||||
public string StageFile { get; set; } = string.Empty;
|
||||
public string BackBmp { get; set; } = string.Empty;
|
||||
public string Md5 { get; set; } = string.Empty;
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
public double InitialBpm { get; set; } = 120.0;
|
||||
public double MinimumBpm { get; set; }
|
||||
public double MaximumBpm { get; set; }
|
||||
public double Total { get; set; }
|
||||
public int PlayLevel { get; set; }
|
||||
public int Difficulty { get; set; }
|
||||
public int Rank { get; set; } = 3;
|
||||
public int LnType { get; set; } = 1;
|
||||
public string LnObj { get; set; } = string.Empty;
|
||||
public int ObjectBase { get; set; } = 36;
|
||||
public int KeyCount { get; set; } = 5;
|
||||
public bool HasScratch { get; set; }
|
||||
public int UsedKeyChannelMask { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BmsRawEvent
|
||||
{
|
||||
public BmsPosition Position { get; set; }
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
public string ObjectId { get; set; } = string.Empty;
|
||||
public int Sequence { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BmsDocument
|
||||
{
|
||||
public BmsMetadata Metadata { get; } = new BmsMetadata();
|
||||
public Dictionary<string, string> WavDefinitions { get; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
public Dictionary<string, string> BmpDefinitions { get; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
public Dictionary<string, double> ExtendedBpms { get; } = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
public Dictionary<string, double> Stops { get; } = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
public Dictionary<string, double> Scrolls { get; } = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
public Dictionary<int, double> MeasureLengths { get; } = new Dictionary<int, double>();
|
||||
public List<BmsRawEvent> Events { get; } = new List<BmsRawEvent>();
|
||||
public List<string> Warnings { get; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class BmsNote
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Lane { get; set; }
|
||||
public double StartTime { get; set; }
|
||||
public double EndTime { get; set; }
|
||||
public double StartBeat { get; set; } = double.NaN;
|
||||
public double EndBeat { get; set; } = double.NaN;
|
||||
public string SoundId { get; set; } = string.Empty;
|
||||
public string TailSoundId { get; set; } = string.Empty;
|
||||
public bool IsScratch { get; set; }
|
||||
public bool IsLong => EndTime > StartTime;
|
||||
}
|
||||
|
||||
public sealed class BmsAudioEvent
|
||||
{
|
||||
public double Time { get; set; }
|
||||
public string SoundId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class BmsLaneSoundEvent
|
||||
{
|
||||
public int Lane { get; set; }
|
||||
public double Time { get; set; }
|
||||
public string SoundId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum BmsBgaLayer
|
||||
{
|
||||
Base,
|
||||
Poor,
|
||||
Layer,
|
||||
}
|
||||
|
||||
public sealed class BmsBgaEvent
|
||||
{
|
||||
public double Time { get; set; }
|
||||
public string ResourceId { get; set; } = string.Empty;
|
||||
public BmsBgaLayer Layer { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BmsChart
|
||||
{
|
||||
public BmsMetadata Metadata { get; set; } = new BmsMetadata();
|
||||
public TempoMap TempoMap { get; set; }
|
||||
public ScrollMap ScrollMap { get; set; }
|
||||
public Dictionary<string, string> WavDefinitions { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
public Dictionary<string, string> BmpDefinitions { get; set; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
public List<BmsNote> Notes { get; } = new List<BmsNote>();
|
||||
public List<BmsAudioEvent> BackgroundSounds { get; } = new List<BmsAudioEvent>();
|
||||
public List<BmsLaneSoundEvent> LaneSoundEvents { get; } = new List<BmsLaneSoundEvent>();
|
||||
public List<BmsBgaEvent> BgaEvents { get; } = new List<BmsBgaEvent>();
|
||||
public List<string> Warnings { get; } = new List<string>();
|
||||
public int ScratchObjectCount { get; set; }
|
||||
public double Duration { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b835aeba53ee44eca669ba79b1f4fad
|
||||
@@ -0,0 +1,588 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace ProjectQuint.Core.Bms
|
||||
{
|
||||
public static class BmsParser
|
||||
{
|
||||
private static readonly Encoding ShiftJisEncoding = CreateShiftJisEncoding();
|
||||
|
||||
public static BmsDocument ParseFile(string chartPath, int randomSeed = 1, int forcedKeyCount = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(chartPath))
|
||||
throw new ArgumentException("Chart path is required.", nameof(chartPath));
|
||||
|
||||
var bytes = File.ReadAllBytes(chartPath);
|
||||
var document = ParseText(chartPath, Decode(bytes), randomSeed, true, forcedKeyCount);
|
||||
return document;
|
||||
}
|
||||
|
||||
public static BmsMetadata ReadMetadataFile(string chartPath, int randomSeed = 1, int forcedKeyCount = 0)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(chartPath);
|
||||
return ParseText(chartPath, Decode(bytes), randomSeed, false, forcedKeyCount).Metadata;
|
||||
}
|
||||
|
||||
public static BmsDocument ParseText(string chartPath, string text, int randomSeed = 1)
|
||||
{
|
||||
return ParseText(chartPath, text, randomSeed, true, 0);
|
||||
}
|
||||
|
||||
private static BmsDocument ParseText(
|
||||
string chartPath,
|
||||
string text,
|
||||
int randomSeed,
|
||||
bool includeEvents,
|
||||
int forcedKeyCount)
|
||||
{
|
||||
var document = new BmsDocument();
|
||||
document.Metadata.ChartPath = chartPath ?? string.Empty;
|
||||
|
||||
var processor = new ConditionalProcessor(randomSeed);
|
||||
var sequence = 0;
|
||||
var lines = (text ?? string.Empty)
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n');
|
||||
document.Metadata.ObjectBase = ResolveObjectBase(lines, randomSeed);
|
||||
|
||||
foreach (var originalLine in lines)
|
||||
{
|
||||
var line = originalLine.Trim();
|
||||
if (line.Length == 0 || line[0] != '#')
|
||||
continue;
|
||||
|
||||
if (processor.TryHandle(line))
|
||||
continue;
|
||||
|
||||
if (!processor.IsActive)
|
||||
continue;
|
||||
|
||||
if (TryParseMeasureLine(line, document, includeEvents, ref sequence))
|
||||
continue;
|
||||
|
||||
ParseHeaderLine(line, document);
|
||||
}
|
||||
|
||||
if (processor.HasOpenCondition)
|
||||
AddWarning(document, "Conditional block was not closed before EOF.");
|
||||
|
||||
FinalizeKeyLayout(document.Metadata, forcedKeyCount);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static bool TryParseMeasureLine(string line, BmsDocument document, bool includeEvents, ref int sequence)
|
||||
{
|
||||
if (line.Length < 7 || line[6] != ':' ||
|
||||
!char.IsDigit(line[1]) || !char.IsDigit(line[2]) || !char.IsDigit(line[3]))
|
||||
return false;
|
||||
|
||||
if (!int.TryParse(line.Substring(1, 3), NumberStyles.None, CultureInfo.InvariantCulture, out var measure))
|
||||
return false;
|
||||
|
||||
var channel = line.Substring(4, 2).ToUpperInvariant();
|
||||
var payload = line.Substring(7).Replace(" ", string.Empty).Replace("\t", string.Empty);
|
||||
|
||||
ObservePlayableChannel(document.Metadata, channel, payload);
|
||||
|
||||
if (channel == "02")
|
||||
{
|
||||
if (double.TryParse(payload, NumberStyles.Float, CultureInfo.InvariantCulture, out var length) && length > 0)
|
||||
document.MeasureLengths[measure] = length;
|
||||
else
|
||||
AddWarning(document, $"Invalid measure length at #{measure:000}02.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!includeEvents)
|
||||
return true;
|
||||
|
||||
if ((payload.Length & 1) != 0)
|
||||
{
|
||||
AddWarning(document, $"Odd object data length at #{measure:000}{channel}.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var resolution = payload.Length / 2;
|
||||
if (resolution == 0)
|
||||
return true;
|
||||
|
||||
for (var index = 0; index < resolution; index++)
|
||||
{
|
||||
var rawObjectId = payload.Substring(index * 2, 2);
|
||||
if (!TryNormalizeObjectId(rawObjectId, document.Metadata.ObjectBase, out var objectId))
|
||||
{
|
||||
AddWarning(
|
||||
document,
|
||||
$"Invalid base-{document.Metadata.ObjectBase} object id '{rawObjectId}' at " +
|
||||
$"#{measure:000}{channel}.");
|
||||
continue;
|
||||
}
|
||||
if (objectId == "00")
|
||||
continue;
|
||||
|
||||
document.Events.Add(new BmsRawEvent
|
||||
{
|
||||
Position = new BmsPosition(measure, new BmsFraction(index, resolution)),
|
||||
Channel = channel,
|
||||
ObjectId = objectId,
|
||||
Sequence = sequence++,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ParseHeaderLine(string line, BmsDocument document)
|
||||
{
|
||||
var separator = FindWhitespace(line);
|
||||
var rawCommand = separator < 0 ? line.Substring(1) : line.Substring(1, separator - 1);
|
||||
var command = rawCommand.ToUpperInvariant();
|
||||
var value = separator < 0 ? string.Empty : line.Substring(separator).Trim();
|
||||
|
||||
if (command.StartsWith("WAV", StringComparison.Ordinal) && command.Length == 5)
|
||||
{
|
||||
AddObjectDefinition(
|
||||
document,
|
||||
document.WavDefinitions,
|
||||
rawCommand.Substring(3, 2),
|
||||
value,
|
||||
"WAV");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.StartsWith("BMP", StringComparison.Ordinal) && command.Length == 5)
|
||||
{
|
||||
AddObjectDefinition(
|
||||
document,
|
||||
document.BmpDefinitions,
|
||||
rawCommand.Substring(3, 2),
|
||||
value,
|
||||
"BMP");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.StartsWith("BPM", StringComparison.Ordinal) && command.Length == 5)
|
||||
{
|
||||
if (!TryNormalizeObjectId(
|
||||
rawCommand.Substring(3, 2),
|
||||
document.Metadata.ObjectBase,
|
||||
out var objectId))
|
||||
AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} BPM object id: {rawCommand}");
|
||||
else if (TryParsePositiveDouble(value, out var bpm))
|
||||
document.ExtendedBpms[objectId] = bpm;
|
||||
else
|
||||
AddWarning(document, $"Invalid extended BPM: {rawCommand} {value}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.StartsWith("STOP", StringComparison.Ordinal) && command.Length == 6)
|
||||
{
|
||||
if (!TryNormalizeObjectId(
|
||||
rawCommand.Substring(4, 2),
|
||||
document.Metadata.ObjectBase,
|
||||
out var objectId))
|
||||
AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} STOP object id: {rawCommand}");
|
||||
else if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var stop) && stop >= 0)
|
||||
document.Stops[objectId] = stop;
|
||||
else
|
||||
AddWarning(document, $"Invalid STOP definition: {rawCommand} {value}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.StartsWith("SCROLL", StringComparison.Ordinal) && command.Length == 8)
|
||||
{
|
||||
if (!TryNormalizeObjectId(
|
||||
rawCommand.Substring(6, 2),
|
||||
document.Metadata.ObjectBase,
|
||||
out var objectId))
|
||||
AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} SCROLL object id: {rawCommand}");
|
||||
else if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var scroll) &&
|
||||
!double.IsNaN(scroll) && !double.IsInfinity(scroll))
|
||||
document.Scrolls[objectId] = scroll;
|
||||
else
|
||||
AddWarning(document, $"Invalid SCROLL definition: {rawCommand} {value}");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "TITLE": document.Metadata.Title = EmptyFallback(value, "Untitled"); break;
|
||||
case "SUBTITLE": document.Metadata.Subtitle = value; break;
|
||||
case "ARTIST": document.Metadata.Artist = EmptyFallback(value, "Unknown Artist"); break;
|
||||
case "SUBARTIST": document.Metadata.SubArtist = value; break;
|
||||
case "GENRE": document.Metadata.Genre = value; break;
|
||||
case "STAGEFILE": document.Metadata.StageFile = value; break;
|
||||
case "BACKBMP": document.Metadata.BackBmp = value; break;
|
||||
case "LNOBJ":
|
||||
if (TryNormalizeObjectId(value, document.Metadata.ObjectBase, out var lnObject))
|
||||
document.Metadata.LnObj = lnObject;
|
||||
else
|
||||
AddWarning(document, $"Invalid base-{document.Metadata.ObjectBase} LNOBJ id: {value}");
|
||||
break;
|
||||
case "BPM":
|
||||
if (TryParsePositiveDouble(value, out var bpm)) document.Metadata.InitialBpm = bpm;
|
||||
else AddWarning(document, $"Invalid initial BPM: {value}");
|
||||
break;
|
||||
case "TOTAL":
|
||||
if (TryParsePositiveDouble(value, out var total)) document.Metadata.Total = total;
|
||||
else AddWarning(document, $"Invalid TOTAL: {value}");
|
||||
break;
|
||||
case "PLAYLEVEL": document.Metadata.PlayLevel = ParseLeadingInteger(value); break;
|
||||
case "DIFFICULTY": document.Metadata.Difficulty = ParseLeadingInteger(value); break;
|
||||
case "RANK": document.Metadata.Rank = ParseLeadingInteger(value); break;
|
||||
case "LNTYPE":
|
||||
document.Metadata.LnType = ParseLeadingInteger(value);
|
||||
EnsureSupportedLongNoteNotation(document.Metadata.LnType);
|
||||
break;
|
||||
case "SCROLL": AddWarning(document, "SCROLL requires a two-character object id."); break;
|
||||
case "BASE": break;
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureSupportedLongNoteNotation(int lnType)
|
||||
{
|
||||
if (lnType == 2)
|
||||
throw new FormatException(
|
||||
"Unsupported BMS #LNTYPE 2 (obsolete MGQ long-note notation). " +
|
||||
"ProjectQuint supports #LNTYPE 1 and #LNOBJ charts.");
|
||||
}
|
||||
|
||||
private static int ResolveObjectBase(IReadOnlyList<string> lines, int randomSeed)
|
||||
{
|
||||
var objectBase = 36;
|
||||
var processor = new ConditionalProcessor(randomSeed);
|
||||
foreach (var originalLine in lines)
|
||||
{
|
||||
var line = (originalLine ?? string.Empty).Trim();
|
||||
if (line.Length == 0 || line[0] != '#')
|
||||
continue;
|
||||
if (processor.TryHandle(line) || !processor.IsActive)
|
||||
continue;
|
||||
|
||||
var separator = FindWhitespace(line);
|
||||
var command = (separator < 0
|
||||
? line.Substring(1)
|
||||
: line.Substring(1, separator - 1))
|
||||
.ToUpperInvariant();
|
||||
if (command != "BASE")
|
||||
continue;
|
||||
|
||||
var value = separator < 0 ? string.Empty : line.Substring(separator).Trim();
|
||||
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out objectBase) ||
|
||||
(objectBase != 36 && objectBase != 62))
|
||||
throw new FormatException(
|
||||
$"Unsupported BMS #BASE '{value}'. ProjectQuint supports only #BASE 36 and #BASE 62.");
|
||||
}
|
||||
return objectBase;
|
||||
}
|
||||
|
||||
private static void AddObjectDefinition(
|
||||
BmsDocument document,
|
||||
IDictionary<string, string> definitions,
|
||||
string rawObjectId,
|
||||
string value,
|
||||
string definitionType)
|
||||
{
|
||||
if (TryNormalizeObjectId(
|
||||
rawObjectId,
|
||||
document.Metadata.ObjectBase,
|
||||
out var objectId))
|
||||
definitions[objectId] = value;
|
||||
else
|
||||
AddWarning(
|
||||
document,
|
||||
$"Invalid base-{document.Metadata.ObjectBase} {definitionType} object id: {rawObjectId}");
|
||||
}
|
||||
|
||||
private static bool TryNormalizeObjectId(string rawObjectId, int objectBase, out string objectId)
|
||||
{
|
||||
objectId = string.Empty;
|
||||
if (string.IsNullOrEmpty(rawObjectId) || rawObjectId.Length != 2)
|
||||
return false;
|
||||
|
||||
for (var index = 0; index < rawObjectId.Length; index++)
|
||||
{
|
||||
var character = rawObjectId[index];
|
||||
if (!(character >= '0' && character <= '9') &&
|
||||
!(character >= 'A' && character <= 'Z') &&
|
||||
!(character >= 'a' && character <= 'z'))
|
||||
return false;
|
||||
}
|
||||
|
||||
objectId = objectBase == 36 ? rawObjectId.ToUpperInvariant() : rawObjectId;
|
||||
return objectBase == 36 || objectBase == 62;
|
||||
}
|
||||
|
||||
private static string Decode(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
|
||||
return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3);
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
|
||||
return Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2);
|
||||
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
|
||||
return Encoding.BigEndianUnicode.GetString(bytes, 2, bytes.Length - 2);
|
||||
|
||||
try
|
||||
{
|
||||
return new UTF8Encoding(false, true).GetString(bytes);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ShiftJisEncoding.GetString(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Encoding.Default.GetString(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Encoding CreateShiftJisEncoding()
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerType = Type.GetType(
|
||||
"System.Text.CodePagesEncodingProvider, System.Text.Encoding.CodePages",
|
||||
throwOnError: false);
|
||||
var provider = providerType?.GetProperty("Instance")?.GetValue(null) as EncodingProvider;
|
||||
if (provider != null)
|
||||
Encoding.RegisterProvider(provider);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Unity's Mono runtime already exposes legacy code pages directly. Registration
|
||||
// is only required by runtimes that split them into System.Text.Encoding.CodePages.
|
||||
}
|
||||
return Encoding.GetEncoding(
|
||||
932,
|
||||
EncoderFallback.ExceptionFallback,
|
||||
DecoderFallback.ExceptionFallback);
|
||||
}
|
||||
|
||||
private static int FindWhitespace(string line)
|
||||
{
|
||||
for (var index = 1; index < line.Length; index++)
|
||||
if (char.IsWhiteSpace(line[index]))
|
||||
return index;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool TryParsePositiveDouble(string value, out double result)
|
||||
{
|
||||
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result) && result > 0;
|
||||
}
|
||||
|
||||
private static int ParseLeadingInteger(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return 0;
|
||||
|
||||
var digits = new string(value.Trim().TakeWhile(char.IsDigit).ToArray());
|
||||
return int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out var result) ? result : 0;
|
||||
}
|
||||
|
||||
private static string EmptyFallback(string value, string fallback) =>
|
||||
string.IsNullOrWhiteSpace(value) ? fallback : value;
|
||||
|
||||
private static void AddWarning(BmsDocument document, string warning)
|
||||
{
|
||||
if (!document.Warnings.Contains(warning))
|
||||
document.Warnings.Add(warning);
|
||||
}
|
||||
|
||||
private static void ObservePlayableChannel(BmsMetadata metadata, string channel, string payload)
|
||||
{
|
||||
if (metadata == null || string.IsNullOrWhiteSpace(payload) || !ContainsPlayableObject(payload))
|
||||
return;
|
||||
|
||||
var keyIndex = BmsKeyLayout.ResolveStandardKeyIndex(channel);
|
||||
if (keyIndex >= 0)
|
||||
metadata.UsedKeyChannelMask |= 1 << keyIndex;
|
||||
if (channel == "16" || channel == "26" || channel == "56" || channel == "66")
|
||||
metadata.HasScratch = true;
|
||||
}
|
||||
|
||||
private static bool ContainsPlayableObject(string payload)
|
||||
{
|
||||
for (var index = 0; index + 1 < payload.Length; index += 2)
|
||||
if (payload[index] != '0' || payload[index + 1] != '0')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void FinalizeKeyLayout(BmsMetadata metadata, int forcedKeyCount)
|
||||
{
|
||||
if (forcedKeyCount >= 4 && forcedKeyCount <= 7)
|
||||
{
|
||||
metadata.KeyCount = forcedKeyCount;
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.UsedKeyChannelMask == BmsKeyLayout.SixKeyChannelMask)
|
||||
metadata.KeyCount = 6;
|
||||
else if ((metadata.UsedKeyChannelMask & (1 << 6)) != 0)
|
||||
metadata.KeyCount = 7;
|
||||
else if ((metadata.UsedKeyChannelMask & (1 << 5)) != 0)
|
||||
metadata.KeyCount = 6;
|
||||
else if (metadata.UsedKeyChannelMask == BmsKeyLayout.FourKeyChannelMask)
|
||||
metadata.KeyCount = 4;
|
||||
else
|
||||
metadata.KeyCount = 5;
|
||||
}
|
||||
|
||||
private static int CountBits(int value)
|
||||
{
|
||||
var count = 0;
|
||||
while (value != 0)
|
||||
{
|
||||
count += value & 1;
|
||||
value >>= 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private sealed class ConditionalProcessor
|
||||
{
|
||||
private readonly Random _random;
|
||||
private readonly Stack<int> _randomValues = new Stack<int>();
|
||||
private readonly Stack<ConditionalFrame> _conditions = new Stack<ConditionalFrame>();
|
||||
|
||||
public ConditionalProcessor(int seed)
|
||||
{
|
||||
_random = new Random(seed);
|
||||
}
|
||||
|
||||
public bool IsActive => _conditions.Count == 0 || _conditions.Peek().CurrentActive;
|
||||
public bool HasOpenCondition => _conditions.Count > 0;
|
||||
|
||||
public bool TryHandle(string line)
|
||||
{
|
||||
var separator = FindWhitespace(line);
|
||||
var command = (separator < 0 ? line.Substring(1) : line.Substring(1, separator - 1)).ToUpperInvariant();
|
||||
var valueText = separator < 0 ? string.Empty : line.Substring(separator).Trim();
|
||||
int.TryParse(valueText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value);
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "RANDOM":
|
||||
_randomValues.Push(value > 0 ? _random.Next(1, value + 1) : 1);
|
||||
return true;
|
||||
case "SETRANDOM":
|
||||
if (_randomValues.Count > 0) _randomValues.Pop();
|
||||
_randomValues.Push(value);
|
||||
return true;
|
||||
case "ENDRANDOM":
|
||||
if (_randomValues.Count > 0) _randomValues.Pop();
|
||||
return true;
|
||||
case "IF":
|
||||
{
|
||||
var parentActive = IsActive;
|
||||
var matched = (_randomValues.Count > 0 ? _randomValues.Peek() : 1) == value;
|
||||
_conditions.Push(new ConditionalFrame(parentActive, matched, parentActive && matched));
|
||||
return true;
|
||||
}
|
||||
case "ELSEIF":
|
||||
{
|
||||
if (_conditions.Count == 0) return true;
|
||||
var frame = _conditions.Pop();
|
||||
var matched = !frame.BranchMatched && (_randomValues.Count > 0 ? _randomValues.Peek() : 1) == value;
|
||||
frame.CurrentActive = frame.ParentActive && matched;
|
||||
frame.BranchMatched |= matched;
|
||||
_conditions.Push(frame);
|
||||
return true;
|
||||
}
|
||||
case "ELSE":
|
||||
{
|
||||
if (_conditions.Count == 0) return true;
|
||||
var frame = _conditions.Pop();
|
||||
var matched = !frame.BranchMatched;
|
||||
frame.CurrentActive = frame.ParentActive && matched;
|
||||
frame.BranchMatched = true;
|
||||
_conditions.Push(frame);
|
||||
return true;
|
||||
}
|
||||
case "ENDIF":
|
||||
if (_conditions.Count > 0) _conditions.Pop();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConditionalFrame
|
||||
{
|
||||
public ConditionalFrame(bool parentActive, bool branchMatched, bool currentActive)
|
||||
{
|
||||
ParentActive = parentActive;
|
||||
BranchMatched = branchMatched;
|
||||
CurrentActive = currentActive;
|
||||
}
|
||||
|
||||
public bool ParentActive;
|
||||
public bool BranchMatched;
|
||||
public bool CurrentActive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class BmsKeyLayout
|
||||
{
|
||||
public static readonly string[] StandardKeyChannels =
|
||||
{ "11", "12", "13", "14", "15", "18", "19" };
|
||||
public const int FourKeyChannelMask =
|
||||
(1 << 0) | (1 << 1) | (1 << 3) | (1 << 4);
|
||||
// Community 6K charts use the seven-key channel grid with the center
|
||||
// channel (14) empty: 11/12/13/[14]/15/18/19.
|
||||
public const int SixKeyChannelMask =
|
||||
(1 << 0) | (1 << 1) | (1 << 2) | (1 << 4) | (1 << 5) | (1 << 6);
|
||||
|
||||
public static int ResolveStandardKeyIndex(string channel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(channel) || channel.Length != 2)
|
||||
return -1;
|
||||
|
||||
var normalized = channel[0] == '5'
|
||||
? "1" + channel.Substring(1, 1)
|
||||
: channel;
|
||||
return Array.IndexOf(StandardKeyChannels, normalized);
|
||||
}
|
||||
|
||||
public static string[] ResolveKeyChannels(BmsMetadata metadata)
|
||||
{
|
||||
metadata ??= new BmsMetadata();
|
||||
var keyCount = Math.Max(4, Math.Min(7, metadata.KeyCount));
|
||||
var channels = new List<string>(keyCount);
|
||||
|
||||
if (keyCount == 4 || keyCount == 6)
|
||||
{
|
||||
// Canonical 4K uses 11/12/14/15, while canonical 6K uses
|
||||
// 11/12/13/15/18/19. Authoritative pack/table contracts must preserve
|
||||
// these non-contiguous layouts even when a chart contains stray objects
|
||||
// in an otherwise unused center channel.
|
||||
var canonicalMask = keyCount == 4
|
||||
? FourKeyChannelMask
|
||||
: SixKeyChannelMask;
|
||||
var preferredMask = keyCount == 6 ||
|
||||
(metadata.UsedKeyChannelMask & canonicalMask) == canonicalMask
|
||||
? canonicalMask
|
||||
: metadata.UsedKeyChannelMask;
|
||||
for (var index = 0; index < StandardKeyChannels.Length && channels.Count < keyCount; index++)
|
||||
if ((preferredMask & (1 << index)) != 0)
|
||||
channels.Add(StandardKeyChannels[index]);
|
||||
}
|
||||
|
||||
for (var index = 0; index < StandardKeyChannels.Length && channels.Count < keyCount; index++)
|
||||
if (!channels.Contains(StandardKeyChannels[index]))
|
||||
channels.Add(StandardKeyChannels[index]);
|
||||
return channels.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 995f0998f5d77462eb5d93d5ddd9c47e
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Core.Bms
|
||||
{
|
||||
public readonly struct BmsPlayableChartConversion
|
||||
{
|
||||
public BmsPlayableChartConversion(PlayableChart chart, int[] laneMap)
|
||||
{
|
||||
Chart = chart ?? throw new ArgumentNullException(nameof(chart));
|
||||
LaneMap = laneMap ?? throw new ArgumentNullException(nameof(laneMap));
|
||||
}
|
||||
|
||||
public PlayableChart Chart { get; }
|
||||
public int[] LaneMap { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The only boundary that turns compiled BMS data into runtime gameplay data.
|
||||
/// It preserves scratch as a fixed left lane, applies modifiers to key lanes only,
|
||||
/// and leaves the source chart untouched.
|
||||
/// </summary>
|
||||
public static class BmsPlayableChartConverter
|
||||
{
|
||||
public static BmsPlayableChartConversion Convert(
|
||||
BmsChart source,
|
||||
LaneModifier laneModifier = LaneModifier.Off,
|
||||
int randomSeed = 0)
|
||||
{
|
||||
if (source == null)
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
|
||||
var keyCount = Math.Max(4, Math.Min(7, source.Metadata?.KeyCount ?? 5));
|
||||
var hasScratch = source.Metadata?.HasScratch == true;
|
||||
var laneMap = LanePermutation.Create(laneModifier, keyCount, hasScratch, randomSeed);
|
||||
var chart = new PlayableChart
|
||||
{
|
||||
Metadata = ConvertMetadata(source.Metadata, source.TempoMap),
|
||||
TempoMap = source.TempoMap ?? TempoMap.Constant(source.Metadata?.InitialBpm > 0
|
||||
? source.Metadata.InitialBpm
|
||||
: 120.0),
|
||||
AudioResources = new Dictionary<string, string>(
|
||||
source.WavDefinitions,
|
||||
StringComparer.Ordinal),
|
||||
VisualResources = new Dictionary<string, string>(
|
||||
source.BmpDefinitions,
|
||||
StringComparer.Ordinal),
|
||||
ScratchObjectCount = source.ScratchObjectCount,
|
||||
};
|
||||
chart.ScrollMap = source.ScrollMap ?? new ScrollMap(chart.TempoMap);
|
||||
chart.Warnings.AddRange(source.Warnings);
|
||||
|
||||
foreach (var note in source.Notes)
|
||||
{
|
||||
if (note.Lane < 0 || note.Lane >= laneMap.Length)
|
||||
continue;
|
||||
|
||||
chart.Notes.Add(new PlayableNote
|
||||
{
|
||||
Id = note.Id,
|
||||
Lane = laneMap[note.Lane],
|
||||
StartTime = note.StartTime,
|
||||
EndTime = note.EndTime,
|
||||
StartBeat = note.StartBeat,
|
||||
EndBeat = note.EndBeat,
|
||||
SoundId = note.SoundId,
|
||||
TailSoundId = note.TailSoundId,
|
||||
IsScratch = note.IsScratch,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var item in source.BackgroundSounds)
|
||||
{
|
||||
chart.BackgroundAudioEvents.Add(new PlayableAudioEvent
|
||||
{
|
||||
Time = item.Time,
|
||||
SoundId = item.SoundId,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var item in source.LaneSoundEvents)
|
||||
{
|
||||
if (item.Lane < 0 || item.Lane >= laneMap.Length)
|
||||
continue;
|
||||
chart.LaneSoundEvents.Add(new PlayableLaneSoundEvent
|
||||
{
|
||||
Lane = laneMap[item.Lane],
|
||||
Time = item.Time,
|
||||
SoundId = item.SoundId,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var item in source.BgaEvents)
|
||||
{
|
||||
chart.VisualEvents.Add(new PlayableVisualEvent
|
||||
{
|
||||
Time = item.Time,
|
||||
ResourceId = item.ResourceId,
|
||||
Layer = ConvertLayer(item.Layer),
|
||||
});
|
||||
}
|
||||
|
||||
chart.Notes.Sort((left, right) =>
|
||||
{
|
||||
var time = left.StartTime.CompareTo(right.StartTime);
|
||||
return time != 0 ? time : left.Lane.CompareTo(right.Lane);
|
||||
});
|
||||
chart.BackgroundAudioEvents.Sort((left, right) => left.Time.CompareTo(right.Time));
|
||||
chart.LaneSoundEvents.Sort((left, right) =>
|
||||
{
|
||||
var time = left.Time.CompareTo(right.Time);
|
||||
return time != 0 ? time : left.Lane.CompareTo(right.Lane);
|
||||
});
|
||||
chart.VisualEvents.Sort((left, right) => left.Time.CompareTo(right.Time));
|
||||
var lastNote = chart.Notes.Count == 0
|
||||
? 0.0
|
||||
: chart.Notes.Max(note => note.IsLong ? note.EndTime : note.StartTime);
|
||||
var lastBackgroundAudio = chart.BackgroundAudioEvents.Count == 0
|
||||
? 0.0
|
||||
: chart.BackgroundAudioEvents.Max(item => item.Time);
|
||||
chart.Duration = Math.Max(lastNote, lastBackgroundAudio) + 2.0;
|
||||
return new BmsPlayableChartConversion(chart, laneMap);
|
||||
}
|
||||
|
||||
private static PlayableChartMetadata ConvertMetadata(
|
||||
BmsMetadata source,
|
||||
TempoMap tempoMap)
|
||||
{
|
||||
source ??= new BmsMetadata();
|
||||
var minimumTempo = source.MinimumBpm;
|
||||
var maximumTempo = source.MaximumBpm;
|
||||
if (tempoMap?.Nodes != null && tempoMap.Nodes.Count > 0)
|
||||
{
|
||||
minimumTempo = tempoMap.Nodes.Min(node => node.Bpm);
|
||||
maximumTempo = tempoMap.Nodes.Max(node => node.Bpm);
|
||||
}
|
||||
return new PlayableChartMetadata
|
||||
{
|
||||
SourcePath = source.ChartPath,
|
||||
Title = source.Title,
|
||||
Subtitle = source.Subtitle,
|
||||
Artist = source.Artist,
|
||||
SubArtist = source.SubArtist,
|
||||
Genre = source.Genre,
|
||||
StageImage = source.StageFile,
|
||||
BackgroundImage = source.BackBmp,
|
||||
SourceHash = source.Md5,
|
||||
InitialTempo = source.InitialBpm,
|
||||
MinimumTempo = minimumTempo,
|
||||
MaximumTempo = maximumTempo,
|
||||
GaugeTotal = source.Total,
|
||||
PlayLevel = source.PlayLevel,
|
||||
Difficulty = source.Difficulty,
|
||||
KeyCount = Math.Max(4, Math.Min(7, source.KeyCount)),
|
||||
HasScratch = source.HasScratch,
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayableVisualLayer ConvertLayer(BmsBgaLayer layer) => layer switch
|
||||
{
|
||||
BmsBgaLayer.Base => PlayableVisualLayer.Base,
|
||||
BmsBgaLayer.Poor => PlayableVisualLayer.Poor,
|
||||
BmsBgaLayer.Layer => PlayableVisualLayer.Overlay,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(layer), layer, null),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61f617e6ec3b74517a372d950d8a7b95
|
||||
@@ -0,0 +1,493 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Core.Bms
|
||||
{
|
||||
public static class BmsTimelineCompiler
|
||||
{
|
||||
public static BmsChart Compile(BmsDocument document)
|
||||
{
|
||||
if (document == null)
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
|
||||
var chart = new BmsChart
|
||||
{
|
||||
Metadata = document.Metadata,
|
||||
WavDefinitions = new Dictionary<string, string>(document.WavDefinitions, StringComparer.Ordinal),
|
||||
BmpDefinitions = new Dictionary<string, string>(document.BmpDefinitions, StringComparer.Ordinal),
|
||||
};
|
||||
chart.Warnings.AddRange(document.Warnings);
|
||||
|
||||
var effectiveEvents = MergeDuplicateChannelRows(document.Events);
|
||||
var maxMeasure = Math.Max(
|
||||
effectiveEvents.Count == 0 ? 0 : effectiveEvents.Max(item => item.Position.Measure),
|
||||
document.MeasureLengths.Count == 0 ? 0 : document.MeasureLengths.Keys.Max());
|
||||
var measureStarts = BuildMeasureStarts(document.MeasureLengths, maxMeasure + 1);
|
||||
|
||||
var timedEvents = BuildTimedEvents(
|
||||
document,
|
||||
effectiveEvents,
|
||||
measureStarts,
|
||||
chart.Warnings,
|
||||
out var tempoMap);
|
||||
chart.TempoMap = tempoMap;
|
||||
chart.Metadata.MinimumBpm = tempoMap.Nodes.Min(node => node.Bpm);
|
||||
chart.Metadata.MaximumBpm = tempoMap.Nodes.Max(node => node.Bpm);
|
||||
chart.ScrollMap = CompileScrollMap(document, timedEvents, tempoMap, chart.Warnings);
|
||||
CompileBackgroundSounds(timedEvents, chart);
|
||||
CompileBgaEvents(timedEvents, chart);
|
||||
CompileVisibleNotes(timedEvents, document.Metadata.LnObj, chart);
|
||||
CompileLongNoteChannels(timedEvents, chart);
|
||||
CompileScratchObjects(timedEvents, document.Metadata.LnObj, chart);
|
||||
CompileInvisibleKeySounds(timedEvents, chart);
|
||||
|
||||
chart.ScratchObjectCount = timedEvents.Count(item =>
|
||||
item.Raw.Channel == "16" || item.Raw.Channel == "56" ||
|
||||
item.Raw.Channel == "26" || item.Raw.Channel == "66");
|
||||
|
||||
chart.Notes.Sort((left, right) =>
|
||||
{
|
||||
var time = left.StartTime.CompareTo(right.StartTime);
|
||||
return time != 0 ? time : left.Lane.CompareTo(right.Lane);
|
||||
});
|
||||
for (var index = 0; index < chart.Notes.Count; index++)
|
||||
chart.Notes[index].Id = index;
|
||||
|
||||
chart.BackgroundSounds.Sort((left, right) => left.Time.CompareTo(right.Time));
|
||||
chart.LaneSoundEvents.Sort((left, right) =>
|
||||
{
|
||||
var time = left.Time.CompareTo(right.Time);
|
||||
return time != 0 ? time : left.Lane.CompareTo(right.Lane);
|
||||
});
|
||||
chart.BgaEvents.Sort((left, right) => left.Time.CompareTo(right.Time));
|
||||
var lastNote = chart.Notes.Count == 0 ? 0.0 : chart.Notes.Max(note => note.IsLong ? note.EndTime : note.StartTime);
|
||||
var lastBgm = chart.BackgroundSounds.Count == 0 ? 0.0 : chart.BackgroundSounds.Max(item => item.Time);
|
||||
chart.Duration = Math.Max(lastNote, lastBgm) + 2.0;
|
||||
return chart;
|
||||
}
|
||||
|
||||
private static List<BmsRawEvent> MergeDuplicateChannelRows(IEnumerable<BmsRawEvent> events)
|
||||
{
|
||||
var backgroundEvents = new List<BmsRawEvent>();
|
||||
var merged = new Dictionary<MergeKey, BmsRawEvent>();
|
||||
|
||||
foreach (var item in events.OrderBy(item => item.Sequence))
|
||||
{
|
||||
if (item.Channel == "01")
|
||||
{
|
||||
backgroundEvents.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[new MergeKey(item.Position, item.Channel)] = item;
|
||||
}
|
||||
|
||||
backgroundEvents.AddRange(merged.Values);
|
||||
backgroundEvents.Sort(CompareRawEvents);
|
||||
return backgroundEvents;
|
||||
}
|
||||
|
||||
private static double[] BuildMeasureStarts(IReadOnlyDictionary<int, double> measureLengths, int measureCount)
|
||||
{
|
||||
var starts = new double[measureCount + 1];
|
||||
for (var measure = 0; measure < measureCount; measure++)
|
||||
{
|
||||
var ratio = measureLengths.TryGetValue(measure, out var customLength) ? customLength : 1.0;
|
||||
starts[measure + 1] = starts[measure] + 4.0 * ratio;
|
||||
}
|
||||
|
||||
return starts;
|
||||
}
|
||||
|
||||
private static List<TimedEvent> BuildTimedEvents(
|
||||
BmsDocument document,
|
||||
List<BmsRawEvent> events,
|
||||
double[] measureStarts,
|
||||
List<string> warnings,
|
||||
out TempoMap tempoMap)
|
||||
{
|
||||
var result = new List<TimedEvent>(events.Count);
|
||||
var bpm = document.Metadata.InitialBpm > 0 ? document.Metadata.InitialBpm : 120.0;
|
||||
var currentBeat = 0.0;
|
||||
var currentTime = 0.0;
|
||||
var tempoNodes = new List<TempoMapNode> { new TempoMapNode(0, 0, bpm) };
|
||||
var index = 0;
|
||||
|
||||
while (index < events.Count)
|
||||
{
|
||||
var position = events[index].Position;
|
||||
var beat = measureStarts[position.Measure] +
|
||||
4.0 * GetMeasureRatio(document.MeasureLengths, position.Measure) * position.Fraction.Value;
|
||||
var eventTime = currentTime + Math.Max(0.0, beat - currentBeat) * 60.0 / bpm;
|
||||
currentBeat = beat;
|
||||
|
||||
var groupStart = index;
|
||||
while (index < events.Count && events[index].Position.Equals(position))
|
||||
{
|
||||
result.Add(new TimedEvent(events[index], eventTime, beat));
|
||||
index++;
|
||||
}
|
||||
|
||||
var hasTempoChange = false;
|
||||
for (var eventIndex = groupStart; eventIndex < index; eventIndex++)
|
||||
{
|
||||
var item = events[eventIndex];
|
||||
if (item.Channel == "03" && int.TryParse(item.ObjectId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var directBpm) && directBpm > 0)
|
||||
{
|
||||
bpm = directBpm;
|
||||
hasTempoChange = true;
|
||||
}
|
||||
else if (item.Channel == "08" && document.ExtendedBpms.TryGetValue(item.ObjectId, out var extendedBpm) && extendedBpm > 0)
|
||||
{
|
||||
bpm = extendedBpm;
|
||||
hasTempoChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
var stopDuration = 0.0;
|
||||
for (var eventIndex = groupStart; eventIndex < index; eventIndex++)
|
||||
{
|
||||
var item = events[eventIndex];
|
||||
if (item.Channel != "09")
|
||||
continue;
|
||||
|
||||
if (document.Stops.TryGetValue(item.ObjectId, out var stopUnits))
|
||||
stopDuration += stopUnits * 60.0 / bpm / 48.0;
|
||||
else
|
||||
AddWarning(warnings, $"Undefined STOP object {item.ObjectId}.");
|
||||
}
|
||||
|
||||
if (hasTempoChange || stopDuration > 0)
|
||||
{
|
||||
var node = new TempoMapNode(beat, eventTime, bpm, stopDuration);
|
||||
if (Math.Abs(tempoNodes[tempoNodes.Count - 1].Beat - beat) <= 0.000000001)
|
||||
tempoNodes[tempoNodes.Count - 1] = node;
|
||||
else
|
||||
tempoNodes.Add(node);
|
||||
}
|
||||
currentTime = eventTime + stopDuration;
|
||||
}
|
||||
|
||||
tempoMap = new TempoMap(tempoNodes);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ScrollMap CompileScrollMap(
|
||||
BmsDocument document,
|
||||
IEnumerable<TimedEvent> events,
|
||||
TempoMap tempoMap,
|
||||
List<string> warnings)
|
||||
{
|
||||
var nodes = new List<ScrollMapNode>();
|
||||
foreach (var item in events)
|
||||
{
|
||||
if (item.Raw.Channel != "SC")
|
||||
continue;
|
||||
|
||||
if (!document.Scrolls.TryGetValue(item.Raw.ObjectId, out var multiplier))
|
||||
{
|
||||
AddWarning(warnings, $"Undefined SCROLL object {item.Raw.ObjectId}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.Add(new ScrollMapNode(item.Beat, multiplier));
|
||||
}
|
||||
return new ScrollMap(tempoMap, nodes);
|
||||
}
|
||||
|
||||
private static void CompileBackgroundSounds(IEnumerable<TimedEvent> events, BmsChart chart)
|
||||
{
|
||||
foreach (var item in events)
|
||||
{
|
||||
if (item.Raw.Channel != "01")
|
||||
continue;
|
||||
|
||||
chart.BackgroundSounds.Add(new BmsAudioEvent
|
||||
{
|
||||
Time = item.Time,
|
||||
SoundId = item.Raw.ObjectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompileBgaEvents(IEnumerable<TimedEvent> events, BmsChart chart)
|
||||
{
|
||||
foreach (var item in events)
|
||||
{
|
||||
BmsBgaLayer layer;
|
||||
switch (item.Raw.Channel)
|
||||
{
|
||||
case "04": layer = BmsBgaLayer.Base; break;
|
||||
case "06": layer = BmsBgaLayer.Poor; break;
|
||||
case "07": layer = BmsBgaLayer.Layer; break;
|
||||
default: continue;
|
||||
}
|
||||
|
||||
chart.BgaEvents.Add(new BmsBgaEvent
|
||||
{
|
||||
Time = item.Time,
|
||||
ResourceId = item.Raw.ObjectId,
|
||||
Layer = layer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompileVisibleNotes(List<TimedEvent> events, string lnObject, BmsChart chart)
|
||||
{
|
||||
var channels = BmsKeyLayout.ResolveKeyChannels(chart.Metadata);
|
||||
var keyOffset = chart.Metadata.HasScratch ? 1 : 0;
|
||||
for (var key = 0; key < channels.Length; key++)
|
||||
{
|
||||
var lane = key + keyOffset;
|
||||
var channel = channels[key];
|
||||
var laneEvents = events
|
||||
.Where(item => item.Raw.Channel == channel)
|
||||
.OrderBy(item => item.Time)
|
||||
.ThenBy(item => item.Raw.Sequence)
|
||||
.ToList();
|
||||
|
||||
var laneNotes = new List<BmsNote>();
|
||||
foreach (var item in laneEvents)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(lnObject) && string.Equals(item.Raw.ObjectId, lnObject, StringComparison.Ordinal))
|
||||
{
|
||||
var head = laneNotes.LastOrDefault(note => !note.IsLong && note.StartTime < item.Time);
|
||||
if (head == null)
|
||||
{
|
||||
AddWarning(chart.Warnings, $"LNOBJ tail without a head in lane {lane + 1}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
head.EndTime = item.Time;
|
||||
head.EndBeat = item.Beat;
|
||||
head.TailSoundId = item.Raw.ObjectId;
|
||||
continue;
|
||||
}
|
||||
|
||||
laneNotes.Add(new BmsNote
|
||||
{
|
||||
Lane = lane,
|
||||
StartTime = item.Time,
|
||||
EndTime = item.Time,
|
||||
StartBeat = item.Beat,
|
||||
EndBeat = item.Beat,
|
||||
SoundId = item.Raw.ObjectId,
|
||||
});
|
||||
}
|
||||
|
||||
chart.Notes.AddRange(laneNotes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompileLongNoteChannels(List<TimedEvent> events, BmsChart chart)
|
||||
{
|
||||
var channels = BmsKeyLayout.ResolveKeyChannels(chart.Metadata);
|
||||
var keyOffset = chart.Metadata.HasScratch ? 1 : 0;
|
||||
for (var key = 0; key < channels.Length; key++)
|
||||
{
|
||||
var lane = key + keyOffset;
|
||||
var channel = "5" + channels[key].Substring(1, 1);
|
||||
var laneEvents = events
|
||||
.Where(item => item.Raw.Channel == channel)
|
||||
.OrderBy(item => item.Time)
|
||||
.ThenBy(item => item.Raw.Sequence)
|
||||
.ToList();
|
||||
|
||||
for (var index = 0; index + 1 < laneEvents.Count; index += 2)
|
||||
{
|
||||
var head = laneEvents[index];
|
||||
var tail = laneEvents[index + 1];
|
||||
chart.Notes.Add(new BmsNote
|
||||
{
|
||||
Lane = lane,
|
||||
StartTime = head.Time,
|
||||
EndTime = tail.Time,
|
||||
StartBeat = head.Beat,
|
||||
EndBeat = tail.Beat,
|
||||
SoundId = head.Raw.ObjectId,
|
||||
TailSoundId = tail.Raw.ObjectId,
|
||||
});
|
||||
}
|
||||
|
||||
if ((laneEvents.Count & 1) != 0)
|
||||
AddWarning(chart.Warnings, $"Unpaired long-note object in lane {lane + 1}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompileScratchObjects(List<TimedEvent> events, string lnObject, BmsChart chart)
|
||||
{
|
||||
if (!chart.Metadata.HasScratch)
|
||||
return;
|
||||
|
||||
CompileVisibleChannel(events, "16", 0, lnObject, chart, isScratch: true);
|
||||
CompileVisibleChannel(events, "26", 0, lnObject, chart, isScratch: true);
|
||||
CompileLongNoteChannel(events, "56", 0, chart, isScratch: true);
|
||||
CompileLongNoteChannel(events, "66", 0, chart, isScratch: true);
|
||||
}
|
||||
|
||||
private static void CompileInvisibleKeySounds(List<TimedEvent> events, BmsChart chart)
|
||||
{
|
||||
var channels = BmsKeyLayout.ResolveKeyChannels(chart.Metadata);
|
||||
var keyOffset = chart.Metadata.HasScratch ? 1 : 0;
|
||||
for (var key = 0; key < channels.Length; key++)
|
||||
{
|
||||
var visibleChannel = channels[key];
|
||||
if (visibleChannel.Length != 2)
|
||||
continue;
|
||||
CompileInvisibleChannel(
|
||||
events,
|
||||
"3" + visibleChannel.Substring(1, 1),
|
||||
key + keyOffset,
|
||||
chart);
|
||||
}
|
||||
|
||||
if (chart.Metadata.HasScratch)
|
||||
{
|
||||
CompileInvisibleChannel(events, "36", 0, chart);
|
||||
CompileInvisibleChannel(events, "46", 0, chart);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompileInvisibleChannel(
|
||||
IEnumerable<TimedEvent> events,
|
||||
string channel,
|
||||
int lane,
|
||||
BmsChart chart)
|
||||
{
|
||||
foreach (var item in events)
|
||||
{
|
||||
if (item.Raw.Channel != channel || string.IsNullOrWhiteSpace(item.Raw.ObjectId))
|
||||
continue;
|
||||
chart.LaneSoundEvents.Add(new BmsLaneSoundEvent
|
||||
{
|
||||
Lane = lane,
|
||||
Time = item.Time,
|
||||
SoundId = item.Raw.ObjectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompileVisibleChannel(
|
||||
List<TimedEvent> events,
|
||||
string channel,
|
||||
int lane,
|
||||
string lnObject,
|
||||
BmsChart chart,
|
||||
bool isScratch = false)
|
||||
{
|
||||
var channelEvents = events
|
||||
.Where(item => item.Raw.Channel == channel)
|
||||
.OrderBy(item => item.Time)
|
||||
.ThenBy(item => item.Raw.Sequence)
|
||||
.ToList();
|
||||
var notes = new List<BmsNote>();
|
||||
foreach (var item in channelEvents)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(lnObject) &&
|
||||
string.Equals(item.Raw.ObjectId, lnObject, StringComparison.Ordinal))
|
||||
{
|
||||
var head = notes.LastOrDefault(note => !note.IsLong && note.StartTime < item.Time);
|
||||
if (head != null)
|
||||
{
|
||||
head.EndTime = item.Time;
|
||||
head.EndBeat = item.Beat;
|
||||
head.TailSoundId = item.Raw.ObjectId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
notes.Add(new BmsNote
|
||||
{
|
||||
Lane = lane,
|
||||
StartTime = item.Time,
|
||||
EndTime = item.Time,
|
||||
StartBeat = item.Beat,
|
||||
EndBeat = item.Beat,
|
||||
SoundId = item.Raw.ObjectId,
|
||||
IsScratch = isScratch,
|
||||
});
|
||||
}
|
||||
chart.Notes.AddRange(notes);
|
||||
}
|
||||
|
||||
private static void CompileLongNoteChannel(
|
||||
List<TimedEvent> events,
|
||||
string channel,
|
||||
int lane,
|
||||
BmsChart chart,
|
||||
bool isScratch = false)
|
||||
{
|
||||
var channelEvents = events
|
||||
.Where(item => item.Raw.Channel == channel)
|
||||
.OrderBy(item => item.Time)
|
||||
.ThenBy(item => item.Raw.Sequence)
|
||||
.ToList();
|
||||
for (var index = 0; index + 1 < channelEvents.Count; index += 2)
|
||||
{
|
||||
var head = channelEvents[index];
|
||||
var tail = channelEvents[index + 1];
|
||||
chart.Notes.Add(new BmsNote
|
||||
{
|
||||
Lane = lane,
|
||||
StartTime = head.Time,
|
||||
EndTime = tail.Time,
|
||||
StartBeat = head.Beat,
|
||||
EndBeat = tail.Beat,
|
||||
SoundId = head.Raw.ObjectId,
|
||||
TailSoundId = tail.Raw.ObjectId,
|
||||
IsScratch = isScratch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static double GetMeasureRatio(IReadOnlyDictionary<int, double> measureLengths, int measure)
|
||||
{
|
||||
return measureLengths.TryGetValue(measure, out var ratio) ? ratio : 1.0;
|
||||
}
|
||||
|
||||
private static int CompareRawEvents(BmsRawEvent left, BmsRawEvent right)
|
||||
{
|
||||
var position = left.Position.CompareTo(right.Position);
|
||||
return position != 0 ? position : left.Sequence.CompareTo(right.Sequence);
|
||||
}
|
||||
|
||||
private static void AddWarning(ICollection<string> warnings, string warning)
|
||||
{
|
||||
if (!warnings.Contains(warning))
|
||||
warnings.Add(warning);
|
||||
}
|
||||
|
||||
private readonly struct MergeKey : IEquatable<MergeKey>
|
||||
{
|
||||
public MergeKey(BmsPosition position, string channel)
|
||||
{
|
||||
Position = position;
|
||||
Channel = channel;
|
||||
}
|
||||
|
||||
private BmsPosition Position { get; }
|
||||
private string Channel { get; }
|
||||
|
||||
public bool Equals(MergeKey other) => Position.Equals(other.Position) && Channel == other.Channel;
|
||||
public override bool Equals(object obj) => obj is MergeKey other && Equals(other);
|
||||
public override int GetHashCode() => HashCode.Combine(Position, Channel);
|
||||
}
|
||||
|
||||
private sealed class TimedEvent
|
||||
{
|
||||
public TimedEvent(BmsRawEvent raw, double time, double beat)
|
||||
{
|
||||
Raw = raw;
|
||||
Time = time;
|
||||
Beat = beat;
|
||||
}
|
||||
|
||||
public BmsRawEvent Raw { get; }
|
||||
public double Time { get; }
|
||||
public double Beat { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: baa77492ad43445629ed6490886a40e3
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2bc03fb2c08c47b1b20db316c595ca2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Core.Charts
|
||||
{
|
||||
public enum LaneModifier
|
||||
{
|
||||
Off,
|
||||
Mirror,
|
||||
Random,
|
||||
}
|
||||
|
||||
public enum PlayableVisualLayer
|
||||
{
|
||||
Base,
|
||||
Poor,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
public static class ChartKeyMode
|
||||
{
|
||||
public static string FormatLabel(int keyCount, bool hasScratch)
|
||||
{
|
||||
var normalized = Math.Max(4, Math.Min(7, keyCount));
|
||||
return hasScratch ? $"1+{normalized}K" : $"{normalized}K";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlayableChartMetadata
|
||||
{
|
||||
public string SourcePath { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = "Untitled";
|
||||
public string Subtitle { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = "Unknown Artist";
|
||||
public string SubArtist { get; set; } = string.Empty;
|
||||
public string Genre { get; set; } = string.Empty;
|
||||
public string StageImage { get; set; } = string.Empty;
|
||||
public string BackgroundImage { get; set; } = string.Empty;
|
||||
public string SourceHash { get; set; } = string.Empty;
|
||||
public string DifficultyLabel { get; set; } = string.Empty;
|
||||
public double InitialTempo { get; set; } = 120.0;
|
||||
public double MinimumTempo { get; set; }
|
||||
public double MaximumTempo { get; set; }
|
||||
public double GaugeTotal { get; set; }
|
||||
public int PlayLevel { get; set; }
|
||||
public int Difficulty { get; set; }
|
||||
public int KeyCount { get; set; } = 5;
|
||||
public bool HasScratch { get; set; }
|
||||
public int TotalLaneCount => Math.Max(1, KeyCount) + (HasScratch ? 1 : 0);
|
||||
}
|
||||
|
||||
public sealed class PlayableNote
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Lane { get; set; }
|
||||
public double StartTime { get; set; }
|
||||
public double EndTime { get; set; }
|
||||
public double StartBeat { get; set; } = double.NaN;
|
||||
public double EndBeat { get; set; } = double.NaN;
|
||||
public string SoundId { get; set; } = string.Empty;
|
||||
public string TailSoundId { get; set; } = string.Empty;
|
||||
public bool IsScratch { get; set; }
|
||||
public bool IsLong => EndTime > StartTime;
|
||||
}
|
||||
|
||||
public sealed class PlayableAudioEvent
|
||||
{
|
||||
public double Time { get; set; }
|
||||
public string SoundId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PlayableLaneSoundEvent
|
||||
{
|
||||
public int Lane { get; set; }
|
||||
public double Time { get; set; }
|
||||
public string SoundId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PlayableVisualEvent
|
||||
{
|
||||
public double Time { get; set; }
|
||||
public string ResourceId { get; set; } = string.Empty;
|
||||
public PlayableVisualLayer Layer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format-neutral data consumed by gameplay, audio, BGA and result presentation.
|
||||
/// Source-format parsers must convert into this model before runtime systems see a chart.
|
||||
/// </summary>
|
||||
public sealed class PlayableChart
|
||||
{
|
||||
private static readonly TempoMap DefaultTempoMap = TempoMap.Constant(120.0);
|
||||
|
||||
public PlayableChartMetadata Metadata { get; set; } = new PlayableChartMetadata();
|
||||
public TempoMap TempoMap { get; set; } = DefaultTempoMap;
|
||||
public ScrollMap ScrollMap { get; set; } = new ScrollMap(DefaultTempoMap);
|
||||
public Dictionary<string, string> AudioResources { get; set; } =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
public Dictionary<string, string> VisualResources { get; set; } =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
public List<PlayableNote> Notes { get; } = new List<PlayableNote>();
|
||||
public List<PlayableAudioEvent> BackgroundAudioEvents { get; } = new List<PlayableAudioEvent>();
|
||||
public List<PlayableLaneSoundEvent> LaneSoundEvents { get; } = new List<PlayableLaneSoundEvent>();
|
||||
public List<PlayableVisualEvent> VisualEvents { get; } = new List<PlayableVisualEvent>();
|
||||
public List<string> Warnings { get; } = new List<string>();
|
||||
public int ScratchObjectCount { get; set; }
|
||||
public double Duration { get; set; }
|
||||
}
|
||||
|
||||
public static class LanePermutation
|
||||
{
|
||||
public static int[] Create(
|
||||
LaneModifier modifier,
|
||||
int keyCount = 5,
|
||||
bool hasScratch = false,
|
||||
int randomSeed = 0)
|
||||
{
|
||||
if (keyCount < 4 || keyCount > 7)
|
||||
throw new ArgumentOutOfRangeException(nameof(keyCount), "BMS key count must be between 4 and 7.");
|
||||
|
||||
var keyOffset = hasScratch ? 1 : 0;
|
||||
var laneMap = new int[keyCount + keyOffset];
|
||||
if (hasScratch)
|
||||
laneMap[0] = 0;
|
||||
for (var key = 0; key < keyCount; key++)
|
||||
laneMap[key + keyOffset] = key + keyOffset;
|
||||
|
||||
switch (modifier)
|
||||
{
|
||||
case LaneModifier.Off:
|
||||
return laneMap;
|
||||
case LaneModifier.Mirror:
|
||||
Array.Reverse(laneMap, keyOffset, keyCount);
|
||||
return laneMap;
|
||||
case LaneModifier.Random:
|
||||
{
|
||||
var random = new Random(randomSeed);
|
||||
for (var index = laneMap.Length - 1; index > keyOffset; index--)
|
||||
{
|
||||
var swapIndex = random.Next(keyOffset, index + 1);
|
||||
(laneMap[index], laneMap[swapIndex]) = (laneMap[swapIndex], laneMap[index]);
|
||||
}
|
||||
|
||||
var unchanged = true;
|
||||
for (var lane = keyOffset; lane < laneMap.Length; lane++)
|
||||
unchanged &= laneMap[lane] == lane;
|
||||
if (unchanged)
|
||||
(laneMap[keyOffset], laneMap[keyOffset + 1]) =
|
||||
(laneMap[keyOffset + 1], laneMap[keyOffset]);
|
||||
return laneMap;
|
||||
}
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(modifier), modifier, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 06a0a05da456f45cf80b873aee380ba0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4d67179ecaf44a5937315f956dfa7a9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,26 @@
|
||||
using ProjectQuint.Core.Charts;
|
||||
|
||||
namespace ProjectQuint.Core.Course
|
||||
{
|
||||
/// <summary>
|
||||
/// Competitive course runs permit only the authored lane order or its mirror.
|
||||
/// RANDOM is normalized to the authored order before any stage is transformed.
|
||||
/// </summary>
|
||||
public static class CourseLaneModifierPolicy
|
||||
{
|
||||
public static bool IsAllowed(LaneModifier modifier)
|
||||
{
|
||||
return modifier == LaneModifier.Off || modifier == LaneModifier.Mirror;
|
||||
}
|
||||
|
||||
public static LaneModifier Normalize(LaneModifier modifier)
|
||||
{
|
||||
return modifier == LaneModifier.Mirror ? LaneModifier.Mirror : LaneModifier.Off;
|
||||
}
|
||||
|
||||
public static LaneModifier Normalize(LaneModifier modifier, bool mirrorAllowed)
|
||||
{
|
||||
return mirrorAllowed ? Normalize(modifier) : LaneModifier.Off;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d88302233d278417d9827be5358f8de2
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
|
||||
namespace ProjectQuint.Core.Course
|
||||
{
|
||||
public readonly struct CourseStageResult
|
||||
{
|
||||
public CourseStageResult(RhythmJudgementEngine judgement, bool cleared)
|
||||
{
|
||||
if (judgement == null)
|
||||
throw new ArgumentNullException(nameof(judgement));
|
||||
|
||||
Cleared = cleared;
|
||||
Score = judgement.Score;
|
||||
AccuracyPercent = judgement.AccuracyPercent;
|
||||
ExScore = judgement.KoolCount * 2 + judgement.CoolCount;
|
||||
ExScoreRate = judgement.JudgedNoteCount <= 0
|
||||
? 0.0
|
||||
: ExScore * 100.0 / (judgement.JudgedNoteCount * 2.0);
|
||||
BreakPointCount = judgement.MissCount;
|
||||
MaxCombo = judgement.MaxCombo;
|
||||
JudgedNotes = judgement.JudgedNoteCount;
|
||||
TotalScoringUnits = judgement.TotalScoringUnitCount;
|
||||
KoolCount = judgement.KoolCount;
|
||||
CoolCount = judgement.CoolCount;
|
||||
GoodCount = judgement.GoodCount;
|
||||
MissCount = judgement.MissCount;
|
||||
PurePerfectCount = judgement.PurePerfectCount;
|
||||
}
|
||||
|
||||
public bool Cleared { get; }
|
||||
public int Score { get; }
|
||||
public double AccuracyPercent { get; }
|
||||
public int ExScore { get; }
|
||||
public double ExScoreRate { get; }
|
||||
public int BreakPointCount { get; }
|
||||
public int MaxCombo { get; }
|
||||
public int JudgedNotes { get; }
|
||||
public int TotalScoringUnits { get; }
|
||||
public int KoolCount { get; }
|
||||
public int CoolCount { get; }
|
||||
public int GoodCount { get; }
|
||||
public int MissCount { get; }
|
||||
public int PurePerfectCount { get; }
|
||||
}
|
||||
|
||||
public sealed class CourseProgress
|
||||
{
|
||||
public const int ScoreFormatVersion = 2;
|
||||
|
||||
private long _accuracyValueTotal;
|
||||
private int _stageScoreTotal;
|
||||
private readonly List<CourseStageResult> _stageResults =
|
||||
new List<CourseStageResult>(4);
|
||||
|
||||
public int StagesPlayed { get; private set; }
|
||||
public int ClearedStages { get; private set; }
|
||||
public int JudgedNotes { get; private set; }
|
||||
public int KoolCount { get; private set; }
|
||||
public int CoolCount { get; private set; }
|
||||
public int GoodCount { get; private set; }
|
||||
public int MissCount { get; private set; }
|
||||
public int FastCount { get; private set; }
|
||||
public int LateCount { get; private set; }
|
||||
public int PurePerfectCount { get; private set; }
|
||||
public int CurrentCombo { get; private set; }
|
||||
public int MaxCombo { get; private set; }
|
||||
public IReadOnlyList<CourseStageResult> StageResults => _stageResults;
|
||||
|
||||
public int ExScore => KoolCount * 2 + CoolCount;
|
||||
public int BreakPointCount => MissCount;
|
||||
public int Score => _stageScoreTotal;
|
||||
public double AccuracyPercent => JudgedNotes <= 0
|
||||
? 0.0
|
||||
: _accuracyValueTotal * 100.0 / (JudgedNotes * 1000.0);
|
||||
public double ExScoreRate => JudgedNotes <= 0
|
||||
? 0.0
|
||||
: ExScore * 100.0 / (JudgedNotes * 2.0);
|
||||
public double MissRate => JudgedNotes <= 0
|
||||
? 100.0
|
||||
: BreakPointCount * 100.0 / JudgedNotes;
|
||||
|
||||
public int CombinedScore(RhythmJudgementEngine currentStage)
|
||||
{
|
||||
return _stageScoreTotal + (currentStage?.Score ?? 0);
|
||||
}
|
||||
|
||||
public double CombinedAccuracyPercent(RhythmJudgementEngine currentStage)
|
||||
{
|
||||
var currentJudgedNotes = currentStage?.JudgedNoteCount ?? 0;
|
||||
var combinedJudgedNotes = JudgedNotes + currentJudgedNotes;
|
||||
if (combinedJudgedNotes <= 0)
|
||||
return 100.0;
|
||||
|
||||
var currentAccuracyValue =
|
||||
(currentStage?.KoolCount ?? 0) * 1000L +
|
||||
(currentStage?.CoolCount ?? 0) * 700L +
|
||||
(currentStage?.GoodCount ?? 0) * 200L;
|
||||
return (_accuracyValueTotal + currentAccuracyValue) * 100.0 /
|
||||
(combinedJudgedNotes * 1000.0);
|
||||
}
|
||||
|
||||
public void AddStage(RhythmJudgementEngine judgement, bool cleared)
|
||||
{
|
||||
if (judgement == null)
|
||||
throw new ArgumentNullException(nameof(judgement));
|
||||
|
||||
var stageResult = new CourseStageResult(judgement, cleared);
|
||||
_stageResults.Add(stageResult);
|
||||
StagesPlayed++;
|
||||
if (cleared)
|
||||
ClearedStages++;
|
||||
_stageScoreTotal += stageResult.Score;
|
||||
JudgedNotes += judgement.JudgedNoteCount;
|
||||
KoolCount += judgement.KoolCount;
|
||||
CoolCount += judgement.CoolCount;
|
||||
GoodCount += judgement.GoodCount;
|
||||
MissCount += judgement.MissCount;
|
||||
FastCount += judgement.FastCount;
|
||||
LateCount += judgement.LateCount;
|
||||
PurePerfectCount += judgement.PurePerfectCount;
|
||||
CurrentCombo = judgement.Combo;
|
||||
MaxCombo = Math.Max(MaxCombo, judgement.MaxCombo);
|
||||
_accuracyValueTotal +=
|
||||
judgement.KoolCount * 1000L +
|
||||
judgement.CoolCount * 700L +
|
||||
judgement.GoodCount * 200L;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50b6be1327764ee4ac0141e729418c72
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5538736f60e3447cb97e3a501798db0c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
|
||||
namespace ProjectQuint.Core.Gauge
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks survival-gauge fallbacks from the beginning of a song so that a
|
||||
/// transition uses the exact state the destination gauge would have had.
|
||||
/// All gauges are allocated up front; applying judgements does not allocate.
|
||||
/// </summary>
|
||||
public sealed class GaugeFallbackChain
|
||||
{
|
||||
private readonly PlayGauge[] _gauges;
|
||||
private int _activeIndex;
|
||||
|
||||
public GaugeFallbackChain(
|
||||
GaugeMode initialMode,
|
||||
double declaredTotal,
|
||||
int totalNotes,
|
||||
bool fallbackEnabled,
|
||||
double? restoredValue = null,
|
||||
CourseGaugeProfile courseGaugeProfile = CourseGaugeProfile.Lr2)
|
||||
{
|
||||
var canFallback =
|
||||
fallbackEnabled &&
|
||||
!restoredValue.HasValue &&
|
||||
initialMode != GaugeMode.Class;
|
||||
var modes = canFallback
|
||||
? ResolveModes(initialMode)
|
||||
: new[] { initialMode };
|
||||
|
||||
_gauges = new PlayGauge[modes.Length];
|
||||
for (var index = 0; index < modes.Length; index++)
|
||||
{
|
||||
_gauges[index] = new PlayGauge(
|
||||
modes[index],
|
||||
declaredTotal,
|
||||
totalNotes,
|
||||
index == 0 ? restoredValue : null,
|
||||
courseGaugeProfile);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayGauge ActiveGauge => _gauges[_activeIndex];
|
||||
public int GaugeCount => _gauges.Length;
|
||||
public bool HasFallback => _activeIndex + 1 < _gauges.Length;
|
||||
|
||||
public GaugeTransition Apply(JudgementResult result)
|
||||
{
|
||||
for (var index = 0; index < _gauges.Length; index++)
|
||||
_gauges[index].Apply(result);
|
||||
return AdvancePastFailedGauges();
|
||||
}
|
||||
|
||||
public GaugeTransition Apply(JudgementGrade grade)
|
||||
{
|
||||
for (var index = 0; index < _gauges.Length; index++)
|
||||
_gauges[index].Apply(grade);
|
||||
return AdvancePastFailedGauges();
|
||||
}
|
||||
|
||||
public GaugeTransition ApplyEmptyInputPenalty()
|
||||
{
|
||||
for (var index = 0; index < _gauges.Length; index++)
|
||||
_gauges[index].ApplyEmptyInputPenalty();
|
||||
return AdvancePastFailedGauges();
|
||||
}
|
||||
|
||||
internal PlayGauge GaugeAt(int index)
|
||||
{
|
||||
if (index < 0 || index >= _gauges.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
return _gauges[index];
|
||||
}
|
||||
|
||||
private GaugeTransition AdvancePastFailedGauges()
|
||||
{
|
||||
if (!ActiveGauge.IsFailed || !HasFallback)
|
||||
return GaugeTransition.None;
|
||||
|
||||
var previousMode = ActiveGauge.Mode;
|
||||
do
|
||||
{
|
||||
_activeIndex++;
|
||||
}
|
||||
while (ActiveGauge.IsFailed && HasFallback);
|
||||
|
||||
return new GaugeTransition(previousMode, ActiveGauge.Mode);
|
||||
}
|
||||
|
||||
private static GaugeMode[] ResolveModes(GaugeMode initialMode)
|
||||
{
|
||||
switch (initialMode)
|
||||
{
|
||||
case GaugeMode.ExHard:
|
||||
return new[] { GaugeMode.ExHard, GaugeMode.Hard, GaugeMode.Normal };
|
||||
case GaugeMode.Hard:
|
||||
return new[] { GaugeMode.Hard, GaugeMode.Normal };
|
||||
default:
|
||||
return new[] { initialMode };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct GaugeTransition
|
||||
{
|
||||
public static readonly GaugeTransition None = default;
|
||||
|
||||
public GaugeTransition(GaugeMode from, GaugeMode to)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
Changed = from != to;
|
||||
}
|
||||
|
||||
public GaugeMode From { get; }
|
||||
public GaugeMode To { get; }
|
||||
public bool Changed { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f9710e41db44ce3a2317d8ef1f95ed7
|
||||
@@ -0,0 +1,263 @@
|
||||
using System;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
|
||||
namespace ProjectQuint.Core.Gauge
|
||||
{
|
||||
public enum GaugeMode
|
||||
{
|
||||
Normal = 0,
|
||||
Hard = 1,
|
||||
Class = 2,
|
||||
ExHard = 3,
|
||||
}
|
||||
|
||||
public enum CourseGaugeProfile
|
||||
{
|
||||
Lr2,
|
||||
FiveKey,
|
||||
SevenKey,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns ProjectQuint's normal, hard, ex-hard, and course life-gauge state.
|
||||
/// Regular survival play uses one consistent ProjectQuint profile across
|
||||
/// every supported key mode. Source-specific course rules remain isolated
|
||||
/// in the course profile.
|
||||
/// </summary>
|
||||
public sealed class PlayGauge
|
||||
{
|
||||
public const double NormalInitialValue = 20.0;
|
||||
public const double NormalMinimumValue = 2.0;
|
||||
public const double NormalClearBorder = 75.0;
|
||||
public const double HardInitialValue = 100.0;
|
||||
public const double ClassInitialValue = 100.0;
|
||||
public const double MaximumValue = 100.0;
|
||||
|
||||
private readonly double _normalFullHitIncrement;
|
||||
private readonly double _standardHardRecoveryScale;
|
||||
private readonly CourseGaugeProfile _courseGaugeProfile;
|
||||
|
||||
public PlayGauge(
|
||||
GaugeMode mode,
|
||||
double declaredTotal,
|
||||
int totalNotes,
|
||||
double? restoredValue = null,
|
||||
CourseGaugeProfile courseGaugeProfile = CourseGaugeProfile.Lr2)
|
||||
{
|
||||
Mode = mode;
|
||||
TotalNotes = Math.Max(0, totalNotes);
|
||||
Total = declaredTotal > 0 ? declaredTotal : CalculateDefaultTotal(TotalNotes);
|
||||
_courseGaugeProfile = courseGaugeProfile;
|
||||
_normalFullHitIncrement = TotalNotes > 0 ? Total / TotalNotes : 0;
|
||||
_standardHardRecoveryScale = CalculateStandardHardRecoveryScale(Total, TotalNotes);
|
||||
Reset();
|
||||
if (restoredValue.HasValue)
|
||||
Restore(restoredValue.Value);
|
||||
}
|
||||
|
||||
public GaugeMode Mode { get; }
|
||||
public int TotalNotes { get; }
|
||||
public double Total { get; }
|
||||
public double Value { get; private set; }
|
||||
public double MinimumValue => Mode == GaugeMode.Normal ? NormalMinimumValue : 0.0;
|
||||
public double ClearBorder => Mode == GaugeMode.Normal ? NormalClearBorder : 0.0;
|
||||
public bool IsFailed => Mode != GaugeMode.Normal && Value <= 0.0;
|
||||
public bool IsCleared => Mode == GaugeMode.Normal
|
||||
? Value >= NormalClearBorder
|
||||
: !IsFailed;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Value = Mode switch
|
||||
{
|
||||
GaugeMode.Normal => NormalInitialValue,
|
||||
GaugeMode.Class => ClassInitialValue,
|
||||
_ => HardInitialValue,
|
||||
};
|
||||
}
|
||||
|
||||
public void Restore(double value)
|
||||
{
|
||||
Value = Math.Max(MinimumValue, Math.Min(MaximumValue, value));
|
||||
}
|
||||
|
||||
public void Apply(JudgementResult result)
|
||||
{
|
||||
Apply(result.Grade);
|
||||
}
|
||||
|
||||
public void Apply(JudgementGrade grade)
|
||||
{
|
||||
if (IsFailed || grade == JudgementGrade.None)
|
||||
return;
|
||||
|
||||
var delta = Mode switch
|
||||
{
|
||||
GaugeMode.Normal => ResolveNormalDelta(grade),
|
||||
GaugeMode.Class => ResolveCourseDelta(grade, Value, _courseGaugeProfile),
|
||||
GaugeMode.ExHard => ResolveExHardDelta(grade),
|
||||
_ => ResolveHardDelta(grade),
|
||||
};
|
||||
Value = Math.Max(MinimumValue, Math.Min(MaximumValue, Value + delta));
|
||||
}
|
||||
|
||||
public void ApplyEmptyInputPenalty()
|
||||
{
|
||||
if (IsFailed)
|
||||
return;
|
||||
|
||||
var delta = Mode switch
|
||||
{
|
||||
GaugeMode.Normal => -2.0,
|
||||
GaugeMode.Class => ResolveCourseEmptyInputDelta(Value, _courseGaugeProfile),
|
||||
GaugeMode.ExHard => -8.0,
|
||||
_ => ApplyStandardHardDamageReduction(-5.0, Value),
|
||||
};
|
||||
Value = Math.Max(MinimumValue, Math.Min(MaximumValue, Value + delta));
|
||||
}
|
||||
|
||||
public static double CalculateDefaultTotal(int totalNotes)
|
||||
{
|
||||
var notes = Math.Max(0, totalNotes);
|
||||
return Math.Max(260.0, 7.605 * notes / (0.01 * notes + 6.5));
|
||||
}
|
||||
|
||||
private double ResolveNormalDelta(JudgementGrade grade)
|
||||
{
|
||||
switch (grade)
|
||||
{
|
||||
case JudgementGrade.Kool:
|
||||
case JudgementGrade.Cool:
|
||||
return _normalFullHitIncrement;
|
||||
case JudgementGrade.Good:
|
||||
return _normalFullHitIncrement * 0.5;
|
||||
case JudgementGrade.Miss:
|
||||
return -6.0;
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveHardDelta(JudgementGrade grade)
|
||||
{
|
||||
switch (grade)
|
||||
{
|
||||
case JudgementGrade.Kool:
|
||||
return 0.15 * _standardHardRecoveryScale;
|
||||
case JudgementGrade.Cool:
|
||||
return 0.12 * _standardHardRecoveryScale;
|
||||
case JudgementGrade.Good:
|
||||
return 0.03 * _standardHardRecoveryScale;
|
||||
case JudgementGrade.Miss:
|
||||
return ApplyStandardHardDamageReduction(-10.0, Value);
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveExHardDelta(JudgementGrade grade)
|
||||
{
|
||||
switch (grade)
|
||||
{
|
||||
case JudgementGrade.Kool:
|
||||
return 0.15 * _standardHardRecoveryScale;
|
||||
case JudgementGrade.Cool:
|
||||
return 0.06 * _standardHardRecoveryScale;
|
||||
case JudgementGrade.Good:
|
||||
return 0.0;
|
||||
case JudgementGrade.Miss:
|
||||
return -16.0;
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
private static double ResolveCourseDelta(
|
||||
JudgementGrade grade,
|
||||
double currentValue,
|
||||
CourseGaugeProfile profile)
|
||||
{
|
||||
var delta = profile switch
|
||||
{
|
||||
CourseGaugeProfile.FiveKey => grade switch
|
||||
{
|
||||
JudgementGrade.Kool => 0.01,
|
||||
JudgementGrade.Cool => 0.01,
|
||||
JudgementGrade.Good => 0.0,
|
||||
JudgementGrade.Miss => -1.0,
|
||||
_ => 0.0,
|
||||
},
|
||||
CourseGaugeProfile.SevenKey => grade switch
|
||||
{
|
||||
JudgementGrade.Kool => 0.15,
|
||||
JudgementGrade.Cool => 0.12,
|
||||
JudgementGrade.Good => 0.06,
|
||||
JudgementGrade.Miss => -3.0,
|
||||
_ => 0.0,
|
||||
},
|
||||
_ => grade switch
|
||||
{
|
||||
JudgementGrade.Kool => 0.10,
|
||||
JudgementGrade.Cool => 0.10,
|
||||
JudgementGrade.Good => 0.05,
|
||||
JudgementGrade.Miss => -3.0,
|
||||
_ => 0.0,
|
||||
},
|
||||
};
|
||||
return ApplyCourseDamageReduction(delta, currentValue, profile);
|
||||
}
|
||||
|
||||
private static double ResolveCourseEmptyInputDelta(
|
||||
double currentValue,
|
||||
CourseGaugeProfile profile)
|
||||
{
|
||||
var delta = profile switch
|
||||
{
|
||||
CourseGaugeProfile.FiveKey => -0.5,
|
||||
CourseGaugeProfile.SevenKey => -1.5,
|
||||
_ => -2.0,
|
||||
};
|
||||
return ApplyCourseDamageReduction(delta, currentValue, profile);
|
||||
}
|
||||
|
||||
private static double ApplyCourseDamageReduction(
|
||||
double delta,
|
||||
double currentValue,
|
||||
CourseGaugeProfile profile)
|
||||
{
|
||||
if (delta >= 0.0 || profile == CourseGaugeProfile.FiveKey)
|
||||
return delta;
|
||||
if (profile == CourseGaugeProfile.Lr2)
|
||||
return currentValue < 30.0 ? delta * 0.6 : delta;
|
||||
if (currentValue < 5.0) return delta * 0.4;
|
||||
if (currentValue < 10.0) return delta * 0.5;
|
||||
if (currentValue < 15.0) return delta * 0.6;
|
||||
if (currentValue < 20.0) return delta * 0.7;
|
||||
if (currentValue < 25.0) return delta * 0.8;
|
||||
return delta;
|
||||
}
|
||||
|
||||
private static double CalculateStandardHardRecoveryScale(double total, int totalNotes)
|
||||
{
|
||||
if (totalNotes <= 0)
|
||||
return 0.0;
|
||||
|
||||
var fullHitRecovery = Math.Max(
|
||||
0.0,
|
||||
Math.Min(0.15, (2.0 * total - 320.0) / totalNotes));
|
||||
return fullHitRecovery / 0.15;
|
||||
}
|
||||
|
||||
private static double ApplyStandardHardDamageReduction(double delta, double currentValue)
|
||||
{
|
||||
if (delta >= 0.0)
|
||||
return delta;
|
||||
if (currentValue < 10.0) return delta * 0.4;
|
||||
if (currentValue < 20.0) return delta * 0.5;
|
||||
if (currentValue < 30.0) return delta * 0.6;
|
||||
if (currentValue < 40.0) return delta * 0.7;
|
||||
if (currentValue < 50.0) return delta * 0.8;
|
||||
return delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f17a55945f92a41b8b11cafbae0b788a
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ce17824f818ae4ce59c3510373fb2848
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
|
||||
namespace ProjectQuint.Core.Identity
|
||||
{
|
||||
public readonly struct ChartId : IEquatable<ChartId>
|
||||
{
|
||||
private ChartId(string value)
|
||||
{
|
||||
Value = value ?? string.Empty;
|
||||
}
|
||||
|
||||
public string Value { get; }
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(Value);
|
||||
|
||||
public static ChartId FromMd5(string md5) =>
|
||||
Create("md5", md5);
|
||||
|
||||
public static ChartId FromSha256(string sha256) =>
|
||||
Create("sha256", sha256);
|
||||
|
||||
public static ChartId FromPathFallback(string pathHash) =>
|
||||
Create("path-sha256", pathHash);
|
||||
|
||||
public bool Equals(ChartId other) =>
|
||||
string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
|
||||
public override bool Equals(object obj) =>
|
||||
obj is ChartId other && Equals(other);
|
||||
|
||||
public override int GetHashCode() =>
|
||||
StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
|
||||
|
||||
public override string ToString() => Value ?? string.Empty;
|
||||
|
||||
public static bool operator ==(ChartId left, ChartId right) => left.Equals(right);
|
||||
public static bool operator !=(ChartId left, ChartId right) => !left.Equals(right);
|
||||
|
||||
private static ChartId Create(string algorithm, string digest)
|
||||
{
|
||||
var normalized = (digest ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(normalized)
|
||||
? default
|
||||
: new ChartId($"{algorithm}:{normalized}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a50d6052e42b438f902e8410ddb3682
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 831a80b86d35b4af99c56b93f4ad623d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Core.Judgement
|
||||
{
|
||||
public sealed class LongNoteReleasePolicy
|
||||
{
|
||||
public static LongNoteReleasePolicy DjmaxLike { get; } = new LongNoteReleasePolicy(0.100);
|
||||
|
||||
public LongNoteReleasePolicy(double earlyReleaseGraceSeconds)
|
||||
{
|
||||
if (earlyReleaseGraceSeconds < 0 ||
|
||||
double.IsNaN(earlyReleaseGraceSeconds) ||
|
||||
double.IsInfinity(earlyReleaseGraceSeconds))
|
||||
throw new ArgumentOutOfRangeException(nameof(earlyReleaseGraceSeconds));
|
||||
EarlyReleaseGraceSeconds = earlyReleaseGraceSeconds;
|
||||
}
|
||||
|
||||
public double EarlyReleaseGraceSeconds { get; }
|
||||
|
||||
public bool Accepts(double releaseTime, double tailTime) =>
|
||||
releaseTime + EarlyReleaseGraceSeconds + 0.000001 >= tailTime;
|
||||
}
|
||||
|
||||
public sealed class LongNoteTickPolicy
|
||||
{
|
||||
public const double SixteenthNoteBeats = 0.25;
|
||||
public static LongNoteTickPolicy MusicalSixteenths { get; } =
|
||||
new LongNoteTickPolicy(SixteenthNoteBeats);
|
||||
|
||||
public LongNoteTickPolicy(double beatInterval)
|
||||
{
|
||||
if (!(beatInterval > 0) || double.IsNaN(beatInterval) || double.IsInfinity(beatInterval))
|
||||
throw new ArgumentOutOfRangeException(nameof(beatInterval));
|
||||
BeatInterval = beatInterval;
|
||||
}
|
||||
|
||||
public double BeatInterval { get; }
|
||||
|
||||
public double[] CreateTickTimes(PlayableNote note, TempoMap tempoMap)
|
||||
{
|
||||
if (note?.IsLong != true)
|
||||
return Array.Empty<double>();
|
||||
if (tempoMap == null)
|
||||
throw new ArgumentNullException(nameof(tempoMap));
|
||||
|
||||
var hasMusicalPosition = IsFinite(note.StartBeat) &&
|
||||
IsFinite(note.EndBeat) &&
|
||||
note.EndBeat > note.StartBeat;
|
||||
var startBeat = hasMusicalPosition
|
||||
? note.StartBeat
|
||||
: tempoMap.TimeToBeat(note.StartTime);
|
||||
var endBeat = hasMusicalPosition
|
||||
? note.EndBeat
|
||||
: tempoMap.TimeToBeat(note.EndTime);
|
||||
if (endBeat <= startBeat)
|
||||
return Array.Empty<double>();
|
||||
|
||||
var tickCount = (int)Math.Floor((endBeat - startBeat + 0.000000001) / BeatInterval);
|
||||
if (tickCount <= 0)
|
||||
return Array.Empty<double>();
|
||||
|
||||
var result = new List<double>(tickCount);
|
||||
for (var tick = 1; tick <= tickCount; tick++)
|
||||
{
|
||||
var beat = startBeat + tick * BeatInterval;
|
||||
if (beat > endBeat + 0.000000001)
|
||||
break;
|
||||
result.Add(Math.Min(note.EndTime, tempoMap.BeatToTime(beat)));
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsFinite(double value) =>
|
||||
!double.IsNaN(value) && !double.IsInfinity(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a557f3222278452785f524b4dd244e27
|
||||
@@ -0,0 +1,575 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Core.Judgement
|
||||
{
|
||||
public enum JudgementGrade
|
||||
{
|
||||
None,
|
||||
Kool,
|
||||
Cool,
|
||||
Good,
|
||||
Miss,
|
||||
}
|
||||
|
||||
public enum RuntimeNoteState
|
||||
{
|
||||
Pending,
|
||||
Holding,
|
||||
BrokenHolding,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public readonly struct JudgementResult
|
||||
{
|
||||
public JudgementResult(
|
||||
int noteId,
|
||||
JudgementGrade grade,
|
||||
double offsetSeconds,
|
||||
bool longNoteBreak = false,
|
||||
bool isHoldTick = false,
|
||||
bool suppressGameplayPenalty = false)
|
||||
{
|
||||
NoteId = noteId;
|
||||
Grade = grade;
|
||||
OffsetSeconds = offsetSeconds;
|
||||
LongNoteBreak = longNoteBreak;
|
||||
IsHoldTick = isHoldTick;
|
||||
SuppressGameplayPenalty = suppressGameplayPenalty;
|
||||
}
|
||||
|
||||
public int NoteId { get; }
|
||||
public JudgementGrade Grade { get; }
|
||||
public double OffsetSeconds { get; }
|
||||
public bool LongNoteBreak { get; }
|
||||
public bool IsHoldTick { get; }
|
||||
public bool SuppressGameplayPenalty { get; }
|
||||
public bool IsEarly => OffsetSeconds < 0;
|
||||
}
|
||||
|
||||
public readonly struct TimingSample
|
||||
{
|
||||
public TimingSample(double songTime, double offsetSeconds)
|
||||
{
|
||||
SongTime = songTime;
|
||||
OffsetSeconds = offsetSeconds;
|
||||
}
|
||||
|
||||
public double SongTime { get; }
|
||||
public double OffsetSeconds { get; }
|
||||
}
|
||||
|
||||
public sealed class JudgementProfile
|
||||
{
|
||||
public static JudgementProfile ProjectQuint { get; } = new JudgementProfile(
|
||||
koolSeconds: 0.042,
|
||||
coolSeconds: 0.083,
|
||||
goodSeconds: 0.125);
|
||||
|
||||
public JudgementProfile(double koolSeconds, double coolSeconds, double goodSeconds)
|
||||
{
|
||||
if (!(koolSeconds > 0 && koolSeconds <= coolSeconds && coolSeconds <= goodSeconds))
|
||||
throw new ArgumentException("Judgement windows must be positive and ascending.");
|
||||
|
||||
KoolSeconds = koolSeconds;
|
||||
CoolSeconds = coolSeconds;
|
||||
GoodSeconds = goodSeconds;
|
||||
}
|
||||
|
||||
public double KoolSeconds { get; }
|
||||
public double CoolSeconds { get; }
|
||||
public double GoodSeconds { get; }
|
||||
|
||||
public JudgementGrade Grade(double absoluteOffset)
|
||||
{
|
||||
if (absoluteOffset <= KoolSeconds) return JudgementGrade.Kool;
|
||||
if (absoluteOffset <= CoolSeconds) return JudgementGrade.Cool;
|
||||
if (absoluteOffset <= GoodSeconds) return JudgementGrade.Good;
|
||||
return JudgementGrade.Miss;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RhythmJudgementEngine
|
||||
{
|
||||
private const int MaxTimingSamples = 24;
|
||||
public const int TimingHistogramBinCount = 25;
|
||||
public const double PerfectTimingNeutralSeconds = 0.022;
|
||||
public const double LongNoteReleaseGraceSeconds = 0.100;
|
||||
private readonly JudgementProfile _profile;
|
||||
private readonly LongNoteReleasePolicy _longNoteReleasePolicy;
|
||||
private readonly Func<PlayableNote, bool> _suppressMissPenalty;
|
||||
private readonly List<RuntimeNote> _notes;
|
||||
private readonly List<RuntimeNote>[] _notesByLane;
|
||||
private readonly Dictionary<int, RuntimeNote> _notesById;
|
||||
private readonly List<TimingSample> _recentTimingSamples = new List<TimingSample>(MaxTimingSamples);
|
||||
private readonly int[] _timingHistogram = new int[TimingHistogramBinCount];
|
||||
private readonly int[] _lanePendingCursors;
|
||||
private readonly int[] _judgementCounts = new int[Enum.GetValues(typeof(JudgementGrade)).Length];
|
||||
private readonly RuntimeNote[] _activeLongNotes;
|
||||
private readonly List<RuntimeNote> _failedLongNotes;
|
||||
private readonly int _totalScoringUnitCount;
|
||||
private int _missCursor;
|
||||
private int _unfinishedNoteCount;
|
||||
private int _accuracyJudgedNotes;
|
||||
private long _accuracyValueTotal;
|
||||
private int _timingSampleCount;
|
||||
private int _suppressedMissCount;
|
||||
private double _timingOffsetTotalSeconds;
|
||||
|
||||
public event Action<JudgementResult> Judged;
|
||||
|
||||
public RhythmJudgementEngine(
|
||||
IEnumerable<PlayableNote> notes,
|
||||
JudgementProfile profile = null,
|
||||
int laneCount = 0,
|
||||
TempoMap tempoMap = null,
|
||||
LongNoteTickPolicy longNoteTickPolicy = null,
|
||||
LongNoteReleasePolicy longNoteReleasePolicy = null,
|
||||
Func<PlayableNote, bool> suppressMissPenalty = null)
|
||||
{
|
||||
_profile = profile ?? JudgementProfile.ProjectQuint;
|
||||
var effectiveTempoMap = tempoMap ?? TempoMap.Constant(150.0);
|
||||
var effectiveTickPolicy = longNoteTickPolicy ?? LongNoteTickPolicy.MusicalSixteenths;
|
||||
_longNoteReleasePolicy = longNoteReleasePolicy ?? LongNoteReleasePolicy.DjmaxLike;
|
||||
_suppressMissPenalty = suppressMissPenalty;
|
||||
_notes = (notes ?? Array.Empty<PlayableNote>())
|
||||
.Select(note => new RuntimeNote(
|
||||
note,
|
||||
effectiveTickPolicy.CreateTickTimes(note, effectiveTempoMap)))
|
||||
.OrderBy(note => note.Source.StartTime)
|
||||
.ThenBy(note => note.Source.Lane)
|
||||
.ToList();
|
||||
_notesById = _notes.ToDictionary(note => note.Source.Id);
|
||||
var inferredLaneCount = _notes.Count == 0
|
||||
? 5
|
||||
: _notes.Max(note => note.Source.Lane) + 1;
|
||||
laneCount = laneCount > 0 ? laneCount : inferredLaneCount;
|
||||
if (laneCount < 1 || laneCount > 8)
|
||||
throw new ArgumentOutOfRangeException(nameof(laneCount), "Gameplay lane count must be between 1 and 8.");
|
||||
_notesByLane = new List<RuntimeNote>[laneCount];
|
||||
_lanePendingCursors = new int[laneCount];
|
||||
_activeLongNotes = new RuntimeNote[laneCount];
|
||||
_failedLongNotes = new List<RuntimeNote>(laneCount);
|
||||
for (var lane = 0; lane < _notesByLane.Length; lane++)
|
||||
_notesByLane[lane] = new List<RuntimeNote>();
|
||||
foreach (var note in _notes)
|
||||
{
|
||||
if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length)
|
||||
_notesByLane[note.Source.Lane].Add(note);
|
||||
}
|
||||
_totalScoringUnitCount = _notes.Sum(note => 1 + note.TotalHoldTickCount);
|
||||
_unfinishedNoteCount = _notes.Count;
|
||||
}
|
||||
|
||||
public int Combo { get; private set; }
|
||||
public int MaxCombo { get; private set; }
|
||||
public int Score => _totalScoringUnitCount == 0
|
||||
? 0
|
||||
: (int)Math.Round(
|
||||
_accuracyValueTotal * 1_000_000.0 / (_totalScoringUnitCount * 1000.0),
|
||||
MidpointRounding.AwayFromZero) + PurePerfectCount;
|
||||
public int TotalScoringUnitCount => _totalScoringUnitCount;
|
||||
public int PurePerfectCount { get; private set; }
|
||||
public int KoolCount => GetJudgementCount(JudgementGrade.Kool);
|
||||
public int CoolCount => GetJudgementCount(JudgementGrade.Cool);
|
||||
public int GoodCount => GetJudgementCount(JudgementGrade.Good);
|
||||
public int MissCount => GetJudgementCount(JudgementGrade.Miss);
|
||||
public int VisibleMissCount => Math.Max(0, MissCount - _suppressedMissCount);
|
||||
public int FastCount { get; private set; }
|
||||
public int LateCount { get; private set; }
|
||||
public JudgementResult? LastResult { get; private set; }
|
||||
public double LastResultTime { get; private set; } = double.NegativeInfinity;
|
||||
public int ResultSequence { get; private set; }
|
||||
public IReadOnlyList<TimingSample> RecentTimingSamples => _recentTimingSamples;
|
||||
public IReadOnlyList<int> TimingHistogram => _timingHistogram;
|
||||
public double PerfectTimingWindowSeconds => _profile.KoolSeconds;
|
||||
public double TimingWindowSeconds => _profile.GoodSeconds;
|
||||
public double AverageTimingOffsetMilliseconds => _timingSampleCount == 0
|
||||
? 0
|
||||
: _timingOffsetTotalSeconds * 1000.0 / _timingSampleCount;
|
||||
public int JudgedNoteCount => _accuracyJudgedNotes;
|
||||
public double AccuracyPercent => _accuracyJudgedNotes == 0
|
||||
? 100.0
|
||||
: _accuracyValueTotal * 100.0 / (_accuracyJudgedNotes * 1000.0);
|
||||
public bool IsFinished => _unfinishedNoteCount == 0;
|
||||
|
||||
public void RestoreCourseCombo(int combo, int maxCombo)
|
||||
{
|
||||
Combo = Math.Max(0, combo);
|
||||
MaxCombo = Math.Max(Combo, maxCombo);
|
||||
}
|
||||
|
||||
public static bool IsPerfectTimingNeutral(double offsetSeconds) =>
|
||||
Math.Abs(offsetSeconds) <= PerfectTimingNeutralSeconds + 0.000000001;
|
||||
|
||||
public RuntimeNoteState GetState(int noteId)
|
||||
{
|
||||
return _notesById.TryGetValue(noteId, out var note) ? note.State : RuntimeNoteState.Completed;
|
||||
}
|
||||
|
||||
public int GetJudgementCount(JudgementGrade grade)
|
||||
{
|
||||
var index = (int)grade;
|
||||
return index > (int)JudgementGrade.None && index < _judgementCounts.Length
|
||||
? _judgementCounts[index]
|
||||
: 0;
|
||||
}
|
||||
|
||||
public JudgementResult? Press(int lane, double songTime, double timingOffsetSeconds = 0)
|
||||
{
|
||||
if (lane < 0 || lane >= _notesByLane.Length)
|
||||
return null;
|
||||
|
||||
var judgementTime = songTime + timingOffsetSeconds;
|
||||
ProcessPendingMisses(judgementTime, songTime);
|
||||
ProcessLongNoteTail(lane, songTime);
|
||||
AdvanceLaneCursor(lane);
|
||||
|
||||
RuntimeNote candidate = null;
|
||||
var candidateOffset = 0.0;
|
||||
var bestAbsoluteOffset = double.PositiveInfinity;
|
||||
var laneNotes = _notesByLane[lane];
|
||||
for (var index = _lanePendingCursors[lane]; index < laneNotes.Count; index++)
|
||||
{
|
||||
var note = laneNotes[index];
|
||||
if (note.Source.StartTime > judgementTime + _profile.GoodSeconds)
|
||||
break;
|
||||
if (note.State != RuntimeNoteState.Pending)
|
||||
continue;
|
||||
|
||||
var offset = judgementTime - note.Source.StartTime;
|
||||
var absoluteOffset = Math.Abs(offset);
|
||||
if (absoluteOffset > _profile.GoodSeconds || absoluteOffset >= bestAbsoluteOffset)
|
||||
continue;
|
||||
|
||||
candidate = note;
|
||||
candidateOffset = offset;
|
||||
bestAbsoluteOffset = absoluteOffset;
|
||||
}
|
||||
|
||||
if (candidate == null)
|
||||
return null;
|
||||
|
||||
var grade = _profile.Grade(bestAbsoluteOffset);
|
||||
if (candidate.Source.IsLong)
|
||||
{
|
||||
candidate.State = RuntimeNoteState.Holding;
|
||||
_activeLongNotes[lane] = candidate;
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.State = RuntimeNoteState.Completed;
|
||||
FinishNote(candidate);
|
||||
}
|
||||
ApplyHitScore(grade);
|
||||
RecordAccuracy(candidate, grade);
|
||||
if (grade == JudgementGrade.Kool && IsPerfectTimingNeutral(candidateOffset))
|
||||
PurePerfectCount++;
|
||||
RecordTiming(candidateOffset, songTime);
|
||||
AdvanceLaneCursor(lane);
|
||||
|
||||
var result = new JudgementResult(candidate.Source.Id, grade, candidateOffset);
|
||||
SetLastResult(result, songTime);
|
||||
return result;
|
||||
}
|
||||
|
||||
public JudgementResult? Release(int lane, double songTime)
|
||||
{
|
||||
if (lane < 0 || lane >= _activeLongNotes.Length)
|
||||
return null;
|
||||
|
||||
var held = _activeLongNotes[lane];
|
||||
if (held == null || held.State != RuntimeNoteState.Holding)
|
||||
return null;
|
||||
|
||||
AwardHoldComboTicks(held, Math.Min(songTime, held.Source.EndTime));
|
||||
if (!_longNoteReleasePolicy.Accepts(songTime, held.Source.EndTime))
|
||||
{
|
||||
held.State = RuntimeNoteState.BrokenHolding;
|
||||
Combo = 0;
|
||||
FinalizeUnawardedHoldTicks(held);
|
||||
var failure = new JudgementResult(
|
||||
held.Source.Id,
|
||||
JudgementGrade.Miss,
|
||||
songTime - held.Source.EndTime,
|
||||
longNoteBreak: true);
|
||||
SetLastResult(failure, songTime);
|
||||
return failure;
|
||||
}
|
||||
|
||||
AwardHoldComboTicks(held, held.Source.EndTime);
|
||||
held.State = RuntimeNoteState.Completed;
|
||||
_activeLongNotes[lane] = null;
|
||||
FinishNote(held);
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Update(double songTime, double timingOffsetSeconds = 0)
|
||||
{
|
||||
var judgementTime = songTime + timingOffsetSeconds;
|
||||
ProcessPendingMisses(judgementTime, songTime);
|
||||
ProcessFailedLongNoteTails(songTime);
|
||||
for (var lane = 0; lane < _activeLongNotes.Length; lane++)
|
||||
ProcessLongNoteTail(lane, songTime);
|
||||
}
|
||||
|
||||
private void ProcessFailedLongNoteTails(double songTime)
|
||||
{
|
||||
for (var index = _failedLongNotes.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var note = _failedLongNotes[index];
|
||||
if (songTime < note.Source.EndTime)
|
||||
continue;
|
||||
|
||||
note.State = RuntimeNoteState.Failed;
|
||||
FinishNote(note);
|
||||
_failedLongNotes.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessLongNoteTail(int lane, double judgementTime)
|
||||
{
|
||||
var note = _activeLongNotes[lane];
|
||||
if (note == null)
|
||||
return;
|
||||
|
||||
if (note.State == RuntimeNoteState.Holding)
|
||||
AwardHoldComboTicks(note, Math.Min(judgementTime, note.Source.EndTime));
|
||||
if (judgementTime < note.Source.EndTime)
|
||||
return;
|
||||
|
||||
note.State = note.State == RuntimeNoteState.Holding
|
||||
? RuntimeNoteState.Completed
|
||||
: RuntimeNoteState.Failed;
|
||||
_activeLongNotes[lane] = null;
|
||||
FinishNote(note);
|
||||
}
|
||||
|
||||
private void AwardHoldComboTicks(RuntimeNote note, double heldUntilTime)
|
||||
{
|
||||
if (note.State != RuntimeNoteState.Holding)
|
||||
return;
|
||||
|
||||
heldUntilTime = Math.Min(heldUntilTime, note.Source.EndTime);
|
||||
var earnedTickCount = note.AwardedHoldTickCount;
|
||||
while (earnedTickCount < note.HoldTickTimes.Length &&
|
||||
note.HoldTickTimes[earnedTickCount] <= heldUntilTime + 0.000001)
|
||||
earnedTickCount++;
|
||||
var newTickCount = earnedTickCount - note.AwardedHoldTickCount;
|
||||
if (newTickCount <= 0)
|
||||
return;
|
||||
|
||||
var firstNewTick = note.AwardedHoldTickCount + 1;
|
||||
note.AwardedHoldTickCount = earnedTickCount;
|
||||
Combo += newTickCount;
|
||||
MaxCombo = Math.Max(MaxCombo, Combo);
|
||||
|
||||
// A COOL-or-better head confirms that the player entered the long note
|
||||
// cleanly. Every subsequently held tick is therefore an exact KOOL/Pure
|
||||
// unit. Lower head grades stay lower so a late/early entry cannot be
|
||||
// repaired into a perfect long note merely by continuing to hold it.
|
||||
var headWasCoolOrBetter = note.FinalGrade == JudgementGrade.Kool ||
|
||||
note.FinalGrade == JudgementGrade.Cool;
|
||||
var tickGrade = headWasCoolOrBetter
|
||||
? JudgementGrade.Kool
|
||||
: note.FinalGrade;
|
||||
RecordScoringUnits(tickGrade, newTickCount);
|
||||
if (headWasCoolOrBetter)
|
||||
PurePerfectCount += newTickCount;
|
||||
|
||||
// Ticks are real score/combo judgements and must retrigger the HUD. They
|
||||
// are marked separately so presentation can react while the gauge does
|
||||
// not receive recovery or damage a second time for the same long note.
|
||||
for (var tick = firstNewTick; tick <= earnedTickCount; tick++)
|
||||
{
|
||||
var tickSongTime = note.HoldTickTimes[tick - 1];
|
||||
SetLastResult(
|
||||
new JudgementResult(
|
||||
note.Source.Id,
|
||||
tickGrade,
|
||||
offsetSeconds: 0,
|
||||
isHoldTick: true),
|
||||
tickSongTime);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessPendingMisses(double judgementTime, double songTime)
|
||||
{
|
||||
while (_missCursor < _notes.Count)
|
||||
{
|
||||
var note = _notes[_missCursor];
|
||||
if (judgementTime <= note.Source.StartTime + _profile.GoodSeconds)
|
||||
break;
|
||||
|
||||
_missCursor++;
|
||||
if (note.State != RuntimeNoteState.Pending)
|
||||
continue;
|
||||
|
||||
var deadLongNote = note.Source.IsLong && songTime < note.Source.EndTime;
|
||||
note.State = deadLongNote
|
||||
? RuntimeNoteState.BrokenHolding
|
||||
: RuntimeNoteState.Failed;
|
||||
if (deadLongNote)
|
||||
_failedLongNotes.Add(note);
|
||||
else
|
||||
FinishNote(note);
|
||||
var suppressGameplayPenalty =
|
||||
_suppressMissPenalty?.Invoke(note.Source) == true;
|
||||
if (!suppressGameplayPenalty)
|
||||
Combo = 0;
|
||||
var remainingHoldTicks = Math.Max(
|
||||
0,
|
||||
note.TotalHoldTickCount - note.AwardedHoldTickCount);
|
||||
RecordAccuracy(note, JudgementGrade.Miss);
|
||||
FinalizeUnawardedHoldTicks(note);
|
||||
if (suppressGameplayPenalty)
|
||||
{
|
||||
// Assisted scratch misses remain part of the final score and
|
||||
// result breakdown, but must be completely silent during play:
|
||||
// no MISS judgement event, no combo-break pulse, and no live
|
||||
// MISS counter increment.
|
||||
_suppressedMissCount += 1 + remainingHoldTicks;
|
||||
if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length)
|
||||
AdvanceLaneCursor(note.Source.Lane);
|
||||
continue;
|
||||
}
|
||||
SetLastResult(
|
||||
new JudgementResult(
|
||||
note.Source.Id,
|
||||
JudgementGrade.Miss,
|
||||
judgementTime - note.Source.StartTime),
|
||||
songTime);
|
||||
if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length)
|
||||
AdvanceLaneCursor(note.Source.Lane);
|
||||
}
|
||||
}
|
||||
|
||||
private void AdvanceLaneCursor(int lane)
|
||||
{
|
||||
var notes = _notesByLane[lane];
|
||||
var cursor = _lanePendingCursors[lane];
|
||||
while (cursor < notes.Count && notes[cursor].State != RuntimeNoteState.Pending)
|
||||
cursor++;
|
||||
_lanePendingCursors[lane] = cursor;
|
||||
}
|
||||
|
||||
private void FinishNote(RuntimeNote note)
|
||||
{
|
||||
if (note.IsFinished)
|
||||
return;
|
||||
|
||||
note.IsFinished = true;
|
||||
_unfinishedNoteCount--;
|
||||
}
|
||||
|
||||
private void ApplyHitScore(JudgementGrade grade)
|
||||
{
|
||||
Combo++;
|
||||
MaxCombo = Math.Max(MaxCombo, Combo);
|
||||
}
|
||||
|
||||
private void RecordAccuracy(RuntimeNote note, JudgementGrade grade)
|
||||
{
|
||||
if (note.AccuracyValue.HasValue)
|
||||
{
|
||||
_accuracyValueTotal -= note.AccuracyValue.Value;
|
||||
_judgementCounts[(int)note.FinalGrade]--;
|
||||
}
|
||||
else
|
||||
{
|
||||
_accuracyJudgedNotes++;
|
||||
}
|
||||
|
||||
var value = GradeValue(grade);
|
||||
note.FinalGrade = grade;
|
||||
note.AccuracyValue = value;
|
||||
_accuracyValueTotal += value;
|
||||
_judgementCounts[(int)grade]++;
|
||||
}
|
||||
|
||||
private void FinalizeUnawardedHoldTicks(RuntimeNote note)
|
||||
{
|
||||
if (note == null || note.HoldTicksFinalized)
|
||||
return;
|
||||
|
||||
note.HoldTicksFinalized = true;
|
||||
var remaining = note.TotalHoldTickCount - note.AwardedHoldTickCount;
|
||||
if (remaining > 0)
|
||||
RecordScoringUnits(JudgementGrade.Miss, remaining);
|
||||
}
|
||||
|
||||
private void RecordScoringUnits(JudgementGrade grade, int count)
|
||||
{
|
||||
if (count <= 0 || grade == JudgementGrade.None)
|
||||
return;
|
||||
|
||||
_accuracyJudgedNotes += count;
|
||||
_accuracyValueTotal += (long)GradeValue(grade) * count;
|
||||
_judgementCounts[(int)grade] += count;
|
||||
}
|
||||
|
||||
private void SetLastResult(JudgementResult result, double songTime)
|
||||
{
|
||||
LastResult = result;
|
||||
LastResultTime = songTime;
|
||||
ResultSequence++;
|
||||
Judged?.Invoke(result);
|
||||
}
|
||||
|
||||
private void RecordTiming(double offsetSeconds, double songTime)
|
||||
{
|
||||
_timingSampleCount++;
|
||||
_timingOffsetTotalSeconds += offsetSeconds;
|
||||
var normalizedOffset = Math.Max(-1.0, Math.Min(1.0, offsetSeconds / _profile.GoodSeconds));
|
||||
var histogramIndex = (int)Math.Round(
|
||||
(normalizedOffset + 1.0) * 0.5 * (TimingHistogramBinCount - 1),
|
||||
MidpointRounding.AwayFromZero);
|
||||
_timingHistogram[Math.Max(0, Math.Min(TimingHistogramBinCount - 1, histogramIndex))]++;
|
||||
if (IsPerfectTimingNeutral(offsetSeconds))
|
||||
return;
|
||||
|
||||
if (offsetSeconds < 0)
|
||||
FastCount++;
|
||||
else if (offsetSeconds > 0)
|
||||
LateCount++;
|
||||
|
||||
if (_recentTimingSamples.Count == MaxTimingSamples)
|
||||
_recentTimingSamples.RemoveAt(0);
|
||||
_recentTimingSamples.Add(new TimingSample(songTime, offsetSeconds));
|
||||
}
|
||||
|
||||
private static int GradeValue(JudgementGrade grade)
|
||||
{
|
||||
switch (grade)
|
||||
{
|
||||
case JudgementGrade.Kool: return 1000;
|
||||
case JudgementGrade.Cool: return 700;
|
||||
case JudgementGrade.Good: return 200;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RuntimeNote
|
||||
{
|
||||
public RuntimeNote(PlayableNote source, double[] holdTickTimes)
|
||||
{
|
||||
Source = source;
|
||||
HoldTickTimes = holdTickTimes ?? Array.Empty<double>();
|
||||
}
|
||||
|
||||
public PlayableNote Source { get; }
|
||||
public RuntimeNoteState State { get; set; }
|
||||
public JudgementGrade FinalGrade { get; set; }
|
||||
public int? AccuracyValue { get; set; }
|
||||
public bool IsFinished { get; set; }
|
||||
public int AwardedHoldTickCount { get; set; }
|
||||
public double[] HoldTickTimes { get; }
|
||||
public int TotalHoldTickCount => HoldTickTimes.Length;
|
||||
public bool HoldTicksFinalized { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97c17f28bde3e452bb4ad0bf51210d12
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc48a4e4f54c34f28870b1b3532ca1f0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ProjectQuint.Core.Progression
|
||||
{
|
||||
public readonly struct CollectionGroupSummary
|
||||
{
|
||||
public CollectionGroupSummary(
|
||||
string id,
|
||||
string displayName,
|
||||
int played,
|
||||
int normalClear,
|
||||
int hardClear,
|
||||
int failed,
|
||||
int fullCombo,
|
||||
int allPure)
|
||||
{
|
||||
Id = id ?? string.Empty;
|
||||
DisplayName = string.IsNullOrWhiteSpace(displayName) ? "UNKNOWN" : displayName;
|
||||
Played = Math.Max(0, played);
|
||||
NormalClear = Math.Max(0, normalClear);
|
||||
HardClear = Math.Max(0, hardClear);
|
||||
Failed = Math.Max(0, failed);
|
||||
FullCombo = Math.Max(0, fullCombo);
|
||||
AllPure = Math.Max(0, allPure);
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public string DisplayName { get; }
|
||||
public int Played { get; }
|
||||
public int NormalClear { get; }
|
||||
public int HardClear { get; }
|
||||
public int Failed { get; }
|
||||
public int FullCombo { get; }
|
||||
public int AllPure { get; }
|
||||
public int Cleared => NormalClear + HardClear;
|
||||
public double ClearRate => Played <= 0 ? 0.0 : Cleared * 100.0 / Played;
|
||||
}
|
||||
|
||||
public sealed class ProfileCollectionSnapshot
|
||||
{
|
||||
public static readonly ProfileCollectionSnapshot Empty = new ProfileCollectionSnapshot(
|
||||
default,
|
||||
Array.Empty<CollectionGroupSummary>(),
|
||||
Array.Empty<CollectionGroupSummary>(),
|
||||
Array.Empty<CollectionGroupSummary>());
|
||||
|
||||
public ProfileCollectionSnapshot(
|
||||
CollectionGroupSummary total,
|
||||
IReadOnlyList<CollectionGroupSummary> packs,
|
||||
IReadOnlyList<CollectionGroupSummary> keyModes,
|
||||
IReadOnlyList<CollectionGroupSummary> difficulties)
|
||||
{
|
||||
Total = total;
|
||||
Packs = packs ?? Array.Empty<CollectionGroupSummary>();
|
||||
KeyModes = keyModes ?? Array.Empty<CollectionGroupSummary>();
|
||||
Difficulties = difficulties ?? Array.Empty<CollectionGroupSummary>();
|
||||
}
|
||||
|
||||
public CollectionGroupSummary Total { get; }
|
||||
public IReadOnlyList<CollectionGroupSummary> Packs { get; }
|
||||
public IReadOnlyList<CollectionGroupSummary> KeyModes { get; }
|
||||
public IReadOnlyList<CollectionGroupSummary> Difficulties { get; }
|
||||
}
|
||||
|
||||
public readonly struct CourseCollectionEntry
|
||||
{
|
||||
public CourseCollectionEntry(
|
||||
string courseId,
|
||||
string providerDisplayName,
|
||||
string courseName,
|
||||
bool cleared,
|
||||
int playCount,
|
||||
int clearCount,
|
||||
int bestStageReached,
|
||||
double bestAccuracyPercent,
|
||||
int bestTrophyRank,
|
||||
string bestTrophyName)
|
||||
{
|
||||
CourseId = courseId ?? string.Empty;
|
||||
ProviderDisplayName = providerDisplayName ?? string.Empty;
|
||||
CourseName = string.IsNullOrWhiteSpace(courseName) ? "UNKNOWN COURSE" : courseName;
|
||||
Cleared = cleared;
|
||||
PlayCount = Math.Max(0, playCount);
|
||||
ClearCount = Math.Max(0, clearCount);
|
||||
BestStageReached = Math.Max(0, bestStageReached);
|
||||
BestAccuracyPercent = Math.Max(0.0, Math.Min(100.0, bestAccuracyPercent));
|
||||
BestTrophyRank = Math.Max(0, bestTrophyRank);
|
||||
BestTrophyName = bestTrophyName ?? string.Empty;
|
||||
}
|
||||
|
||||
public string CourseId { get; }
|
||||
public string ProviderDisplayName { get; }
|
||||
public string CourseName { get; }
|
||||
public bool Cleared { get; }
|
||||
public int PlayCount { get; }
|
||||
public int ClearCount { get; }
|
||||
public int BestStageReached { get; }
|
||||
public double BestAccuracyPercent { get; }
|
||||
public int BestTrophyRank { get; }
|
||||
public string BestTrophyName { get; }
|
||||
}
|
||||
|
||||
public readonly struct CourseCertificationBadge
|
||||
{
|
||||
public CourseCertificationBadge(CourseCollectionEntry course)
|
||||
{
|
||||
CourseId = course.CourseId;
|
||||
ProviderDisplayName = course.ProviderDisplayName;
|
||||
CourseName = course.CourseName;
|
||||
BestAccuracyPercent = course.BestAccuracyPercent;
|
||||
TrophyRank = course.BestTrophyRank;
|
||||
TrophyName = course.BestTrophyName;
|
||||
}
|
||||
|
||||
public string CourseId { get; }
|
||||
public string ProviderDisplayName { get; }
|
||||
public string CourseName { get; }
|
||||
public double BestAccuracyPercent { get; }
|
||||
public int TrophyRank { get; }
|
||||
public string TrophyName { get; }
|
||||
public string CertificationLabel => string.IsNullOrWhiteSpace(ProviderDisplayName)
|
||||
? CourseName
|
||||
: $"{ProviderDisplayName} // {CourseName}";
|
||||
}
|
||||
|
||||
public sealed class ProfileCourseSnapshot
|
||||
{
|
||||
public static readonly ProfileCourseSnapshot Empty = new ProfileCourseSnapshot(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Array.Empty<CourseCollectionEntry>());
|
||||
|
||||
public ProfileCourseSnapshot(
|
||||
int recordedCourses,
|
||||
int clearedCourses,
|
||||
int totalPlayCount,
|
||||
IReadOnlyList<CourseCollectionEntry> entries,
|
||||
string featuredBadgeCourseId = null)
|
||||
{
|
||||
RecordedCourses = Math.Max(0, recordedCourses);
|
||||
ClearedCourses = Math.Max(0, clearedCourses);
|
||||
TotalPlayCount = Math.Max(0, totalPlayCount);
|
||||
Entries = entries ?? Array.Empty<CourseCollectionEntry>();
|
||||
CertificationBadges = BuildCertificationBadges(Entries);
|
||||
FeaturedBadgeCourseId = ResolveFeaturedBadgeCourseId(
|
||||
CertificationBadges,
|
||||
featuredBadgeCourseId);
|
||||
}
|
||||
|
||||
public int RecordedCourses { get; }
|
||||
public int ClearedCourses { get; }
|
||||
public int TotalPlayCount { get; }
|
||||
public IReadOnlyList<CourseCollectionEntry> Entries { get; }
|
||||
public IReadOnlyList<CourseCertificationBadge> CertificationBadges { get; }
|
||||
public string FeaturedBadgeCourseId { get; }
|
||||
public int BadgeCount => CertificationBadges.Count;
|
||||
public bool HasFeaturedBadge => BadgeCount > 0;
|
||||
public CourseCertificationBadge FeaturedBadge
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var badge in CertificationBadges)
|
||||
{
|
||||
if (string.Equals(badge.CourseId, FeaturedBadgeCourseId, StringComparison.OrdinalIgnoreCase))
|
||||
return badge;
|
||||
}
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CourseCertificationBadge> BuildCertificationBadges(
|
||||
IReadOnlyList<CourseCollectionEntry> entries)
|
||||
{
|
||||
var badges = new List<CourseCertificationBadge>();
|
||||
foreach (var entry in entries ?? Array.Empty<CourseCollectionEntry>())
|
||||
{
|
||||
if (entry.Cleared)
|
||||
badges.Add(new CourseCertificationBadge(entry));
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
|
||||
private static string ResolveFeaturedBadgeCourseId(
|
||||
IReadOnlyList<CourseCertificationBadge> badges,
|
||||
string requestedCourseId)
|
||||
{
|
||||
var requested = (requestedCourseId ?? string.Empty).Trim();
|
||||
foreach (var badge in badges ?? Array.Empty<CourseCertificationBadge>())
|
||||
{
|
||||
if (string.Equals(badge.CourseId, requested, StringComparison.OrdinalIgnoreCase))
|
||||
return badge.CourseId;
|
||||
}
|
||||
return badges != null && badges.Count > 0 ? badges[0].CourseId : string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: abc0e008a277e4267aad53209773e6bb
|
||||
@@ -0,0 +1,390 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using ProjectQuint.Core.Charts;
|
||||
|
||||
namespace ProjectQuint.Core.Progression
|
||||
{
|
||||
public readonly struct RatingContribution
|
||||
{
|
||||
public RatingContribution(string chartId, double playRating)
|
||||
{
|
||||
ChartId = chartId ?? string.Empty;
|
||||
PlayRating = Math.Max(0.0, playRating);
|
||||
}
|
||||
|
||||
public string ChartId { get; }
|
||||
public double PlayRating { get; }
|
||||
}
|
||||
|
||||
public readonly struct RatingChartEntry
|
||||
{
|
||||
public RatingChartEntry(
|
||||
string chartId,
|
||||
string title,
|
||||
string artist,
|
||||
string difficultyLabel,
|
||||
double accuracyPercent,
|
||||
bool hardClear,
|
||||
double playRating)
|
||||
{
|
||||
ChartId = chartId ?? string.Empty;
|
||||
Title = string.IsNullOrWhiteSpace(title) ? "UNKNOWN CHART" : title;
|
||||
Artist = artist ?? string.Empty;
|
||||
DifficultyLabel = difficultyLabel ?? string.Empty;
|
||||
AccuracyPercent = Math.Max(0.0, Math.Min(100.0, accuracyPercent));
|
||||
HardClear = hardClear;
|
||||
PlayRating = Math.Max(0.0, playRating);
|
||||
}
|
||||
|
||||
public string ChartId { get; }
|
||||
public string Title { get; }
|
||||
public string Artist { get; }
|
||||
public string DifficultyLabel { get; }
|
||||
public double AccuracyPercent { get; }
|
||||
public bool HardClear { get; }
|
||||
public double PlayRating { get; }
|
||||
}
|
||||
|
||||
public readonly struct RatingSummary
|
||||
{
|
||||
public RatingSummary(
|
||||
string providerId,
|
||||
string providerDisplayName,
|
||||
int keyCount,
|
||||
bool hasScratch,
|
||||
double value,
|
||||
int filledSlots,
|
||||
IReadOnlyList<RatingChartEntry> bestPlays = null,
|
||||
int activityPlayCount = 0)
|
||||
{
|
||||
ProviderId = (providerId ?? string.Empty).Trim();
|
||||
ProviderDisplayName = string.IsNullOrWhiteSpace(providerDisplayName)
|
||||
? ProviderId
|
||||
: providerDisplayName.Trim();
|
||||
KeyCount = Math.Max(0, keyCount);
|
||||
HasScratch = hasScratch;
|
||||
Value = Math.Max(0.0, value);
|
||||
FilledSlots = Math.Max(0, Math.Min(RatingRules.BestCount, filledSlots));
|
||||
BestPlays = bestPlays ?? Array.Empty<RatingChartEntry>();
|
||||
ActivityPlayCount = Math.Max(0, activityPlayCount);
|
||||
}
|
||||
|
||||
public string ProviderId { get; }
|
||||
public string ProviderDisplayName { get; }
|
||||
public int KeyCount { get; }
|
||||
public bool HasScratch { get; }
|
||||
public double Value { get; }
|
||||
public int FilledSlots { get; }
|
||||
public IReadOnlyList<RatingChartEntry> BestPlays { get; }
|
||||
public int ActivityPlayCount { get; }
|
||||
public bool HasContext =>
|
||||
!string.IsNullOrWhiteSpace(ProviderId) &&
|
||||
KeyCount >= 4 && KeyCount <= 7;
|
||||
|
||||
public string ContextLabel => !HasContext
|
||||
? "RATING"
|
||||
: $"{ProviderDisplayName.ToUpperInvariant()} {RatingRules.KeyModeLabel(KeyCount, HasScratch)} RATING";
|
||||
}
|
||||
|
||||
public readonly struct RatingUpdate
|
||||
{
|
||||
public RatingUpdate(
|
||||
bool eligible,
|
||||
double playRating,
|
||||
RatingSummary previous,
|
||||
RatingSummary current)
|
||||
{
|
||||
Eligible = eligible;
|
||||
PlayRating = Math.Max(0.0, playRating);
|
||||
Previous = previous;
|
||||
Current = current;
|
||||
}
|
||||
|
||||
public bool Eligible { get; }
|
||||
public double PlayRating { get; }
|
||||
public RatingSummary Previous { get; }
|
||||
public RatingSummary Current { get; }
|
||||
public double Delta => Current.Value - Previous.Value;
|
||||
}
|
||||
|
||||
public readonly struct ProfileProgressSnapshot
|
||||
{
|
||||
public ProfileProgressSnapshot(
|
||||
string displayName,
|
||||
long totalXp,
|
||||
int validPlayCount,
|
||||
double activePlaySeconds,
|
||||
string lastRatingProviderId,
|
||||
string lastRatingProviderDisplayName,
|
||||
int lastRatingKeyCount,
|
||||
bool lastRatingHasScratch)
|
||||
{
|
||||
DisplayName = string.IsNullOrWhiteSpace(displayName) ? "PLAYER" : displayName;
|
||||
TotalXp = Math.Max(0, totalXp);
|
||||
Level = ProfileLevelRules.LevelForTotalXp(TotalXp);
|
||||
LevelStartXp = ProfileLevelRules.TotalXpForLevel(Level);
|
||||
NextLevelXp = ProfileLevelRules.TotalXpForLevel(Level + 1);
|
||||
ValidPlayCount = Math.Max(0, validPlayCount);
|
||||
ActivePlaySeconds = Math.Max(0.0, activePlaySeconds);
|
||||
LastRatingProviderId = (lastRatingProviderId ?? string.Empty).Trim();
|
||||
LastRatingProviderDisplayName = string.IsNullOrWhiteSpace(lastRatingProviderDisplayName)
|
||||
? LastRatingProviderId
|
||||
: lastRatingProviderDisplayName.Trim();
|
||||
LastRatingKeyCount = Math.Max(0, lastRatingKeyCount);
|
||||
LastRatingHasScratch = lastRatingHasScratch;
|
||||
}
|
||||
|
||||
public string DisplayName { get; }
|
||||
public long TotalXp { get; }
|
||||
public int Level { get; }
|
||||
public long LevelStartXp { get; }
|
||||
public long NextLevelXp { get; }
|
||||
public int ValidPlayCount { get; }
|
||||
public double ActivePlaySeconds { get; }
|
||||
public string LastRatingProviderId { get; }
|
||||
public string LastRatingProviderDisplayName { get; }
|
||||
public int LastRatingKeyCount { get; }
|
||||
public bool LastRatingHasScratch { get; }
|
||||
public long XpIntoLevel => TotalXp - LevelStartXp;
|
||||
public long XpForLevel => Math.Max(1, NextLevelXp - LevelStartXp);
|
||||
public double LevelProgress => Math.Max(0.0, Math.Min(1.0, XpIntoLevel / (double)XpForLevel));
|
||||
}
|
||||
|
||||
public readonly struct ProfileProgressUpdate
|
||||
{
|
||||
public ProfileProgressUpdate(
|
||||
bool applied,
|
||||
int xpAwarded,
|
||||
ProfileProgressSnapshot previous,
|
||||
ProfileProgressSnapshot current,
|
||||
bool firstPlay,
|
||||
bool firstNormalClear,
|
||||
bool firstHardClear,
|
||||
bool firstFullCombo,
|
||||
bool firstAllPure)
|
||||
{
|
||||
Applied = applied;
|
||||
XpAwarded = Math.Max(0, xpAwarded);
|
||||
Previous = previous;
|
||||
Current = current;
|
||||
FirstPlay = firstPlay;
|
||||
FirstNormalClear = firstNormalClear;
|
||||
FirstHardClear = firstHardClear;
|
||||
FirstFullCombo = firstFullCombo;
|
||||
FirstAllPure = firstAllPure;
|
||||
}
|
||||
|
||||
public bool Applied { get; }
|
||||
public int XpAwarded { get; }
|
||||
public ProfileProgressSnapshot Previous { get; }
|
||||
public ProfileProgressSnapshot Current { get; }
|
||||
public bool FirstPlay { get; }
|
||||
public bool FirstNormalClear { get; }
|
||||
public bool FirstHardClear { get; }
|
||||
public bool FirstFullCombo { get; }
|
||||
public bool FirstAllPure { get; }
|
||||
public bool LeveledUp => Current.Level > Previous.Level;
|
||||
}
|
||||
|
||||
public static class RatingRules
|
||||
{
|
||||
public const int RulesVersion = 5;
|
||||
public const int BestCount = 30;
|
||||
public const double HardClearBonus = 0.50;
|
||||
public const double MinimumChartConstant = 0.50;
|
||||
|
||||
private static readonly double[] AccuracyAnchors =
|
||||
{
|
||||
0.0, 80.0, 90.0, 95.0, 97.0, 98.0, 99.0, 99.5, 100.0,
|
||||
};
|
||||
|
||||
private static readonly double[] FactorAnchors =
|
||||
{
|
||||
0.0, 0.30, 0.60, 0.80, 0.88, 0.93, 0.97, 0.985, 1.0,
|
||||
};
|
||||
|
||||
public static double AccuracyFactor(double accuracyPercent)
|
||||
{
|
||||
var accuracy = Math.Max(0.0, Math.Min(100.0, accuracyPercent));
|
||||
for (var index = 1; index < AccuracyAnchors.Length; index++)
|
||||
{
|
||||
if (accuracy > AccuracyAnchors[index])
|
||||
continue;
|
||||
|
||||
var start = AccuracyAnchors[index - 1];
|
||||
var end = AccuracyAnchors[index];
|
||||
var progress = end <= start ? 0.0 : (accuracy - start) / (end - start);
|
||||
return FactorAnchors[index - 1] +
|
||||
(FactorAnchors[index] - FactorAnchors[index - 1]) * progress;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
public static double CalculatePlayRating(
|
||||
double chartConstant,
|
||||
double accuracyPercent,
|
||||
bool hardClear)
|
||||
{
|
||||
if (chartConstant <= 0.0)
|
||||
return 0.0;
|
||||
return chartConstant * AccuracyFactor(accuracyPercent) +
|
||||
(hardClear ? HardClearBonus : 0.0);
|
||||
}
|
||||
|
||||
public static RatingSummary CalculateBest30(
|
||||
string providerId,
|
||||
string providerDisplayName,
|
||||
int keyCount,
|
||||
bool hasScratch,
|
||||
IEnumerable<RatingContribution> contributions)
|
||||
{
|
||||
var bestByChart = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
foreach (var item in contributions ?? Array.Empty<RatingContribution>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.ChartId) || item.PlayRating <= 0.0)
|
||||
continue;
|
||||
if (!bestByChart.TryGetValue(item.ChartId, out var current) || item.PlayRating > current)
|
||||
bestByChart[item.ChartId] = item.PlayRating;
|
||||
}
|
||||
|
||||
var best = bestByChart.Values
|
||||
.OrderByDescending(value => value)
|
||||
.Take(BestCount)
|
||||
.ToArray();
|
||||
return new RatingSummary(
|
||||
providerId,
|
||||
providerDisplayName,
|
||||
keyCount,
|
||||
hasScratch,
|
||||
best.Sum() / BestCount,
|
||||
best.Length);
|
||||
}
|
||||
|
||||
public static string KeyModeLabel(int keyCount, bool hasScratch) =>
|
||||
ChartKeyMode.FormatLabel(keyCount, hasScratch);
|
||||
|
||||
public static bool TryResolveChartConstant(
|
||||
string difficultyValue,
|
||||
string difficultyLabel,
|
||||
out double chartConstant)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(difficultyValue))
|
||||
return TryParseChartConstant(difficultyValue, out chartConstant);
|
||||
return TryParseChartConstant(difficultyLabel, out chartConstant);
|
||||
}
|
||||
|
||||
public static bool TryParseChartConstant(string difficultyText, out double chartConstant)
|
||||
{
|
||||
chartConstant = 0.0;
|
||||
var label = (difficultyText ?? string.Empty).Trim();
|
||||
if (label.Length == 0)
|
||||
return false;
|
||||
|
||||
var upper = label.ToUpperInvariant();
|
||||
if (upper.Contains("GIMMICK") || upper.Contains("SPECIAL") ||
|
||||
upper.Contains("DUMMY") || upper.Contains("OLD"))
|
||||
return false;
|
||||
|
||||
var numericEnd = label.Length;
|
||||
var plus = false;
|
||||
if (numericEnd > 0 && label[numericEnd - 1] == '+')
|
||||
{
|
||||
plus = true;
|
||||
numericEnd--;
|
||||
while (numericEnd > 0 && char.IsWhiteSpace(label[numericEnd - 1]))
|
||||
numericEnd--;
|
||||
}
|
||||
|
||||
var start = numericEnd;
|
||||
var dotSeen = false;
|
||||
var digitSeen = false;
|
||||
while (start > 0)
|
||||
{
|
||||
var character = label[start - 1];
|
||||
if (char.IsDigit(character))
|
||||
{
|
||||
digitSeen = true;
|
||||
start--;
|
||||
continue;
|
||||
}
|
||||
if (character == '.' && !dotSeen)
|
||||
{
|
||||
dotSeen = true;
|
||||
start--;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!digitSeen)
|
||||
return false;
|
||||
if (start > 0 && (label[start - 1] == '-' || label[start - 1] == '.'))
|
||||
return false;
|
||||
|
||||
if (!double.TryParse(
|
||||
label.Substring(start, numericEnd - start),
|
||||
NumberStyles.AllowDecimalPoint,
|
||||
CultureInfo.InvariantCulture,
|
||||
out chartConstant) ||
|
||||
chartConstant < 0.0 ||
|
||||
double.IsNaN(chartConstant) ||
|
||||
double.IsInfinity(chartConstant))
|
||||
{
|
||||
chartConstant = 0.0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some established tables start at level 0 (for example Satellite
|
||||
// sl0). It is a real lowest tier, not a missing difficulty value.
|
||||
// A positive half-step keeps it below level 1 while allowing it to
|
||||
// participate in the same rating pipeline.
|
||||
if (chartConstant <= 0.0)
|
||||
chartConstant = MinimumChartConstant;
|
||||
|
||||
if (plus &&
|
||||
Math.Abs(chartConstant - Math.Round(chartConstant)) < 0.000001)
|
||||
chartConstant += 0.5;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProfileLevelRules
|
||||
{
|
||||
public const int XpRulesVersion = 1;
|
||||
|
||||
public static long TotalXpForLevel(int level)
|
||||
{
|
||||
var normalized = Math.Max(1, level) - 1L;
|
||||
return 20L * normalized * normalized + 50L * normalized;
|
||||
}
|
||||
|
||||
public static int NextLevelXp(int level) => checked(40 * Math.Max(1, level) + 30);
|
||||
|
||||
public static int LevelForTotalXp(long totalXp)
|
||||
{
|
||||
var xp = Math.Max(0L, totalXp);
|
||||
var low = 1;
|
||||
var high = 2;
|
||||
while (TotalXpForLevel(high) <= xp && high < 1_000_000)
|
||||
high *= 2;
|
||||
while (low + 1 < high)
|
||||
{
|
||||
var middle = low + (high - low) / 2;
|
||||
if (TotalXpForLevel(middle) <= xp)
|
||||
low = middle;
|
||||
else
|
||||
high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
public static int CalculateBaseXp(double activePlaySeconds, int judgedUnits)
|
||||
{
|
||||
var value = 20 +
|
||||
(int)Math.Floor(Math.Max(0.0, activePlaySeconds) / 10.0) * 4 +
|
||||
Math.Max(0, judgedUnits) / 100 * 2;
|
||||
return Math.Max(20, Math.Min(100, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 459ea391724314c1397e5b3531e642d3
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ProjectQuint.Core",
|
||||
"rootNamespace": "ProjectQuint.Core",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b5f07eda49ea4e18b3acb164cc392c1
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 823010ad9f0f4b81b0b9bbee2ff34369
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ProjectQuint.Core.Release
|
||||
{
|
||||
/// <summary>
|
||||
/// Public product version metadata shared by the runtime, release tooling, and update contract.
|
||||
/// Persistent-data schema versions remain independent from this product version.
|
||||
/// </summary>
|
||||
public static class ProjectQuintReleaseVersion
|
||||
{
|
||||
public const string ProductVersion = "0.1.0";
|
||||
public const string ReleaseChannel = "alpha";
|
||||
public const int ChannelRevision = 4;
|
||||
public const int BuildNumber = 4;
|
||||
public const string SparkleFeedUrl =
|
||||
"https://projectquint.duckdns.org/updates/appcast.xml";
|
||||
public const string SparklePublicEdKey =
|
||||
"w94qM58k8MAhIxYYQiR1ivyR5CyMW1tlRV68lQoLehA=";
|
||||
|
||||
public static string DisplayVersion =>
|
||||
$"{ProductVersion}-{ReleaseChannel}.{ChannelRevision.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
public static string BuildNumberText =>
|
||||
BuildNumber.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
public static string DisplayVersionWithBuild =>
|
||||
$"{DisplayVersion} (build {BuildNumberText})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e73b647666ee4452a7f5d0ed2735461d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3efd064767d294d688e7a4fafe935965
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ProjectQuint.Core.Timing
|
||||
{
|
||||
public enum AudioSyncStability
|
||||
{
|
||||
Insufficient,
|
||||
Good,
|
||||
Fair,
|
||||
Unstable,
|
||||
OutOfRange,
|
||||
}
|
||||
|
||||
public readonly struct AudioSyncCalibrationResult
|
||||
{
|
||||
public AudioSyncCalibrationResult(
|
||||
int recommendedLatencyMilliseconds,
|
||||
double medianMilliseconds,
|
||||
double medianAbsoluteDeviationMilliseconds,
|
||||
int acceptedSampleCount,
|
||||
int rejectedSampleCount,
|
||||
AudioSyncStability stability)
|
||||
{
|
||||
RecommendedLatencyMilliseconds = recommendedLatencyMilliseconds;
|
||||
MedianMilliseconds = medianMilliseconds;
|
||||
MedianAbsoluteDeviationMilliseconds = medianAbsoluteDeviationMilliseconds;
|
||||
AcceptedSampleCount = acceptedSampleCount;
|
||||
RejectedSampleCount = rejectedSampleCount;
|
||||
Stability = stability;
|
||||
}
|
||||
|
||||
public int RecommendedLatencyMilliseconds { get; }
|
||||
public double MedianMilliseconds { get; }
|
||||
public double MedianAbsoluteDeviationMilliseconds { get; }
|
||||
public int AcceptedSampleCount { get; }
|
||||
public int RejectedSampleCount { get; }
|
||||
public AudioSyncStability Stability { get; }
|
||||
public bool CanApply => AcceptedSampleCount >= AudioSyncCalibrationEstimator.MinimumAcceptedSamples &&
|
||||
Stability != AudioSyncStability.Unstable &&
|
||||
Stability != AudioSyncStability.OutOfRange;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Robustly estimates audible output latency from tap-minus-scheduled-click samples.
|
||||
/// The estimator is deterministic and deliberately independent from Unity and FMOD.
|
||||
/// </summary>
|
||||
public static class AudioSyncCalibrationEstimator
|
||||
{
|
||||
public const int PracticeClickCount = 4;
|
||||
public const int MeasurementClickCount = 24;
|
||||
public const int MinimumAcceptedSamples = 18;
|
||||
public const int MaximumLatencyMilliseconds = 500;
|
||||
private const double MinimumOutlierThresholdMilliseconds = 30.0;
|
||||
|
||||
public static AudioSyncCalibrationResult Estimate(IReadOnlyList<double> samplesMilliseconds)
|
||||
{
|
||||
if (samplesMilliseconds == null)
|
||||
throw new ArgumentNullException(nameof(samplesMilliseconds));
|
||||
if (samplesMilliseconds.Count == 0)
|
||||
return new AudioSyncCalibrationResult(
|
||||
0, 0, 0, 0, 0, AudioSyncStability.Insufficient);
|
||||
|
||||
var sorted = CopyFinite(samplesMilliseconds);
|
||||
if (sorted.Count == 0)
|
||||
return new AudioSyncCalibrationResult(
|
||||
0, 0, 0, 0, samplesMilliseconds.Count, AudioSyncStability.Insufficient);
|
||||
|
||||
sorted.Sort();
|
||||
var initialMedian = Median(sorted);
|
||||
var deviations = new List<double>(sorted.Count);
|
||||
for (var index = 0; index < sorted.Count; index++)
|
||||
deviations.Add(Math.Abs(sorted[index] - initialMedian));
|
||||
deviations.Sort();
|
||||
var initialMad = Median(deviations);
|
||||
var threshold = Math.Max(MinimumOutlierThresholdMilliseconds, initialMad * 3.0);
|
||||
var accepted = new List<double>(sorted.Count);
|
||||
for (var index = 0; index < sorted.Count; index++)
|
||||
{
|
||||
if (Math.Abs(sorted[index] - initialMedian) <= threshold)
|
||||
accepted.Add(sorted[index]);
|
||||
}
|
||||
|
||||
if (accepted.Count == 0)
|
||||
return new AudioSyncCalibrationResult(
|
||||
0, initialMedian, initialMad, 0, samplesMilliseconds.Count,
|
||||
AudioSyncStability.Insufficient);
|
||||
|
||||
accepted.Sort();
|
||||
var median = Median(accepted);
|
||||
deviations.Clear();
|
||||
for (var index = 0; index < accepted.Count; index++)
|
||||
deviations.Add(Math.Abs(accepted[index] - median));
|
||||
deviations.Sort();
|
||||
var mad = Median(deviations);
|
||||
var stability = median > MaximumLatencyMilliseconds
|
||||
? AudioSyncStability.OutOfRange
|
||||
: accepted.Count < MinimumAcceptedSamples
|
||||
? AudioSyncStability.Insufficient
|
||||
: mad <= 12.0
|
||||
? AudioSyncStability.Good
|
||||
: mad <= 22.0
|
||||
? AudioSyncStability.Fair
|
||||
: AudioSyncStability.Unstable;
|
||||
var recommendation = (int)Math.Round(
|
||||
Math.Max(0.0, Math.Min(MaximumLatencyMilliseconds, median)),
|
||||
MidpointRounding.AwayFromZero);
|
||||
return new AudioSyncCalibrationResult(
|
||||
recommendation,
|
||||
median,
|
||||
mad,
|
||||
accepted.Count,
|
||||
samplesMilliseconds.Count - accepted.Count,
|
||||
stability);
|
||||
}
|
||||
|
||||
private static List<double> CopyFinite(IReadOnlyList<double> values)
|
||||
{
|
||||
var result = new List<double>(values.Count);
|
||||
for (var index = 0; index < values.Count; index++)
|
||||
{
|
||||
var value = values[index];
|
||||
if (!double.IsNaN(value) && !double.IsInfinity(value))
|
||||
result.Add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double Median(IReadOnlyList<double> sorted)
|
||||
{
|
||||
var middle = sorted.Count / 2;
|
||||
return sorted.Count % 2 == 0
|
||||
? (sorted[middle - 1] + sorted[middle]) * 0.5
|
||||
: sorted[middle];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f9ad2638954b4c7682a159ec2ad15d3d
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ProjectQuint.Core.Timing
|
||||
{
|
||||
/// <summary>
|
||||
/// Matches a tap only against the next expected click. This deliberately avoids
|
||||
/// nearest-neighbour matching, which can fold a large output latency onto the
|
||||
/// following metronome click and report a false value near zero.
|
||||
/// </summary>
|
||||
public static class AudioSyncTapMatcher
|
||||
{
|
||||
public const double MaximumOffsetSeconds = 0.650;
|
||||
|
||||
public static bool TryMatchExpected(
|
||||
IReadOnlyList<double> clickTimes,
|
||||
int expectedClickIndex,
|
||||
double tapTime,
|
||||
out double offsetSeconds)
|
||||
{
|
||||
if (clickTimes == null)
|
||||
throw new ArgumentNullException(nameof(clickTimes));
|
||||
offsetSeconds = 0.0;
|
||||
if (expectedClickIndex < 0 || expectedClickIndex >= clickTimes.Count)
|
||||
return false;
|
||||
|
||||
offsetSeconds = tapTime - clickTimes[expectedClickIndex];
|
||||
return offsetSeconds >= 0.0 && offsetSeconds <= MaximumOffsetSeconds;
|
||||
}
|
||||
|
||||
public static bool HasExpired(
|
||||
IReadOnlyList<double> clickTimes,
|
||||
int expectedClickIndex,
|
||||
double currentTime)
|
||||
{
|
||||
if (clickTimes == null)
|
||||
throw new ArgumentNullException(nameof(clickTimes));
|
||||
return expectedClickIndex >= 0 &&
|
||||
expectedClickIndex < clickTimes.Count &&
|
||||
currentTime > clickTimes[expectedClickIndex] + MaximumOffsetSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37c9a6416f6248c39db4c32d614aa5d3
|
||||
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ProjectQuint.Core.Timing
|
||||
{
|
||||
/// <summary>
|
||||
/// A deterministic musical-time anchor. Time is the instant at which Beat is
|
||||
/// judged, before an optional STOP at that same beat. Bpm is the tempo used
|
||||
/// after that STOP and until the next node.
|
||||
/// </summary>
|
||||
public readonly struct TempoMapNode
|
||||
{
|
||||
public TempoMapNode(double beat, double time, double bpm, double stopDuration = 0)
|
||||
{
|
||||
if (double.IsNaN(beat) || double.IsInfinity(beat))
|
||||
throw new ArgumentOutOfRangeException(nameof(beat));
|
||||
if (double.IsNaN(time) || double.IsInfinity(time))
|
||||
throw new ArgumentOutOfRangeException(nameof(time));
|
||||
if (!(bpm > 0) || double.IsNaN(bpm) || double.IsInfinity(bpm))
|
||||
throw new ArgumentOutOfRangeException(nameof(bpm));
|
||||
if (stopDuration < 0 || double.IsNaN(stopDuration) || double.IsInfinity(stopDuration))
|
||||
throw new ArgumentOutOfRangeException(nameof(stopDuration));
|
||||
|
||||
Beat = beat;
|
||||
Time = time;
|
||||
Bpm = bpm;
|
||||
StopDuration = stopDuration;
|
||||
}
|
||||
|
||||
public double Beat { get; }
|
||||
public double Time { get; }
|
||||
public double Bpm { get; }
|
||||
public double StopDuration { get; }
|
||||
public double StopEndTime => Time + StopDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts quarter-note beat positions and DSP song seconds in both directions.
|
||||
/// BeatToTime returns the pre-STOP judgement instant for an exact STOP position;
|
||||
/// TimeToBeat holds that beat for the entire STOP interval.
|
||||
/// </summary>
|
||||
public sealed class TempoMap
|
||||
{
|
||||
private const double Epsilon = 0.000000001;
|
||||
private readonly TempoMapNode[] _nodes;
|
||||
|
||||
public TempoMap(IEnumerable<TempoMapNode> nodes)
|
||||
{
|
||||
_nodes = (nodes ?? throw new ArgumentNullException(nameof(nodes))).ToArray();
|
||||
if (_nodes.Length == 0)
|
||||
throw new ArgumentException("A tempo map requires at least one node.", nameof(nodes));
|
||||
|
||||
for (var index = 1; index < _nodes.Length; index++)
|
||||
{
|
||||
if (_nodes[index].Beat <= _nodes[index - 1].Beat + Epsilon)
|
||||
throw new ArgumentException("Tempo-map beats must be strictly increasing.", nameof(nodes));
|
||||
if (_nodes[index].Time + Epsilon < _nodes[index - 1].StopEndTime)
|
||||
throw new ArgumentException("Tempo-map times must not overlap the previous STOP.", nameof(nodes));
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<TempoMapNode> Nodes => _nodes;
|
||||
public double InitialBpm => _nodes[0].Bpm;
|
||||
|
||||
public static TempoMap Constant(double bpm) =>
|
||||
new TempoMap(new[] { new TempoMapNode(0, 0, bpm) });
|
||||
|
||||
public double BeatToTime(double beat)
|
||||
{
|
||||
var index = FindNodeAtOrBeforeBeat(beat);
|
||||
var node = _nodes[index];
|
||||
if (Math.Abs(beat - node.Beat) <= Epsilon)
|
||||
return node.Time;
|
||||
if (beat < node.Beat)
|
||||
return node.Time + (beat - node.Beat) * 60.0 / node.Bpm;
|
||||
return node.StopEndTime + (beat - node.Beat) * 60.0 / node.Bpm;
|
||||
}
|
||||
|
||||
public double TimeToBeat(double time)
|
||||
{
|
||||
var index = FindNodeAtOrBeforeTime(time);
|
||||
var node = _nodes[index];
|
||||
if (time < node.Time - Epsilon)
|
||||
return node.Beat + (time - node.Time) * node.Bpm / 60.0;
|
||||
if (time <= node.StopEndTime + Epsilon && time >= node.Time - Epsilon)
|
||||
return node.Beat;
|
||||
return node.Beat + (time - node.StopEndTime) * node.Bpm / 60.0;
|
||||
}
|
||||
|
||||
public bool IsStopped(double time)
|
||||
{
|
||||
var node = _nodes[FindNodeAtOrBeforeTime(time)];
|
||||
return node.StopDuration > 0 &&
|
||||
time >= node.Time - Epsilon &&
|
||||
time < node.StopEndTime - Epsilon;
|
||||
}
|
||||
|
||||
private int FindNodeAtOrBeforeBeat(double beat)
|
||||
{
|
||||
if (beat < _nodes[0].Beat)
|
||||
return 0;
|
||||
|
||||
var low = 0;
|
||||
var high = _nodes.Length - 1;
|
||||
while (low <= high)
|
||||
{
|
||||
var middle = low + ((high - low) >> 1);
|
||||
if (_nodes[middle].Beat <= beat + Epsilon)
|
||||
low = middle + 1;
|
||||
else
|
||||
high = middle - 1;
|
||||
}
|
||||
return Math.Max(0, high);
|
||||
}
|
||||
|
||||
private int FindNodeAtOrBeforeTime(double time)
|
||||
{
|
||||
if (time < _nodes[0].Time)
|
||||
return 0;
|
||||
|
||||
var low = 0;
|
||||
var high = _nodes.Length - 1;
|
||||
while (low <= high)
|
||||
{
|
||||
var middle = low + ((high - low) >> 1);
|
||||
if (_nodes[middle].Time <= time + Epsilon)
|
||||
low = middle + 1;
|
||||
else
|
||||
high = middle - 1;
|
||||
}
|
||||
return Math.Max(0, high);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct ScrollMapNode
|
||||
{
|
||||
public ScrollMapNode(double beat, double multiplier)
|
||||
{
|
||||
if (beat < 0 || double.IsNaN(beat) || double.IsInfinity(beat))
|
||||
throw new ArgumentOutOfRangeException(nameof(beat));
|
||||
if (double.IsNaN(multiplier) || double.IsInfinity(multiplier))
|
||||
throw new ArgumentOutOfRangeException(nameof(multiplier));
|
||||
|
||||
Beat = beat;
|
||||
Multiplier = multiplier;
|
||||
}
|
||||
|
||||
public double Beat { get; }
|
||||
public double Multiplier { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visual scroll is deliberately separate from judgement time. Its coordinate
|
||||
/// integrates the active BMS #SCROLL multiplier over musical beat, so BPM and
|
||||
/// STOP still come from TempoMap while scroll gimmicks never alter judgement.
|
||||
/// </summary>
|
||||
public sealed class ScrollMap
|
||||
{
|
||||
private const double Epsilon = 0.000000001;
|
||||
private readonly ScrollMapNode[] _nodes;
|
||||
private readonly double[] _positions;
|
||||
|
||||
public ScrollMap(TempoMap tempoMap)
|
||||
: this(tempoMap, Array.Empty<ScrollMapNode>())
|
||||
{
|
||||
}
|
||||
|
||||
public ScrollMap(TempoMap tempoMap, IEnumerable<ScrollMapNode> nodes)
|
||||
{
|
||||
TempoMap = tempoMap ?? throw new ArgumentNullException(nameof(tempoMap));
|
||||
|
||||
var ordered = (nodes ?? throw new ArgumentNullException(nameof(nodes)))
|
||||
.OrderBy(node => node.Beat)
|
||||
.ToArray();
|
||||
var normalized = new List<ScrollMapNode>(ordered.Length + 1);
|
||||
if (ordered.Length == 0 || ordered[0].Beat > Epsilon)
|
||||
normalized.Add(new ScrollMapNode(0, 1));
|
||||
|
||||
foreach (var node in ordered)
|
||||
{
|
||||
if (normalized.Count > 0 &&
|
||||
Math.Abs(normalized[normalized.Count - 1].Beat - node.Beat) <= Epsilon)
|
||||
normalized[normalized.Count - 1] = node;
|
||||
else
|
||||
normalized.Add(node);
|
||||
}
|
||||
|
||||
if (normalized.Count == 0)
|
||||
normalized.Add(new ScrollMapNode(0, 1));
|
||||
|
||||
_nodes = normalized.ToArray();
|
||||
_positions = new double[_nodes.Length];
|
||||
for (var index = 1; index < _nodes.Length; index++)
|
||||
{
|
||||
_positions[index] = _positions[index - 1] +
|
||||
(_nodes[index].Beat - _nodes[index - 1].Beat) *
|
||||
_nodes[index - 1].Multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
public TempoMap TempoMap { get; }
|
||||
public IReadOnlyList<ScrollMapNode> Nodes => _nodes;
|
||||
public bool HasVariableScroll =>
|
||||
_nodes.Count(node => Math.Abs(node.Multiplier - 1.0) > Epsilon) > 0;
|
||||
public bool HasReverseScroll => _nodes.Any(node => node.Multiplier < -Epsilon);
|
||||
|
||||
public double PositionAtTime(double songTime) => PositionAtBeat(TempoMap.TimeToBeat(songTime));
|
||||
|
||||
public double PositionAtBeat(double beat)
|
||||
{
|
||||
// BMS scroll objects cannot precede measure zero. Keep the countdown
|
||||
// preroll at the normal 1x rate even if a chart changes scroll at beat 0.
|
||||
if (beat < 0)
|
||||
return beat;
|
||||
|
||||
var index = FindNodeAtOrBeforeBeat(beat);
|
||||
return _positions[index] + (beat - _nodes[index].Beat) * _nodes[index].Multiplier;
|
||||
}
|
||||
|
||||
public double TimeAtPosition(double position)
|
||||
{
|
||||
if (position < 0)
|
||||
return TempoMap.BeatToTime(position);
|
||||
|
||||
if (TryGetTimeAtForwardDistance(TempoMap.BeatToTime(0), position, out var time))
|
||||
return time;
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first future judgement time whose visual position is the requested
|
||||
/// distance ahead. Zero and reverse segments are traversed instead of being
|
||||
/// treated as elapsed song time, which keeps note-window culling correct.
|
||||
/// </summary>
|
||||
public bool TryGetTimeAtForwardDistance(double songTime, double distance, out double time)
|
||||
{
|
||||
if (distance < 0 || double.IsNaN(distance) || double.IsInfinity(distance))
|
||||
throw new ArgumentOutOfRangeException(nameof(distance));
|
||||
|
||||
var beat = TempoMap.TimeToBeat(songTime);
|
||||
var position = PositionAtBeat(beat);
|
||||
var target = position + distance;
|
||||
if (distance <= Epsilon)
|
||||
{
|
||||
time = songTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (beat < 0)
|
||||
{
|
||||
if (CrossesForward(position, 0, target, 1, out var fraction))
|
||||
{
|
||||
time = TempoMap.BeatToTime(beat + (0 - beat) * fraction);
|
||||
return true;
|
||||
}
|
||||
beat = 0;
|
||||
position = 0;
|
||||
}
|
||||
|
||||
var index = FindNodeAtOrBeforeBeat(beat);
|
||||
while (index + 1 < _nodes.Length)
|
||||
{
|
||||
var nextBeat = _nodes[index + 1].Beat;
|
||||
var nextPosition = position + (nextBeat - beat) * _nodes[index].Multiplier;
|
||||
if (CrossesForward(position, nextPosition, target, _nodes[index].Multiplier, out var fraction))
|
||||
{
|
||||
time = TempoMap.BeatToTime(beat + (nextBeat - beat) * fraction);
|
||||
return true;
|
||||
}
|
||||
|
||||
beat = nextBeat;
|
||||
position = nextPosition;
|
||||
index++;
|
||||
}
|
||||
|
||||
var multiplier = _nodes[index].Multiplier;
|
||||
if (multiplier > Epsilon && target >= position - Epsilon)
|
||||
{
|
||||
var targetBeat = beat + Math.Max(0, target - position) / multiplier;
|
||||
time = TempoMap.BeatToTime(targetBeat);
|
||||
return true;
|
||||
}
|
||||
|
||||
time = double.PositiveInfinity;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CrossesForward(
|
||||
double start,
|
||||
double end,
|
||||
double target,
|
||||
double multiplier,
|
||||
out double fraction)
|
||||
{
|
||||
if (multiplier > Epsilon && target >= start - Epsilon && target <= end + Epsilon)
|
||||
{
|
||||
fraction = Math.Abs(end - start) <= Epsilon ? 0 : (target - start) / (end - start);
|
||||
return true;
|
||||
}
|
||||
|
||||
fraction = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private int FindNodeAtOrBeforeBeat(double beat)
|
||||
{
|
||||
var low = 0;
|
||||
var high = _nodes.Length - 1;
|
||||
while (low <= high)
|
||||
{
|
||||
var middle = low + ((high - low) >> 1);
|
||||
if (_nodes[middle].Beat <= beat + Epsilon)
|
||||
low = middle + 1;
|
||||
else
|
||||
high = middle - 1;
|
||||
}
|
||||
return Math.Max(0, high);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 287be2f5502b49849f5a30773b68e98a
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
|
||||
namespace ProjectQuint.Core.Timing
|
||||
{
|
||||
public sealed class ResilientSongClock
|
||||
{
|
||||
public const double DspStallThresholdSeconds = 0.250;
|
||||
private const double DspAdvanceEpsilonSeconds = 0.000001;
|
||||
private const double RecoveryToleranceSeconds = 0.005;
|
||||
|
||||
private bool _initialized;
|
||||
private double _currentSongTime;
|
||||
private double _lastDspTime;
|
||||
private double _lastDspSongTime;
|
||||
private double _lastDspAdvanceRealtime;
|
||||
private double _lastSampleRealtime;
|
||||
|
||||
public bool IsFallbackActive { get; private set; }
|
||||
public double CurrentSongTime => _currentSongTime;
|
||||
|
||||
public void Reset(double currentDspTime, double startDspTime, double currentRealtime)
|
||||
{
|
||||
_initialized = true;
|
||||
_currentSongTime = currentDspTime - startDspTime;
|
||||
_lastDspTime = currentDspTime;
|
||||
_lastDspSongTime = _currentSongTime;
|
||||
_lastDspAdvanceRealtime = currentRealtime;
|
||||
_lastSampleRealtime = currentRealtime;
|
||||
IsFallbackActive = false;
|
||||
}
|
||||
|
||||
public void Reanchor(
|
||||
double songTime,
|
||||
double currentDspTime,
|
||||
double currentRealtime)
|
||||
{
|
||||
_initialized = true;
|
||||
_currentSongTime = songTime;
|
||||
_lastDspTime = currentDspTime;
|
||||
_lastDspSongTime = songTime;
|
||||
_lastDspAdvanceRealtime = currentRealtime;
|
||||
_lastSampleRealtime = currentRealtime;
|
||||
IsFallbackActive = false;
|
||||
}
|
||||
|
||||
public double Sample(double currentDspTime, double startDspTime, double currentRealtime)
|
||||
{
|
||||
if (!_initialized)
|
||||
Reset(currentDspTime, startDspTime, currentRealtime);
|
||||
|
||||
var realtimeDelta = Math.Max(0, currentRealtime - _lastSampleRealtime);
|
||||
_lastSampleRealtime = currentRealtime;
|
||||
var dspSongTime = currentDspTime - startDspTime;
|
||||
var dspAdvanced = currentDspTime > _lastDspTime + DspAdvanceEpsilonSeconds;
|
||||
|
||||
if (IsFallbackActive)
|
||||
{
|
||||
_currentSongTime += realtimeDelta;
|
||||
if (dspAdvanced && dspSongTime >= _currentSongTime - RecoveryToleranceSeconds)
|
||||
{
|
||||
_currentSongTime = Math.Max(_currentSongTime, dspSongTime);
|
||||
IsFallbackActive = false;
|
||||
}
|
||||
}
|
||||
else if (dspAdvanced)
|
||||
{
|
||||
_currentSongTime = Math.Max(_currentSongTime, dspSongTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stalledFor = Math.Max(0, currentRealtime - _lastDspAdvanceRealtime);
|
||||
if (stalledFor >= DspStallThresholdSeconds)
|
||||
{
|
||||
_currentSongTime = Math.Max(_currentSongTime, _lastDspSongTime + stalledFor);
|
||||
IsFallbackActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dspAdvanced)
|
||||
{
|
||||
_lastDspTime = currentDspTime;
|
||||
_lastDspSongTime = dspSongTime;
|
||||
_lastDspAdvanceRealtime = currentRealtime;
|
||||
}
|
||||
|
||||
return _currentSongTime;
|
||||
}
|
||||
|
||||
public static double ResolveInputEventSongTime(
|
||||
double inputEventTime,
|
||||
double currentInputTime,
|
||||
double currentSongTime) =>
|
||||
currentSongTime + inputEventTime - currentInputTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fd5a9a33c48fb42df9852b599f5a3eeb
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6ab15a3928614b1dae5cecabd46a518
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Application")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Media.Audio")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Media.Images")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Media.Video")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Presentation")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")]
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2af160e05b69349aeb7ac43baa29c62f
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8a18e0ff9fe24ad6a98a191cf6c35dd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Application")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Bootstrap")]
|
||||
[assembly: InternalsVisibleTo("ProjectQuint.Tests.EditMode")]
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2fb26024c41234d0798636f56c78e9f8
|
||||
@@ -0,0 +1,738 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Library;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace ProjectQuint.Audio
|
||||
{
|
||||
internal enum AudioAssetStoreState
|
||||
{
|
||||
Empty,
|
||||
Loading,
|
||||
Ready,
|
||||
Cancelled,
|
||||
Failed,
|
||||
Disposed,
|
||||
}
|
||||
|
||||
internal enum AudioResourceLoadStatus
|
||||
{
|
||||
Loaded,
|
||||
SilentPlaceholder,
|
||||
Missing,
|
||||
DecodeFailed,
|
||||
TooLarge,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
internal readonly struct AudioResourceLoadResult
|
||||
{
|
||||
public AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus status,
|
||||
AudioClip clip = null,
|
||||
string detail = null)
|
||||
{
|
||||
Status = status;
|
||||
Clip = clip;
|
||||
Detail = detail ?? string.Empty;
|
||||
}
|
||||
|
||||
public AudioResourceLoadStatus Status { get; }
|
||||
public AudioClip Clip { get; }
|
||||
public string Detail { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the decoded Unity clips for one prepared song. File resolution, asynchronous
|
||||
/// decoding and the per-song PCM budget live here so both playback modes share exactly
|
||||
/// the same assets and failure state.
|
||||
/// </summary>
|
||||
internal sealed class AudioAssetStore : IDisposable
|
||||
{
|
||||
private static readonly string[] AudioExtensionFallbacks = { ".wav", ".ogg", ".mp3" };
|
||||
internal const long DefaultDecodedPcmBudgetBytes = 1536L * 1024L * 1024L;
|
||||
|
||||
private readonly Dictionary<string, AudioClip> _clips =
|
||||
new Dictionary<string, AudioClip>(StringComparer.Ordinal);
|
||||
private readonly long _decodedPcmBudgetBytes;
|
||||
|
||||
public AudioAssetStore(long decodedPcmBudgetBytes = DefaultDecodedPcmBudgetBytes)
|
||||
{
|
||||
if (decodedPcmBudgetBytes <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(decodedPcmBudgetBytes));
|
||||
_decodedPcmBudgetBytes = decodedPcmBudgetBytes;
|
||||
}
|
||||
|
||||
public AudioAssetStoreState State { get; private set; } = AudioAssetStoreState.Empty;
|
||||
public int LoadedClipCount => _clips.Count;
|
||||
public int MissingClipCount { get; private set; }
|
||||
public int DecodeFailedClipCount { get; private set; }
|
||||
public int SilentPlaceholderCount { get; private set; }
|
||||
public long EstimatedDecodedPcmBytes { get; private set; }
|
||||
public long DecodedPcmBudgetBytes => _decodedPcmBudgetBytes;
|
||||
public string LoadError { get; private set; } = string.Empty;
|
||||
|
||||
public IEnumerator Load(
|
||||
PlayableChart chart,
|
||||
Action<int, int, string> progress,
|
||||
Func<bool> cancellationRequested = null)
|
||||
{
|
||||
if (chart == null)
|
||||
throw new ArgumentNullException(nameof(chart));
|
||||
ThrowIfDisposed();
|
||||
|
||||
ClearAssets();
|
||||
State = AudioAssetStoreState.Loading;
|
||||
MissingClipCount = 0;
|
||||
DecodeFailedClipCount = 0;
|
||||
SilentPlaceholderCount = 0;
|
||||
LoadError = string.Empty;
|
||||
|
||||
if (IsCancelled(cancellationRequested))
|
||||
{
|
||||
State = AudioAssetStoreState.Cancelled;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var soundIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var note in chart.Notes)
|
||||
if (!string.IsNullOrWhiteSpace(note.SoundId)) soundIds.Add(note.SoundId);
|
||||
foreach (var item in chart.BackgroundAudioEvents)
|
||||
if (!string.IsNullOrWhiteSpace(item.SoundId)) soundIds.Add(item.SoundId);
|
||||
foreach (var item in chart.LaneSoundEvents)
|
||||
if (!string.IsNullOrWhiteSpace(item.SoundId)) soundIds.Add(item.SoundId);
|
||||
|
||||
var orderedIds = soundIds.OrderBy(item => item, StringComparer.Ordinal).ToList();
|
||||
for (var index = 0; index < orderedIds.Count; index++)
|
||||
{
|
||||
if (CancelLoad(cancellationRequested))
|
||||
yield break;
|
||||
|
||||
var soundId = orderedIds[index];
|
||||
progress?.Invoke(index, orderedIds.Count, soundId);
|
||||
|
||||
if (!chart.AudioResources.TryGetValue(soundId, out var declaredPath))
|
||||
{
|
||||
MissingClipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedPath = ResolveReferencedAudioPath(chart.Metadata.SourcePath, declaredPath);
|
||||
if (string.IsNullOrEmpty(resolvedPath))
|
||||
{
|
||||
MissingClipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryClassifyWaveResource(
|
||||
resolvedPath,
|
||||
soundId,
|
||||
out var resourceStatus,
|
||||
out var resourceError))
|
||||
{
|
||||
DecodeFailedClipCount++;
|
||||
Debug.LogWarning(
|
||||
$"[ProjectQuint] Skipped keysound {soundId} '{resolvedPath}': {resourceError}");
|
||||
continue;
|
||||
}
|
||||
if (resourceStatus == AudioResourceLoadStatus.SilentPlaceholder)
|
||||
{
|
||||
SilentPlaceholderCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
AudioClip clip = null;
|
||||
var loadError = string.Empty;
|
||||
if (string.Equals(Path.GetExtension(resolvedPath), ".wav", StringComparison.OrdinalIgnoreCase) &&
|
||||
WaveAudioDecoder.IsMicrosoftAdpcm(resolvedPath))
|
||||
{
|
||||
// The format probe reads only the small RIFF header. The expensive full
|
||||
// file read and ADPCM decode run off the Unity main thread.
|
||||
var decodeTask = Task.Run(() => DecodeMicrosoftAdpcm(resolvedPath));
|
||||
while (!decodeTask.IsCompleted)
|
||||
{
|
||||
if (CancelLoad(cancellationRequested))
|
||||
{
|
||||
ObserveFaultAfterCancellation(decodeTask);
|
||||
yield break;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (decodeTask.IsFaulted)
|
||||
{
|
||||
loadError = decodeTask.Exception?.GetBaseException().Message ?? "WAV decode probe failed.";
|
||||
}
|
||||
else
|
||||
{
|
||||
var decode = decodeTask.Result;
|
||||
loadError = decode.Error;
|
||||
if (decode.Audio != null)
|
||||
clip = CreateDecodedClip(soundId, decode.Audio, out loadError);
|
||||
}
|
||||
}
|
||||
|
||||
if (clip == null && string.IsNullOrEmpty(loadError))
|
||||
{
|
||||
using var request = UnityWebRequestMultimedia.GetAudioClip(
|
||||
BuildLocalFileUri(resolvedPath),
|
||||
GetAudioType(resolvedPath));
|
||||
if (request.downloadHandler is DownloadHandlerAudioClip audioHandler)
|
||||
{
|
||||
audioHandler.streamAudio = false;
|
||||
audioHandler.compressed = false;
|
||||
}
|
||||
|
||||
var operation = request.SendWebRequest();
|
||||
while (!operation.isDone)
|
||||
{
|
||||
if (IsCancelled(cancellationRequested))
|
||||
{
|
||||
request.Abort();
|
||||
CancelLoad(cancellationRequested);
|
||||
yield break;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (request.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
clip = DownloadHandlerAudioClip.GetContent(request);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
loadError = exception.Message;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
loadError = request.error;
|
||||
}
|
||||
}
|
||||
|
||||
if (CancelLoad(cancellationRequested))
|
||||
{
|
||||
DestroyUnityObject(clip);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (clip == null)
|
||||
{
|
||||
DecodeFailedClipCount++;
|
||||
Debug.LogWarning($"[ProjectQuint] Skipped keysound {soundId} '{resolvedPath}': {loadError}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var clipBytes = CalculateDecodedPcmBytes(clip.samples, clip.channels);
|
||||
if (clipBytes > _decodedPcmBudgetBytes - EstimatedDecodedPcmBytes)
|
||||
{
|
||||
DestroyUnityObject(clip);
|
||||
Fail(
|
||||
$"Decoded keysounds exceed the per-song audio budget " +
|
||||
$"({FormatBytes(_decodedPcmBudgetBytes)}). Last asset: {soundId}.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
clip.name = $"BMS {soundId}";
|
||||
_clips[soundId] = clip;
|
||||
EstimatedDecodedPcmBytes += clipBytes;
|
||||
}
|
||||
|
||||
if (orderedIds.Count > 0 &&
|
||||
_clips.Count == 0 &&
|
||||
MissingClipCount + DecodeFailedClipCount > 0)
|
||||
{
|
||||
Fail(
|
||||
$"No playable keysounds could be loaded " +
|
||||
$"({MissingClipCount} missing, {DecodeFailedClipCount} decode failed).");
|
||||
yield break;
|
||||
}
|
||||
|
||||
State = AudioAssetStoreState.Ready;
|
||||
progress?.Invoke(orderedIds.Count, orderedIds.Count, string.Empty);
|
||||
}
|
||||
|
||||
internal void PrepareForSequentialMix()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ClearAssets();
|
||||
MissingClipCount = 0;
|
||||
DecodeFailedClipCount = 0;
|
||||
SilentPlaceholderCount = 0;
|
||||
LoadError = string.Empty;
|
||||
State = AudioAssetStoreState.Ready;
|
||||
}
|
||||
|
||||
internal IEnumerator LoadTransientClip(
|
||||
PlayableChart chart,
|
||||
string soundId,
|
||||
Action<AudioResourceLoadResult> completed,
|
||||
Func<bool> cancellationRequested = null)
|
||||
{
|
||||
if (chart == null)
|
||||
throw new ArgumentNullException(nameof(chart));
|
||||
|
||||
if (!chart.AudioResources.TryGetValue(soundId ?? string.Empty, out var declaredPath))
|
||||
{
|
||||
completed?.Invoke(new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.Missing,
|
||||
detail: $"Undefined keysound {soundId}."));
|
||||
yield break;
|
||||
}
|
||||
|
||||
var resolvedPath = ResolveReferencedAudioPath(chart.Metadata.SourcePath, declaredPath);
|
||||
if (string.IsNullOrEmpty(resolvedPath))
|
||||
{
|
||||
completed?.Invoke(new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.Missing,
|
||||
detail: $"Keysound file not found: {declaredPath}"));
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!TryClassifyWaveResource(
|
||||
resolvedPath,
|
||||
soundId,
|
||||
out var resourceStatus,
|
||||
out var resourceError))
|
||||
{
|
||||
completed?.Invoke(new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.DecodeFailed,
|
||||
detail: resourceError));
|
||||
yield break;
|
||||
}
|
||||
if (resourceStatus == AudioResourceLoadStatus.SilentPlaceholder)
|
||||
{
|
||||
completed?.Invoke(new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.SilentPlaceholder));
|
||||
yield break;
|
||||
}
|
||||
|
||||
AudioClip clip = null;
|
||||
var loadError = string.Empty;
|
||||
if (string.Equals(Path.GetExtension(resolvedPath), ".wav", StringComparison.OrdinalIgnoreCase) &&
|
||||
WaveAudioDecoder.IsMicrosoftAdpcm(resolvedPath))
|
||||
{
|
||||
var decodeTask = Task.Run(() => DecodeMicrosoftAdpcm(resolvedPath));
|
||||
while (!decodeTask.IsCompleted)
|
||||
{
|
||||
if (IsCancelled(cancellationRequested))
|
||||
{
|
||||
ObserveFaultAfterCancellation(decodeTask);
|
||||
completed?.Invoke(new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.Cancelled,
|
||||
detail: "Audio loading cancelled."));
|
||||
yield break;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (decodeTask.IsFaulted)
|
||||
loadError = decodeTask.Exception?.GetBaseException().Message ?? "WAV decode failed.";
|
||||
else
|
||||
{
|
||||
var decode = decodeTask.Result;
|
||||
loadError = decode.Error;
|
||||
if (decode.Audio != null)
|
||||
clip = CreateDecodedClip(soundId, decode.Audio, out loadError);
|
||||
}
|
||||
}
|
||||
|
||||
if (clip == null && string.IsNullOrEmpty(loadError))
|
||||
{
|
||||
using var request = UnityWebRequestMultimedia.GetAudioClip(
|
||||
BuildLocalFileUri(resolvedPath),
|
||||
GetAudioType(resolvedPath));
|
||||
if (request.downloadHandler is DownloadHandlerAudioClip audioHandler)
|
||||
{
|
||||
audioHandler.streamAudio = false;
|
||||
audioHandler.compressed = false;
|
||||
}
|
||||
|
||||
var operation = request.SendWebRequest();
|
||||
while (!operation.isDone)
|
||||
{
|
||||
if (IsCancelled(cancellationRequested))
|
||||
{
|
||||
request.Abort();
|
||||
completed?.Invoke(new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.Cancelled,
|
||||
detail: "Audio loading cancelled."));
|
||||
yield break;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (request.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
clip = DownloadHandlerAudioClip.GetContent(request);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
loadError = exception.Message;
|
||||
}
|
||||
}
|
||||
else
|
||||
loadError = request.error;
|
||||
}
|
||||
|
||||
if (IsCancelled(cancellationRequested))
|
||||
{
|
||||
DestroyUnityObject(clip);
|
||||
completed?.Invoke(new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.Cancelled,
|
||||
detail: "Audio loading cancelled."));
|
||||
yield break;
|
||||
}
|
||||
completed?.Invoke(clip != null
|
||||
? new AudioResourceLoadResult(AudioResourceLoadStatus.Loaded, clip)
|
||||
: new AudioResourceLoadResult(
|
||||
AudioResourceLoadStatus.DecodeFailed,
|
||||
detail: string.IsNullOrWhiteSpace(loadError)
|
||||
? $"Keysound {soundId} could not be decoded."
|
||||
: loadError));
|
||||
}
|
||||
|
||||
internal void RecordTransientOutcome(
|
||||
string soundId,
|
||||
AudioResourceLoadStatus status,
|
||||
string detail)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case AudioResourceLoadStatus.SilentPlaceholder:
|
||||
SilentPlaceholderCount++;
|
||||
return;
|
||||
case AudioResourceLoadStatus.Missing:
|
||||
MissingClipCount++;
|
||||
break;
|
||||
case AudioResourceLoadStatus.DecodeFailed:
|
||||
DecodeFailedClipCount++;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.LogWarning(
|
||||
$"[ProjectQuint] Skipped keysound {soundId}: " +
|
||||
$"{(string.IsNullOrWhiteSpace(detail) ? status.ToString() : detail)}");
|
||||
}
|
||||
|
||||
internal static bool TryReadClipPcm(
|
||||
AudioClip clip,
|
||||
string soundId,
|
||||
long decodedPcmBudgetBytes,
|
||||
out PcmAudioData pcm,
|
||||
out AudioResourceLoadStatus failureStatus,
|
||||
out string error)
|
||||
{
|
||||
pcm = null;
|
||||
failureStatus = AudioResourceLoadStatus.DecodeFailed;
|
||||
error = string.Empty;
|
||||
if (clip == null)
|
||||
return true;
|
||||
if (clip.loadState != AudioDataLoadState.Loaded && !clip.LoadAudioData())
|
||||
{
|
||||
error = $"Could not load PCM data for keysound {soundId}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var sampleCount64 = checked((long)clip.samples * clip.channels);
|
||||
if (sampleCount64 <= 0)
|
||||
{
|
||||
error = $"Keysound {soundId} has an invalid PCM size.";
|
||||
return false;
|
||||
}
|
||||
if (sampleCount64 > int.MaxValue ||
|
||||
sampleCount64 > decodedPcmBudgetBytes / sizeof(float))
|
||||
{
|
||||
failureStatus = AudioResourceLoadStatus.TooLarge;
|
||||
error =
|
||||
$"Decoded keysound {soundId} exceeds the per-resource audio budget " +
|
||||
$"({FormatBytes(decodedPcmBudgetBytes)}).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var samples = new float[(int)sampleCount64];
|
||||
if (!clip.GetData(samples, 0))
|
||||
{
|
||||
error = $"Keysound {soundId} cannot be decoded to PCM. Re-encode the source audio as WAV or OGG.";
|
||||
return false;
|
||||
}
|
||||
pcm = new PcmAudioData(samples, clip.channels, clip.frequency);
|
||||
failureStatus = AudioResourceLoadStatus.Loaded;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static void ReleaseTransientClip(AudioClip clip) => DestroyUnityObject(clip);
|
||||
|
||||
public bool TryGetClip(string soundId, out AudioClip clip) =>
|
||||
_clips.TryGetValue(soundId ?? string.Empty, out clip) && clip != null;
|
||||
|
||||
public Dictionary<string, double> GetClipDurations()
|
||||
{
|
||||
var durations = new Dictionary<string, double>(_clips.Count, StringComparer.Ordinal);
|
||||
foreach (var item in _clips)
|
||||
if (item.Value != null) durations[item.Key] = Math.Max(item.Value.length, 0.01f);
|
||||
return durations;
|
||||
}
|
||||
|
||||
public bool TryReadPcm(string soundId, out PcmAudioData pcm, out string error)
|
||||
{
|
||||
if (!TryGetClip(soundId, out var clip))
|
||||
{
|
||||
pcm = null;
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
return TryReadClipPcm(
|
||||
clip,
|
||||
soundId,
|
||||
_decodedPcmBudgetBytes,
|
||||
out pcm,
|
||||
out _,
|
||||
out error);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ClearAssets();
|
||||
MissingClipCount = 0;
|
||||
DecodeFailedClipCount = 0;
|
||||
SilentPlaceholderCount = 0;
|
||||
LoadError = string.Empty;
|
||||
State = AudioAssetStoreState.Empty;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (State == AudioAssetStoreState.Disposed)
|
||||
return;
|
||||
ClearAssets();
|
||||
State = AudioAssetStoreState.Disposed;
|
||||
}
|
||||
|
||||
internal static long CalculateDecodedPcmBytes(int frameCount, int channels)
|
||||
{
|
||||
if (frameCount < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(frameCount));
|
||||
if (channels <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(channels));
|
||||
return checked((long)frameCount * channels * sizeof(float));
|
||||
}
|
||||
|
||||
internal static string ResolveReferencedAudioPath(string chartPath, string declaredPath)
|
||||
{
|
||||
var resolved = BmsResourceResolver.ResolveAudio(chartPath, declaredPath);
|
||||
if (!string.IsNullOrEmpty(resolved))
|
||||
return resolved;
|
||||
if (string.IsNullOrWhiteSpace(chartPath) || string.IsNullOrWhiteSpace(declaredPath))
|
||||
return string.Empty;
|
||||
|
||||
var chartDirectory = Path.GetDirectoryName(chartPath);
|
||||
if (string.IsNullOrEmpty(chartDirectory))
|
||||
return string.Empty;
|
||||
|
||||
var normalizedRelativePath = declaredPath
|
||||
.Trim()
|
||||
.Trim('"')
|
||||
.Replace('\\', Path.DirectorySeparatorChar)
|
||||
.Replace('/', Path.DirectorySeparatorChar);
|
||||
var declaredCandidate = Path.IsPathRooted(normalizedRelativePath)
|
||||
? normalizedRelativePath
|
||||
: Path.Combine(chartDirectory, normalizedRelativePath);
|
||||
var candidateDirectory = Path.GetDirectoryName(declaredCandidate);
|
||||
var candidateStem = Path.GetFileNameWithoutExtension(declaredCandidate);
|
||||
if (string.IsNullOrEmpty(candidateDirectory) || string.IsNullOrEmpty(candidateStem) ||
|
||||
!Directory.Exists(candidateDirectory))
|
||||
return string.Empty;
|
||||
|
||||
foreach (var extension in AudioExtensionFallbacks)
|
||||
{
|
||||
var candidate = Path.Combine(candidateDirectory, candidateStem + extension);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var candidate in Directory.EnumerateFiles(candidateDirectory))
|
||||
{
|
||||
if (!string.Equals(Path.GetFileNameWithoutExtension(candidate), candidateStem,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (AudioExtensionFallbacks.Any(extension =>
|
||||
string.Equals(Path.GetExtension(candidate), extension, StringComparison.OrdinalIgnoreCase)))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
internal static string BuildLocalFileUri(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
throw new ArgumentException("A local audio path is required.", nameof(filePath));
|
||||
|
||||
// System.Uri treats a valid-looking %xx sequence as an already escaped URL byte.
|
||||
// BMS packs can legally contain a literal '%' in a directory or file name, so escape
|
||||
// it before constructing the file URI or UnityWebRequest will open a different path.
|
||||
var literalPath = Path.GetFullPath(filePath);
|
||||
return "file://" + string.Join(
|
||||
"/",
|
||||
literalPath
|
||||
.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.None)
|
||||
.Select(Uri.EscapeDataString));
|
||||
}
|
||||
|
||||
private bool CancelLoad(Func<bool> cancellationRequested)
|
||||
{
|
||||
if (!IsCancelled(cancellationRequested))
|
||||
return false;
|
||||
ClearAssets();
|
||||
State = AudioAssetStoreState.Cancelled;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Fail(string error)
|
||||
{
|
||||
ClearAssets();
|
||||
LoadError = error ?? "Audio asset loading failed.";
|
||||
State = AudioAssetStoreState.Failed;
|
||||
}
|
||||
|
||||
private void ClearAssets()
|
||||
{
|
||||
foreach (var clip in _clips.Values)
|
||||
DestroyUnityObject(clip);
|
||||
_clips.Clear();
|
||||
EstimatedDecodedPcmBytes = 0;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (State == AudioAssetStoreState.Disposed)
|
||||
throw new ObjectDisposedException(nameof(AudioAssetStore));
|
||||
}
|
||||
|
||||
private static bool IsCancelled(Func<bool> cancellationRequested) =>
|
||||
cancellationRequested?.Invoke() == true;
|
||||
|
||||
internal static bool TryClassifyWaveResource(
|
||||
string resolvedPath,
|
||||
string soundId,
|
||||
out AudioResourceLoadStatus status,
|
||||
out string error)
|
||||
{
|
||||
status = AudioResourceLoadStatus.Loaded;
|
||||
error = string.Empty;
|
||||
if (!string.Equals(
|
||||
Path.GetExtension(resolvedPath),
|
||||
".wav",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
var inspection = WaveAudioDecoder.InspectContainer(resolvedPath);
|
||||
switch (inspection.Status)
|
||||
{
|
||||
case WaveContainerStatus.AudioData:
|
||||
return true;
|
||||
case WaveContainerStatus.SilentPlaceholder:
|
||||
status = AudioResourceLoadStatus.SilentPlaceholder;
|
||||
return true;
|
||||
default:
|
||||
status = AudioResourceLoadStatus.DecodeFailed;
|
||||
error =
|
||||
$"Keysound {soundId} is not a valid WAV container: " +
|
||||
inspection.Detail;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static BackgroundWaveDecodeResult DecodeMicrosoftAdpcm(string path)
|
||||
{
|
||||
return WaveAudioDecoder.TryDecodeFile(path, out var audio, out var error)
|
||||
? new BackgroundWaveDecodeResult(audio, string.Empty)
|
||||
: new BackgroundWaveDecodeResult(null, error);
|
||||
}
|
||||
|
||||
private static AudioClip CreateDecodedClip(
|
||||
string soundId,
|
||||
DecodedWaveAudio decoded,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
var clip = AudioClip.Create(
|
||||
$"BMS {soundId}",
|
||||
decoded.FrameCount,
|
||||
decoded.Channels,
|
||||
decoded.SampleRate,
|
||||
false);
|
||||
if (clip != null && clip.SetData(decoded.Samples, 0))
|
||||
return clip;
|
||||
DestroyUnityObject(clip);
|
||||
error = "AudioClip.SetData rejected the decoded samples.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AudioType GetAudioType(string path)
|
||||
{
|
||||
switch (Path.GetExtension(path).ToLowerInvariant())
|
||||
{
|
||||
case ".ogg": return AudioType.OGGVORBIS;
|
||||
case ".mp3": return AudioType.MPEG;
|
||||
default: return AudioType.WAV;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ObserveFaultAfterCancellation(Task task)
|
||||
{
|
||||
task.ContinueWith(
|
||||
completed => { _ = completed.Exception; },
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
private static void DestroyUnityObject(UnityEngine.Object target)
|
||||
{
|
||||
if (target == null)
|
||||
return;
|
||||
if (Application.isPlaying)
|
||||
UnityEngine.Object.Destroy(target);
|
||||
else
|
||||
UnityEngine.Object.DestroyImmediate(target);
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes) => $"{bytes / (1024d * 1024d):0.#} MiB";
|
||||
|
||||
private readonly struct BackgroundWaveDecodeResult
|
||||
{
|
||||
public BackgroundWaveDecodeResult(DecodedWaveAudio audio, string error)
|
||||
{
|
||||
Audio = audio;
|
||||
Error = error ?? string.Empty;
|
||||
}
|
||||
|
||||
public DecodedWaveAudio Audio { get; }
|
||||
public string Error { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd4f69d881f884738aed3561ef698e14
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectQuint.Audio
|
||||
{
|
||||
public readonly struct AudioOutputDeviceInfo
|
||||
{
|
||||
public AudioOutputDeviceInfo(
|
||||
string persistentId,
|
||||
string name,
|
||||
int sampleRate,
|
||||
int channels,
|
||||
bool isSystemDefault = false)
|
||||
{
|
||||
PersistentId = persistentId ?? string.Empty;
|
||||
Name = string.IsNullOrWhiteSpace(name) ? "UNKNOWN DEVICE" : name.Trim();
|
||||
SampleRate = Math.Max(0, sampleRate);
|
||||
Channels = Math.Max(0, channels);
|
||||
IsSystemDefault = isSystemDefault;
|
||||
}
|
||||
|
||||
public string PersistentId { get; }
|
||||
public string Name { get; }
|
||||
public int SampleRate { get; }
|
||||
public int Channels { get; }
|
||||
public bool IsSystemDefault { get; }
|
||||
public string DetailLabel => IsSystemDefault
|
||||
? "FOLLOW macOS SYSTEM OUTPUT"
|
||||
: SampleRate > 0
|
||||
? $"{SampleRate:N0} Hz // {Channels} CH"
|
||||
: "DEVICE FORMAT UNAVAILABLE";
|
||||
|
||||
public static AudioOutputDeviceInfo SystemDefault { get; } =
|
||||
new AudioOutputDeviceInfo(string.Empty, "SYSTEM DEFAULT", 0, 0, true);
|
||||
}
|
||||
|
||||
public readonly struct AudioOutputConfigurationSnapshot
|
||||
{
|
||||
public AudioOutputConfigurationSnapshot(
|
||||
string backendId,
|
||||
string outputType,
|
||||
string deviceName,
|
||||
int bufferLength,
|
||||
int bufferCount,
|
||||
int sampleRate,
|
||||
string devicePersistentId = "")
|
||||
{
|
||||
BackendId = backendId ?? string.Empty;
|
||||
OutputType = outputType ?? string.Empty;
|
||||
DeviceName = deviceName ?? string.Empty;
|
||||
DevicePersistentId = devicePersistentId ?? string.Empty;
|
||||
BufferLength = Math.Max(0, bufferLength);
|
||||
BufferCount = Math.Max(0, bufferCount);
|
||||
SampleRate = Math.Max(0, sampleRate);
|
||||
}
|
||||
|
||||
public string BackendId { get; }
|
||||
public string OutputType { get; }
|
||||
public string DeviceName { get; }
|
||||
public string DevicePersistentId { get; }
|
||||
public int BufferLength { get; }
|
||||
public int BufferCount { get; }
|
||||
public int SampleRate { get; }
|
||||
public double EstimatedMixerLatencyMilliseconds =>
|
||||
BufferLength > 0 && BufferCount > 0 && SampleRate > 0
|
||||
? BufferLength * (double)BufferCount * 1000.0 / SampleRate
|
||||
: 0;
|
||||
public string AppliedLabel => BufferLength > 0 && BufferCount > 0 && SampleRate > 0
|
||||
? $"{BackendId} // {OutputType} // {DeviceName} // " +
|
||||
$"{BufferLength} x {BufferCount} @ {SampleRate} Hz // " +
|
||||
$"EST {EstimatedMixerLatencyMilliseconds:0.0} ms"
|
||||
: $"{BackendId} // APPLIED CONFIGURATION UNAVAILABLE";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monotonic clock owned by the selected audio backend. Gameplay consumes seconds only and
|
||||
/// never reaches into Unity or a future native backend for its clock directly.
|
||||
/// </summary>
|
||||
public interface IAudioClock
|
||||
{
|
||||
string ClockId { get; }
|
||||
double CurrentTime { get; }
|
||||
int OutputSampleRate { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-song audio session contract shared by loading, gameplay and diagnostics. Backend
|
||||
/// implementations own their native clips, channels, scheduling clock and pause semantics.
|
||||
/// </summary>
|
||||
public interface ISongAudioBackend : IDisposable
|
||||
{
|
||||
string BackendId { get; }
|
||||
IAudioClock Clock { get; }
|
||||
double StartDspTime { get; }
|
||||
int LoadedClipCount { get; }
|
||||
int MissingClipCount { get; }
|
||||
int DecodeFailedClipCount { get; }
|
||||
int SilentPlaceholderCount { get; }
|
||||
int ScheduledSourceCount { get; }
|
||||
int PendingScheduledEventCount { get; }
|
||||
int DroppedLateEventCount { get; }
|
||||
bool SmoothMixReady { get; }
|
||||
string SmoothMixError { get; }
|
||||
bool SmoothMixCacheHit { get; }
|
||||
string SmoothMixCacheStatus { get; }
|
||||
string LoadError { get; }
|
||||
long EstimatedDecodedPcmBytes { get; }
|
||||
long EstimatedPremixedPcmBytes { get; }
|
||||
long EstimatedTotalPcmBytes { get; }
|
||||
|
||||
IEnumerator Load(
|
||||
PlayableChart chart,
|
||||
Action<int, int, string> progress,
|
||||
Func<bool> cancellationRequested = null,
|
||||
bool reactiveKeySoundsEnabled = true);
|
||||
|
||||
void PrepareReactivePlayback(double leadSeconds = 0.35);
|
||||
|
||||
IEnumerator PrepareSmoothPlaybackMix(
|
||||
Action<int, int, string> progress,
|
||||
Func<bool> cancellationRequested = null);
|
||||
|
||||
void BeginPlayback(bool reactiveKeySoundsEnabled, double leadSeconds = 0.35);
|
||||
void Update();
|
||||
void PlayKeySound(string soundId, int lane);
|
||||
void SetMasterVolume(float normalizedVolume);
|
||||
void Pause();
|
||||
void Resume();
|
||||
void Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition boundary used by SongLoadCoordinator. A future FMOD implementation can be
|
||||
/// selected here without introducing FMOD types into loading or gameplay assemblies.
|
||||
/// </summary>
|
||||
public interface ISongAudioBackendFactory
|
||||
{
|
||||
string BackendId { get; }
|
||||
IAudioClock Clock { get; }
|
||||
AudioOutputConfigurationSnapshot OutputConfiguration { get; }
|
||||
ISongAudioBackend Create(GameObject resourceOwner);
|
||||
}
|
||||
|
||||
public sealed class UnityDspAudioClock : IAudioClock
|
||||
{
|
||||
private UnityDspAudioClock() { }
|
||||
|
||||
public static UnityDspAudioClock Shared { get; } = new UnityDspAudioClock();
|
||||
public string ClockId => "UNITY_DSP";
|
||||
public double CurrentTime => AudioSettings.dspTime;
|
||||
public int OutputSampleRate => AudioSettings.outputSampleRate;
|
||||
}
|
||||
|
||||
public sealed class UnitySongAudioBackendFactory : ISongAudioBackendFactory
|
||||
{
|
||||
public string BackendId => BmsAudioSystem.UnityBackendId;
|
||||
public IAudioClock Clock => UnityDspAudioClock.Shared;
|
||||
public AudioOutputConfigurationSnapshot OutputConfiguration
|
||||
{
|
||||
get
|
||||
{
|
||||
AudioSettings.GetDSPBufferSize(out var bufferLength, out var bufferCount);
|
||||
return new AudioOutputConfigurationSnapshot(
|
||||
BackendId,
|
||||
"UNITY AUDIO",
|
||||
"SYSTEM DEFAULT",
|
||||
bufferLength,
|
||||
bufferCount,
|
||||
AudioSettings.outputSampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
public ISongAudioBackend Create(GameObject resourceOwner) =>
|
||||
new BmsAudioSystem(resourceOwner, Clock);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user