프로젝트 소스 공개
This commit is contained in:
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/[Ll]ibrary/
|
||||||
|
/[Tt]emp/
|
||||||
|
/[Oo]bj/
|
||||||
|
/[Bb]uild/
|
||||||
|
/[Bb]uilds/
|
||||||
|
/[Ll]ogs/
|
||||||
|
/[Uu]ser[Ss]ettings/
|
||||||
|
/[Mm]emoryCaptures/
|
||||||
|
/[Rr]ecordings/
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
*.csproj
|
||||||
|
*.sln
|
||||||
|
*.slnx
|
||||||
|
*.user
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Local validation output, caches, and working notes.
|
||||||
|
/_workspace/
|
||||||
|
/tmp/
|
||||||
|
/TestResults/
|
||||||
|
/mono_crash.*
|
||||||
|
/plan.md
|
||||||
|
/.release.local.json
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# User-supplied song libraries are never part of the source distribution.
|
||||||
|
*.[bB][mM][sS]
|
||||||
|
*.[bB][mM][eE]
|
||||||
|
*.[bB][mM][lL]
|
||||||
|
*.[pP][mM][sS]
|
||||||
|
*.[bB][mM][sS][oO][nN]
|
||||||
|
*.[wW][aA][vV]
|
||||||
|
*.[oO][gG][gG]
|
||||||
|
*.[mM][pP]3
|
||||||
|
*.[fF][lL][aA][cC]
|
||||||
|
*.[mM]4[aA]
|
||||||
|
*.[aA][iI][fF]
|
||||||
|
*.[aA][iI][fF][fF]
|
||||||
|
*.[bB][mM][sS].meta
|
||||||
|
*.[bB][mM][eE].meta
|
||||||
|
*.[bB][mM][lL].meta
|
||||||
|
*.[pP][mM][sS].meta
|
||||||
|
*.[bB][mM][sS][oO][nN].meta
|
||||||
|
*.[wW][aA][vV].meta
|
||||||
|
*.[oO][gG][gG].meta
|
||||||
|
*.[mM][pP]3.meta
|
||||||
|
*.[fF][lL][aA][cC].meta
|
||||||
|
*.[mM]4[aA].meta
|
||||||
|
*.[aA][iI][fF].meta
|
||||||
|
*.[aA][iI][fF][fF].meta
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"visualstudiotoolsforunity.vstuc"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Attach to Unity",
|
||||||
|
"type": "vstuc",
|
||||||
|
"request": "attach"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"files.exclude": {
|
||||||
|
"**/.DS_Store": true,
|
||||||
|
"**/.git": true,
|
||||||
|
"**/.vs": true,
|
||||||
|
"**/.gitmodules": true,
|
||||||
|
"**/.vsconfig": true,
|
||||||
|
"**/*.booproj": true,
|
||||||
|
"**/*.pidb": true,
|
||||||
|
"**/*.suo": true,
|
||||||
|
"**/*.user": true,
|
||||||
|
"**/*.userprefs": true,
|
||||||
|
"**/*.unityproj": true,
|
||||||
|
"**/*.dll": true,
|
||||||
|
"**/*.exe": true,
|
||||||
|
"**/*.pdf": true,
|
||||||
|
"**/*.mid": true,
|
||||||
|
"**/*.midi": true,
|
||||||
|
"**/*.wav": true,
|
||||||
|
"**/*.gif": true,
|
||||||
|
"**/*.ico": true,
|
||||||
|
"**/*.jpg": true,
|
||||||
|
"**/*.jpeg": true,
|
||||||
|
"**/*.png": true,
|
||||||
|
"**/*.psd": true,
|
||||||
|
"**/*.tga": true,
|
||||||
|
"**/*.tif": true,
|
||||||
|
"**/*.tiff": true,
|
||||||
|
"**/*.3ds": true,
|
||||||
|
"**/*.3DS": true,
|
||||||
|
"**/*.fbx": true,
|
||||||
|
"**/*.FBX": true,
|
||||||
|
"**/*.lxo": true,
|
||||||
|
"**/*.LXO": true,
|
||||||
|
"**/*.ma": true,
|
||||||
|
"**/*.MA": true,
|
||||||
|
"**/*.obj": true,
|
||||||
|
"**/*.OBJ": true,
|
||||||
|
"**/*.asset": true,
|
||||||
|
"**/*.cubemap": true,
|
||||||
|
"**/*.flare": true,
|
||||||
|
"**/*.mat": true,
|
||||||
|
"**/*.meta": true,
|
||||||
|
"**/*.prefab": true,
|
||||||
|
"**/*.unity": true,
|
||||||
|
"build/": true,
|
||||||
|
"Build/": true,
|
||||||
|
"Library/": true,
|
||||||
|
"library/": true,
|
||||||
|
"obj/": true,
|
||||||
|
"Obj/": true,
|
||||||
|
"Logs/": true,
|
||||||
|
"logs/": true,
|
||||||
|
"ProjectSettings/": true,
|
||||||
|
"UserSettings/": true,
|
||||||
|
"temp/": true,
|
||||||
|
"Temp/": true
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"*.asset": "yaml",
|
||||||
|
"*.meta": "yaml",
|
||||||
|
"*.prefab": "yaml",
|
||||||
|
"*.unity": "yaml"
|
||||||
|
},
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
|
"explorer.fileNesting.patterns": {
|
||||||
|
"*.sln": "*.csproj",
|
||||||
|
"*.slnx": "*.csproj"
|
||||||
|
},
|
||||||
|
"dotnet.defaultSolution": "ProjectQuint.slnx"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ProjectQuint Repository Guide
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
- ProjectQuint is a macOS-first 4/5/6/7-key BMS rhythm-game client with optional left scratch, built with Unity 6.3 LTS.
|
||||||
|
- `Assets/ProjectQuint/Runtime/Core` is pure C# chart/timing/judgement code; Unity input, audio and rendering stay under `Runtime/Unity`.
|
||||||
|
- BMS packs are external user libraries. Never copy song media into the repository, `Assets`, `StreamingAssets`, or a player build.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
- Timing and judgement must remain deterministic and independent from render FPS.
|
||||||
|
- Scratch is a fixed left lane and is preserved in notes, audio, score, combo and autoplay. MIRROR/RANDOM only transform key lanes.
|
||||||
|
- Long notes auto-complete while held at the tail. Their scoring ticks use musical 1/16-note boundaries, the default early-release grace is 100 ms, and an early break cannot be recovered by re-pressing.
|
||||||
|
- `TempoMap` owns beat↔DSP-song-time conversion while `ScrollMap` owns visual position. BPM/STOP handling must not be reintroduced as renderer-local time arithmetic.
|
||||||
|
- BMS packs must be registered by the user as folders or ZIP files. Keep song indexes isolated by pack ID, but keep difficulty tables in the global table registry and cache them by table ID so the same provider can match charts from any pack.
|
||||||
|
|
||||||
|
## How
|
||||||
|
|
||||||
|
- Unity version: `6000.3.20f1`.
|
||||||
|
- External editor control uses Unity's official MCP from `com.unity.ai.assistant`; verify the live editor with the `Unity_GetConsoleLogs` tool.
|
||||||
|
- Run tests headlessly with Unity Test Runner and do not append `-quit` to `-runTests`; Unity 6 exits after the run and `-quit` can terminate before tests start.
|
||||||
|
- Read editor errors through the official MCP console tool or the explicit Unity `-logFile` used by the current validation run.
|
||||||
|
- Use `AudioSettings.dspTime` and Input System event timestamps for gameplay clocks; never use frame count as the judgement clock.
|
||||||
@@ -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:
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user