From b80c98a0561ce4c39929e06aa9f29e51e91946fb Mon Sep 17 00:00:00 2001 From: LA <1245661240@qq.com> Date: Wed, 21 Jan 2026 23:20:42 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E5=AF=B9=E9=BD=90=E3=80=91=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E6=8F=90=E4=BA=A4=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/FUNDING.yml | 2 - .github/ISSUE_TEMPLATE/bug-issue.yml | 75 - .github/ISSUE_TEMPLATE/config.yml | 12 - .github/dependabot.yml | 46 - .github/workflows/_diffcalc_processor.yml | 228 --- .github/workflows/auto-release.yml | 312 +++ .github/workflows/ci.yml | 154 -- .github/workflows/deploy.yml | 87 - .github/workflows/diffcalc.yml | 196 -- .github/workflows/qodana_code_quality.yml | 28 + .github/workflows/report-nunit.yml | 45 - .github/workflows/sentry-release.yml | 29 - .../workflows/update-web-mod-definitions.yml | 53 - .gitignore | 5 + .idea/.idea.osu.Desktop/.idea/vcs.xml | 2 + .vscode/launch.json | 6 +- .vscode/settings.json | 1 + CONTRIBUTING.md | 121 +- README.md | 195 +- SkinScriptingImplementation/CHANGES.md | 96 + .../Dependencies/nuget-packages.txt | 1 + .../ExampleManiaSkinScript.lua | 160 ++ .../ExampleSkinScript.lua | 66 + SkinScriptingImplementation/README.md | 169 ++ .../osu.Game/OsuGame_SkinScripting.cs | 17 + .../Overlays/Dialog/FileImportFaultDialog.cs | 31 + .../Overlays/Settings/Sections/SkinSection.cs | 316 ++++ .../Scripting/ManiaSkinScriptExtensions.cs | 81 + .../osu.Game/Skinning/LegacySkin.cs | 369 ++++ .../Skinning/Scripting/ISkinScriptHost.cs | 82 + .../Overlays/SkinScriptingSettingsSection.cs | 300 +++ .../osu.Game/Skinning/Scripting/SkinScript.cs | 191 ++ .../Skinning/Scripting/SkinScriptInterface.cs | 139 ++ .../Skinning/Scripting/SkinScriptManager.cs | 301 +++ .../Skinning/Scripting/SkinScriptingConfig.cs | 35 + .../SkinScriptingOverlayRegistration.cs | 25 + .../osu.Game/Skinning/Skin.cs | 460 +++++ .../osu.Game/Skinning/SkinManager.cs | 504 +++++ .../osu.Game/Skinning/SkinnableDrawable.cs | 157 ++ appveyor.yml | 32 + appveyor_deploy.yml | 86 + osu.Desktop.slnf | 8 +- .../EzMacOS/GameplaySpotlightBlocker.cs | 66 + osu.Desktop/EzMacOS/SpotlightKey.cs | 184 ++ osu.Desktop/OsuGameDesktop.cs | 4 + osu.Desktop/Program.cs | 4 +- osu.Desktop/Properties/launchSettings.json | 5 +- osu.Desktop/osu.Desktop.csproj | 8 +- .../Analysis/HalfHoldBeatmapFactory.cs | 87 + .../Analysis/SRCalculatorTunable.cs | 1126 +++++++++++ .../Analysis/TestSceneSRTune.cs | 259 +++ .../Mods/TestSceneManiaModSpaceBody.cs | 142 ++ .../TestSceneEzHitEventHeatmapGraph.cs | 442 +++++ .../Beatmaps/ManiaBeatmapConverter.cs | 43 +- .../ManiaRulesetConfigManager.cs | 24 +- .../Difficulty/ManiaDifficultyCalculator.cs | 30 +- .../Edit/DrawableManiaEditorRuleset.cs | 3 +- .../Analysis/CrossMatrixProvider.cs | 77 + .../LAsEzMania/Analysis/EzManiaScoreGraph.cs | 320 ++++ .../Analysis/EzManiaScoreGraph_clean.cs | 0 .../Analysis/ManiaScoreHitEventGenerator.cs | 244 +++ .../LAsEzMania/Analysis/SRCalculator.cs | 1284 +++++++++++++ .../LAsEzMania/Analysis/SRErrorCodes.cs | 33 + .../LAsEzMania/CustomVisibilityContainer.cs | 27 + .../LAsEzMania/EzEffectHelper.cs | 57 + .../LAsEzMania/EzHitTimingGraphByColumn.cs | 207 ++ .../LAsEzMania/EzJudgementText.cs | 44 + .../LAsEzMania/EzKeyCounter.cs | 111 ++ .../LAsEzMania/EzKeyCounterPro.cs | 59 + .../LAsEzMania/EzManiaEnums.cs | 56 + .../LAsEzMania/EzManiaHitModeConvertor.cs | 98 + .../LAsEzMania/EzManiaHitTimingInfo.cs | 19 + .../LAsEzMania/EzManiaModStrings.cs | 382 ++++ .../LAsEzMania/EzManiaStrings.cs | 44 + .../LAsEzMania/EzStageDefinitionExtensions.cs | 174 ++ .../LAsEzMania/FastSlowDisplayStrings.cs | 261 +++ .../LAsEzMania/Helper/CustomHitWindows.cs | 350 ++++ .../LAsEzMania/ManiaKeyCounterDisplay.cs | 89 + .../ManiaFilterCriteria.cs | 6 + osu.Game.Rulesets.Mania/ManiaRuleset.cs | 148 +- .../ManiaSettingsSubsection.cs | 122 +- .../Mods/LAsMods/ManiaModBasicScrollSpeed.txt | 93 + .../Mods/LAsMods/ManiaModCleanColumn.cs | 186 ++ .../Mods/LAsMods/ManiaModEz2Settings.cs | 317 ++++ .../Mods/LAsMods/ManiaModFreeHit.txt | 293 +++ .../Mods/LAsMods/ManiaModLoopPlayClip.cs | 538 ++++++ .../Mods/LAsMods/ManiaModNiceBPM.cs | 260 +++ .../Mods/LAsMods/ManiaModSRAdjust.cs | 65 + .../Mods/LAsMods/ManiaModSpaceBody.cs | 137 ++ .../Mods/YuLiangSSSMods/ManiaModAdjust.cs | 614 ++++++ .../ManiaModChangeSpeedByAccuracy.cs | 125 ++ .../Mods/YuLiangSSSMods/ManiaModCleaner.cs | 213 +++ .../YuLiangSSSMods/ManiaModDoublelPlay.cs | 509 +++++ .../Mods/YuLiangSSSMods/ManiaModGracer.cs | 196 ++ .../Mods/YuLiangSSSMods/ManiaModHelper.cs | 635 +++++++ .../Mods/YuLiangSSSMods/ManiaModJackAdjust.cs | 319 ++++ .../YuLiangSSSMods/ManiaModJudgmentsAdjust.cs | 214 +++ .../Mods/YuLiangSSSMods/ManiaModLN.cs | 107 ++ .../ManiaModLNDoubleDistribution.cs | 205 ++ .../ManiaModLNJudgementAdjust.cs | 346 ++++ .../ManiaModLNLongShortAddition.cs | 150 ++ .../Mods/YuLiangSSSMods/ManiaModLNSimplify.cs | 191 ++ .../YuLiangSSSMods/ManiaModLNTransformer.cs | 398 ++++ .../YuLiangSSSMods/ManiaModMalodyStyleLN.cs | 214 +++ .../YuLiangSSSMods/ManiaModNewJudgement.cs | 102 + .../Mods/YuLiangSSSMods/ManiaModNoteAdjust.cs | 734 ++++++++ .../Mods/YuLiangSSSMods/ManiaModNtoM.cs | 338 ++++ .../YuLiangSSSMods/ManiaModNtoMAnother.cs | 490 +++++ .../Mods/YuLiangSSSMods/ManiaModO2Health.cs | 106 ++ .../YuLiangSSSMods/ManiaModO2Judgement.cs | 129 ++ .../ManiaModPlayfieldTransformation.cs | 109 ++ .../YuLiangSSSMods/ManiaModReleaseAdjust.cs | 128 ++ .../Mods/YuLiangSSSMods/ManiaModRemedy.cs | 401 ++++ .../YuLiangSSSMods/ModStarRatingRebirth.cs | 1668 +++++++++++++++++ .../Mods/YuLiangSSSMods/Utils.cs | 118 ++ .../Objects/Drawables/DrawableHoldNoteBody.cs | 2 +- .../DrawableLNTailForNoRelease.cs | 19 + .../EzCurrentHitObject/Ez2AcDrawableLNTail.cs | 59 + .../EzCurrentHitObject/Ez2AcHoldNote.cs | 126 ++ .../EzCurrentHitObject/MalodyDrawableNote.cs | 62 + .../EzCurrentHitObject/NoComboBreakLNTail.cs | 21 + .../EzCurrentHitObject/NoJudgementNote.cs | 21 + .../EzCurrentHitObject/NoJudgmentHoldNote.cs | 46 + .../EzCurrentHitObject/NoMissLNBody.cs | 21 + .../EzCurrentHitObject/O2HitModeExtension.cs | 356 ++++ .../Objects/EzCurrentHitObject/O2HoldNote.cs | 43 + .../Scoring/ManiaHealthProcessor.cs | 135 +- .../Scoring/ManiaHitWindows.cs | 134 +- .../Scoring/ManiaScoreProcessor.cs | 93 +- .../SingleStageVariantGenerator.cs | 30 +- .../ManiaEzProSkinEditorVirtualProvider.cs | 352 ++++ .../Skinning/Ez2/Ez2ColumnBackground.cs | 168 ++ .../Skinning/Ez2/Ez2HitExplosion.cs | 101 + .../Skinning/Ez2/Ez2HitTarget.cs | 78 + .../Skinning/Ez2/Ez2HoldBodyPiece.cs | 118 ++ .../Skinning/Ez2/Ez2HoldNoteHeadPiece.cs | 27 + .../Skinning/Ez2/Ez2HoldNoteHittingLayer.cs | 73 + .../Skinning/Ez2/Ez2HoldNoteTailPiece.cs | 132 ++ .../Skinning/Ez2/Ez2JudgementPiece.cs | 326 ++++ .../Skinning/Ez2/Ez2KeyArea.cs | 252 +++ .../Skinning/Ez2/Ez2KeyAreaPlus.cs | 297 +++ .../Skinning/Ez2/Ez2NotePiece.cs | 169 ++ .../Skinning/Ez2/Ez2StageBackground.cs | 16 + .../Skinning/Ez2/ManiaEz2SkinTransformer.cs | 267 +++ .../Skinning/Ez2HUD/Ez2SongProgress.cs | 139 ++ .../Skinning/Ez2HUD/Ez2SongProgressBar.cs | 208 ++ .../Skinning/Ez2HUD/Ez2SongProgressGraph.cs | 69 + .../Skinning/Ez2HUD/EzComComboCounter.cs | 153 ++ .../Skinning/Ez2HUD/EzComComboSprite.cs | 178 ++ .../Skinning/Ez2HUD/EzComComboSprite.txt | 260 +++ .../Skinning/Ez2HUD/EzComComboSprite2.txt | 98 + .../Skinning/Ez2HUD/EzComHitTiming.cs | 308 +++ .../Skinning/Ez2HUD/EzComHitTimingColumns.cs | 259 +++ .../Skinning/Ez2HUD/EzComKeyCounterDisplay.cs | 107 ++ .../Skinning/Ez2HUD/EzComO2JamPillUI.cs | 244 +++ .../Skinning/Ez2HUD/YuComFastSlowDisplay.cs | 674 +++++++ .../Skinning/EzStylePro/EzColumnBackground.cs | 153 ++ .../Skinning/EzStylePro/EzHitExplosion.cs | 82 + .../Skinning/EzStylePro/EzHitTarget.cs | 90 + .../Skinning/EzStylePro/EzHoldNoteHead.cs | 80 + .../EzStylePro/EzHoldNoteHittingLayer.cs | 99 + .../Skinning/EzStylePro/EzHoldNoteMiddle.cs | 249 +++ .../Skinning/EzStylePro/EzHoldNoteTail.cs | 117 ++ .../Skinning/EzStylePro/EzJudgementLine.cs | 96 + .../Skinning/EzStylePro/EzKeyArea.cs | 217 +++ .../Skinning/EzStylePro/EzNote.cs | 49 + .../Skinning/EzStylePro/EzNoteBase.cs | 199 ++ .../Skinning/EzStylePro/EzNoteSideLine.cs | 85 + .../Skinning/EzStylePro/EzStageBottom.cs | 105 ++ .../ManiaEzStyleProSkinTransformer.cs | 279 +++ .../Legacy/HitTargetInsetContainer.cs | 14 +- .../Skinning/SbI/ManiaSbISkinTransformer.cs | 215 +++ .../Skinning/SbI/SbIColumnBackground.cs | 126 ++ .../Skinning/SbI/SbIHoldBodyPiece.cs | 139 ++ .../Skinning/SbI/SbIHoldNoteHeadPiece.cs | 9 + .../Skinning/SbI/SbIHoldNoteHittingLayer.cs | 64 + .../Skinning/SbI/SbIHoldNoteTailPiece.cs | 85 + .../Skinning/SbI/SbIJudgementPiece.cs | 204 ++ .../Skinning/SbI/SbIKeyArea.cs | 100 + .../Skinning/SbI/SbINotePiece.cs | 111 ++ osu.Game.Rulesets.Mania/UI/Column.cs | 56 +- osu.Game.Rulesets.Mania/UI/ColumnFlow.cs | 45 +- .../Components/HitPositionPaddedContainer.cs | 25 +- .../UI/DrawableManiaRuleset.cs | 122 +- osu.Game.Rulesets.Mania/UI/Stage.cs | 163 ++ .../Editing/TestSceneEzSkinEditorScene.cs | 60 + .../Navigation/TestSceneScreenNavigation.cs | 2 +- osu.Game/Beatmaps/WorkingBeatmap.cs | 6 +- osu.Game/Configuration/BackgroundSource.cs | 7 + osu.Game/Configuration/OsuConfigManager.cs | 18 +- osu.Game/Database/RealmAccess.cs | 4 +- .../Graphics/Backgrounds/BeatmapBackground.cs | 2 +- .../BeatmapBackgroundWithStoryboard.cs | 2 +- .../Backgrounds/SeasonalBackgroundLoader.cs | 2 +- .../Graphics/Containers/ScalingContainer.cs | 38 +- osu.Game/Graphics/OsuColour.cs | 9 + .../Graphics/UserInterface/StarCounter.cs | 13 + osu.Game/ISkinEditorVirtualProvider.cs | 37 + .../Analysis/BaseEzScoreGraph.cs | 333 ++++ .../Diagnostics/EzManiaAnalysisPerf.cs | 295 +++ .../Analysis/EzAnalysisOptionsPopover.cs | 55 + .../Analysis/EzAnalysisScoreButton.cs | 83 + .../Analysis/EzBeatmapCalculator.cs | 161 ++ .../Analysis/EzBeatmapManiaAnalysisCache.cs | 682 +++++++ .../Analysis/EzBeatmapXxySrCache.cs | 304 +++ .../EzManiaAnalysisWarmupProcessor.cs | 286 +++ .../LAsEzExtensions/Analysis/EzScoreGraph.cs | 129 ++ .../LAsEzExtensions/Analysis/GirdPoints.cs | 176 ++ .../Analysis/HitEventTimingDistributionDot.cs | 146 ++ .../Analysis/IHitEventGenerator.cs | 13 + .../Analysis/ManiaAnalysisCacheLookup.cs | 143 ++ .../Analysis/ManiaBeatmapAnalysisCache.cs | 198 ++ .../Analysis/OptimizedBeatmapCalculator.cs | 299 +++ .../EzManiaAnalysisPersistentStore.cs | 896 +++++++++ .../Analysis/XxySrCalculatorBridge.cs | 106 ++ .../Analysis/XxySrDebugJson.cs | 113 ++ .../LAsEzExtensions/Audio/AudioExtensions.cs | 110 ++ .../Audio/InputAudioLatencyTracker.cs | 223 +++ .../Background/VideoBackgroundScreen.cs | 66 + .../Configuration/AnalysisSettings.cs | 32 + .../Configuration/EnumHealthMode.cs | 34 + .../Configuration/Ez2ConfigManager.cs | 521 +++++ .../Configuration/EzColumnTypeManager.cs | 80 + .../Configuration/EzLocalizationManager.cs | 252 +++ .../Configuration/EzMUGHitMode.cs | 34 + .../Configuration/LoopTimeRangeStore.cs | 34 + .../Configuration/ScalingGameMode.cs | 16 + .../Extensions/OsuSpriteTextExtensions.cs | 45 + .../Extensions/SettingsColourExtensions.cs | 96 + .../EzLocalTextureFactory.Preload.cs | 131 ++ .../LAsEzExtensions/EzLocalTextureFactory.cs | 507 +++++ osu.Game/LAsEzExtensions/EzToCollection.cs | 276 +++ .../HUD/EzComHitResultScore.cs | 532 ++++++ .../LAsEzExtensions/HUD/EzComRadarPanel.cs | 313 ++++ .../LAsEzExtensions/HUD/EzComScoreCounter.cs | 67 + osu.Game/LAsEzExtensions/HUD/EzComboText.cs | 107 ++ .../HUD/EzComsPreviewOverlay.cs | 146 ++ .../LAsEzExtensions/HUD/EzGetComboTexture.cs | 148 ++ .../LAsEzExtensions/HUD/EzGetScoreTexture.cs | 132 ++ .../HUD/EzHUDAccuracyCounter.cs | 237 +++ .../LAsEzExtensions/HUD/EzResourceManager.cs | 78 + osu.Game/LAsEzExtensions/HUD/EzScoreText.cs | 101 + .../LAsEzExtensions/HUD/EzSelectorEnumList.cs | 136 ++ .../LAsEzExtensions/HUD/EzSelectorTextures.cs | 198 ++ .../LAsEzExtensions/HUD/EzTextureFactory.txt | 133 ++ .../Screens/Edit/LoopIntervalDisplay.cs | 41 + .../Screens/Edit/LoopMarker.cs | 231 +++ .../LAsEzExtensions/Screens/EzColumnTab.cs | 334 ++++ .../Screens/EzEditorSidebar.cs | 104 + .../Screens/EzSelectorColour.cs | 189 ++ .../Screens/EzSettingsColour.cs | 86 + .../Screens/EzSkinColorButton.cs | 206 ++ .../Screens/EzSkinEditorScreen.cs | 388 ++++ .../Screens/EzSkinSettingsTab.cs | 363 ++++ .../LAsEzExtensions/Screens/HitModePopover.cs | 66 + osu.Game/LAsEzExtensions/Screens/IEzConfig.cs | 15 + .../LAsEzExtensions/Screens/IPreviewable.cs | 10 + .../Screens/OsuColourPickerWithAlpha.cs | 85 + .../Select/DuplicateVirtualTrack.cs | 254 +++ .../LAsEzExtensions/Select/EzKeyModeFilter.cs | 65 + .../Select/EzKeyModeSelector.cs | 390 ++++ .../Select/EzPreviewTrackManager.cs | 1137 +++++++++++ .../LAsEzExtensions/Select/EzToCollection.txt | 68 + .../Select/ManiaRulesetDropdown.txt | 197 ++ .../UserInterface/EzDisplay_LineGraph.cs | 140 ++ .../UserInterface/EzDisplay_XxySR.cs | 149 ++ .../UserInterface/TriangleBorderLineGraph.cs | 193 ++ osu.Game/OsuGame.cs | 121 ++ osu.Game/OsuGameBase.cs | 29 +- osu.Game/Overlays/Mods/ModSelectOverlay.cs | 2 + .../Overlays/NotificationOverlayToastTray.cs | 2 +- .../Sections/Audio/AudioDevicesSettings.cs | 51 +- .../Sections/Gameplay/GeneralSettings.cs | 19 +- .../Sections/Gameplay/InputSettings.cs | 12 +- .../Sections/Graphics/LayoutSettings.cs | 8 +- .../Settings/Sections/Input/KeyBindingRow.cs | 7 +- .../Settings/Sections/MaintenanceSection.cs | 2 + .../Overlays/Settings/Sections/SkinSection.cs | 3 + osu.Game/Overlays/SkinEditor/SkinEditor.cs | 15 +- .../Overlays/SkinEditor/SkinEditorOverlay.cs | 52 + .../SkinEditor/SkinEditorSceneLibrary.cs | 7 + osu.Game/Properties/AssemblyInfo.cs | 1 - osu.Game/Rulesets/Difficulty/Skills/Skill.cs | 7 + .../Rulesets/Judgements/JudgementResult.cs | 12 + .../Mods/IApplicableAfterConversion.cs | 21 + osu.Game/Rulesets/Mods/IHasApplyOrder.cs | 15 + osu.Game/Rulesets/Mods/ILoopTimeRangeMod.cs | 20 + .../Rulesets/Mods/IPreviewOverrideProvider.cs | 14 + osu.Game/Rulesets/Mods/ModRateAdjust.cs | 3 +- osu.Game/Rulesets/Mods/ModType.cs | 4 +- osu.Game/Rulesets/Scoring/HitResult.cs | 29 + osu.Game/Rulesets/Scoring/HitWindows.cs | 20 + osu.Game/Rulesets/Scoring/IHitWindows.cs | 34 + osu.Game/Rulesets/Scoring/ScoreProcessor.cs | 50 +- .../UI/GameplaySampleTriggerSource.cs | 11 + osu.Game/Screens/BackgroundScreen.cs | 12 +- .../Backgrounds/BackgroundScreenDefault.cs | 130 +- osu.Game/Screens/Edit/BottomBar.cs | 2 +- .../Edit/Components/PlaybackControl.cs | 134 +- .../Timelines/Summary/Parts/MarkerPart.cs | 10 + .../Timelines/Summary/SummaryTimeline.cs | 144 ++ osu.Game/Screens/Edit/EditorClock.cs | 32 +- osu.Game/Screens/Menu/MainMenuButton.cs | 10 + .../HUD/EzHealthDisplay/EzHealthDisplay.cs | 203 ++ .../EzHealthDisplayBackground.cs | 16 + .../HUD/EzHealthDisplay/EzHealthDisplayBar.cs | 78 + .../EzHealthDisplay/EzHealthDisplayBase.cs | 49 + .../HUD/HitErrorMeters/BarHitErrorMeter.cs | 14 +- .../Screens/Play/HUD/HoldForMenuButton.cs | 5 +- osu.Game/Screens/Play/Player.cs | 24 +- .../Play/Player_ManiaBackgroundScreen.cs | 180 ++ osu.Game/Screens/Play/SubmittingPlayer.cs | 62 +- osu.Game/Screens/Ranking/ResultsScreen.cs | 30 + osu.Game/Screens/Ranking/ScorePanelList.cs | 3 + .../ScoreHitEventGeneratorBridge.cs | 161 ++ .../Ranking/Statistics/StatisticsPanel.cs | 33 +- .../Carousel/DrawableCarouselBeatmap.cs | 311 ++- osu.Game/Screens/Select/FilterControl.cs | 114 +- osu.Game/Screens/Select/FilterCriteria.cs | 3 + osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 51 +- .../SelectV2/BeatmapCarouselFilterGrouping.cs | 75 +- .../SelectV2/BeatmapCarouselFilterMatching.cs | 1 + osu.Game/Screens/SelectV2/FilterControl.cs | 117 +- osu.Game/Screens/SelectV2/PanelBeatmap.cs | 343 +++- .../SelectV2/PanelBeatmapStandalone.cs | 410 +++- .../Screens/SelectV2/Panel_EzKpcDisplay.cs | 422 +++++ .../Screens/SelectV2/Panel_EzKpsDisplay.cs | 214 +++ osu.Game/Screens/SelectV2/SongSelect.cs | 99 +- osu.Game/Skinning/Ez2Skin.cs | 253 +++ osu.Game/Skinning/EzStyleProSkin.cs | 264 +++ osu.Game/Skinning/PoolableSkinnableSample.cs | 11 + osu.Game/Skinning/SbISkin.cs | 254 +++ osu.Game/Skinning/SkinInfo.cs | 3 + osu.Game/Skinning/SkinManager.cs | 8 + osu.Game/Skinning/SkinnableSprite.cs | 3 + .../Drawables/DrawableStoryboard.cs | 34 +- osu.Game/osu.Game.csproj | 10 +- osu.sln | 225 +-- osu.sln.DotSettings | 26 +- publish.py | 372 ++++ qodana.yaml | 6 + 341 files changed, 50267 insertions(+), 1508 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug-issue.yml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/_diffcalc_processor.yml create mode 100644 .github/workflows/auto-release.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/diffcalc.yml create mode 100644 .github/workflows/qodana_code_quality.yml delete mode 100644 .github/workflows/report-nunit.yml delete mode 100644 .github/workflows/sentry-release.yml delete mode 100644 .github/workflows/update-web-mod-definitions.yml create mode 100644 .vscode/settings.json create mode 100644 SkinScriptingImplementation/CHANGES.md create mode 100644 SkinScriptingImplementation/Dependencies/nuget-packages.txt create mode 100644 SkinScriptingImplementation/ExampleManiaSkinScript.lua create mode 100644 SkinScriptingImplementation/ExampleSkinScript.lua create mode 100644 SkinScriptingImplementation/README.md create mode 100644 SkinScriptingImplementation/osu.Game/OsuGame_SkinScripting.cs create mode 100644 SkinScriptingImplementation/osu.Game/Overlays/Dialog/FileImportFaultDialog.cs create mode 100644 SkinScriptingImplementation/osu.Game/Overlays/Settings/Sections/SkinSection.cs create mode 100644 SkinScriptingImplementation/osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/LegacySkin.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Scripting/ISkinScriptHost.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScript.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptInterface.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptManager.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingConfig.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/Skin.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/SkinManager.cs create mode 100644 SkinScriptingImplementation/osu.Game/Skinning/SkinnableDrawable.cs create mode 100644 appveyor.yml create mode 100644 appveyor_deploy.yml create mode 100644 osu.Desktop/EzMacOS/GameplaySpotlightBlocker.cs create mode 100644 osu.Desktop/EzMacOS/SpotlightKey.cs create mode 100644 osu.Game.Rulesets.Mania.Tests/Analysis/HalfHoldBeatmapFactory.cs create mode 100644 osu.Game.Rulesets.Mania.Tests/Analysis/SRCalculatorTunable.cs create mode 100644 osu.Game.Rulesets.Mania.Tests/Analysis/TestSceneSRTune.cs create mode 100644 osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModSpaceBody.cs create mode 100644 osu.Game.Rulesets.Mania.Tests/TestSceneEzHitEventHeatmapGraph.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/Analysis/CrossMatrixProvider.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph_clean.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/Analysis/ManiaScoreHitEventGenerator.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRCalculator.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRErrorCodes.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/CustomVisibilityContainer.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzEffectHelper.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzHitTimingGraphByColumn.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzJudgementText.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounter.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounterPro.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzManiaEnums.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitModeConvertor.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitTimingInfo.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzManiaModStrings.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzManiaStrings.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/EzStageDefinitionExtensions.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/FastSlowDisplayStrings.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/Helper/CustomHitWindows.cs create mode 100644 osu.Game.Rulesets.Mania/LAsEzMania/ManiaKeyCounterDisplay.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModBasicScrollSpeed.txt create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModCleanColumn.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModEz2Settings.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModFreeHit.txt create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModLoopPlayClip.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModNiceBPM.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSRAdjust.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSpaceBody.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModAdjust.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModChangeSpeedByAccuracy.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModCleaner.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModDoublelPlay.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModGracer.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModHelper.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJackAdjust.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJudgmentsAdjust.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLN.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNDoubleDistribution.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNJudgementAdjust.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNLongShortAddition.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNSimplify.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNTransformer.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModMalodyStyleLN.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNewJudgement.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNoteAdjust.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoM.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoMAnother.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Health.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Judgement.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModPlayfieldTransformation.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModReleaseAdjust.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModRemedy.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ModStarRatingRebirth.cs create mode 100644 osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/Utils.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/DrawableLNTailForNoRelease.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcDrawableLNTail.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcHoldNote.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/MalodyDrawableNote.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoComboBreakLNTail.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgementNote.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgmentHoldNote.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoMissLNBody.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HitModeExtension.cs create mode 100644 osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HoldNote.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Editor/ManiaEzProSkinEditorVirtualProvider.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2ColumnBackground.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitExplosion.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitTarget.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldBodyPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHeadPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHittingLayer.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteTailPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2JudgementPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyArea.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyAreaPlus.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2NotePiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2StageBackground.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2/ManiaEz2SkinTransformer.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgress.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressBar.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressGraph.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboCounter.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.txt create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite2.txt create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTiming.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTimingColumns.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComKeyCounterDisplay.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComO2JamPillUI.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/Ez2HUD/YuComFastSlowDisplay.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzColumnBackground.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitExplosion.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitTarget.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHead.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHittingLayer.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteMiddle.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteTail.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzJudgementLine.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzKeyArea.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNote.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteBase.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteSideLine.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzStageBottom.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/EzStylePro/ManiaEzStyleProSkinTransformer.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/ManiaSbISkinTransformer.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbIColumnBackground.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldBodyPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHeadPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHittingLayer.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteTailPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbIJudgementPiece.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbIKeyArea.cs create mode 100644 osu.Game.Rulesets.Mania/Skinning/SbI/SbINotePiece.cs create mode 100644 osu.Game.Tests/Visual/Editing/TestSceneEzSkinEditorScene.cs create mode 100644 osu.Game/ISkinEditorVirtualProvider.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/BaseEzScoreGraph.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/Diagnostics/EzManiaAnalysisPerf.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/EzAnalysisOptionsPopover.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/EzAnalysisScoreButton.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/EzBeatmapCalculator.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/EzBeatmapManiaAnalysisCache.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/EzBeatmapXxySrCache.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/EzManiaAnalysisWarmupProcessor.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/EzScoreGraph.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/GirdPoints.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/HitEventTimingDistributionDot.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/IHitEventGenerator.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/ManiaAnalysisCacheLookup.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/ManiaBeatmapAnalysisCache.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/OptimizedBeatmapCalculator.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/Persistence/EzManiaAnalysisPersistentStore.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/XxySrCalculatorBridge.cs create mode 100644 osu.Game/LAsEzExtensions/Analysis/XxySrDebugJson.cs create mode 100644 osu.Game/LAsEzExtensions/Audio/AudioExtensions.cs create mode 100644 osu.Game/LAsEzExtensions/Audio/InputAudioLatencyTracker.cs create mode 100644 osu.Game/LAsEzExtensions/Background/VideoBackgroundScreen.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/AnalysisSettings.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/EnumHealthMode.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/Ez2ConfigManager.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/EzColumnTypeManager.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/EzLocalizationManager.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/EzMUGHitMode.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/LoopTimeRangeStore.cs create mode 100644 osu.Game/LAsEzExtensions/Configuration/ScalingGameMode.cs create mode 100644 osu.Game/LAsEzExtensions/Extensions/OsuSpriteTextExtensions.cs create mode 100644 osu.Game/LAsEzExtensions/Extensions/SettingsColourExtensions.cs create mode 100644 osu.Game/LAsEzExtensions/EzLocalTextureFactory.Preload.cs create mode 100644 osu.Game/LAsEzExtensions/EzLocalTextureFactory.cs create mode 100644 osu.Game/LAsEzExtensions/EzToCollection.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzComHitResultScore.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzComRadarPanel.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzComScoreCounter.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzComboText.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzComsPreviewOverlay.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzGetComboTexture.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzGetScoreTexture.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzHUDAccuracyCounter.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzResourceManager.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzScoreText.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzSelectorEnumList.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzSelectorTextures.cs create mode 100644 osu.Game/LAsEzExtensions/HUD/EzTextureFactory.txt create mode 100644 osu.Game/LAsEzExtensions/Screens/Edit/LoopIntervalDisplay.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/Edit/LoopMarker.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/EzColumnTab.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/EzEditorSidebar.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/EzSelectorColour.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/EzSettingsColour.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/EzSkinColorButton.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/EzSkinEditorScreen.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/EzSkinSettingsTab.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/HitModePopover.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/IEzConfig.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/IPreviewable.cs create mode 100644 osu.Game/LAsEzExtensions/Screens/OsuColourPickerWithAlpha.cs create mode 100644 osu.Game/LAsEzExtensions/Select/DuplicateVirtualTrack.cs create mode 100644 osu.Game/LAsEzExtensions/Select/EzKeyModeFilter.cs create mode 100644 osu.Game/LAsEzExtensions/Select/EzKeyModeSelector.cs create mode 100644 osu.Game/LAsEzExtensions/Select/EzPreviewTrackManager.cs create mode 100644 osu.Game/LAsEzExtensions/Select/EzToCollection.txt create mode 100644 osu.Game/LAsEzExtensions/Select/ManiaRulesetDropdown.txt create mode 100644 osu.Game/LAsEzExtensions/UserInterface/EzDisplay_LineGraph.cs create mode 100644 osu.Game/LAsEzExtensions/UserInterface/EzDisplay_XxySR.cs create mode 100644 osu.Game/LAsEzExtensions/UserInterface/TriangleBorderLineGraph.cs create mode 100644 osu.Game/Rulesets/Mods/IApplicableAfterConversion.cs create mode 100644 osu.Game/Rulesets/Mods/IHasApplyOrder.cs create mode 100644 osu.Game/Rulesets/Mods/ILoopTimeRangeMod.cs create mode 100644 osu.Game/Rulesets/Mods/IPreviewOverrideProvider.cs create mode 100644 osu.Game/Rulesets/Scoring/IHitWindows.cs create mode 100644 osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplay.cs create mode 100644 osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBackground.cs create mode 100644 osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBar.cs create mode 100644 osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBase.cs create mode 100644 osu.Game/Screens/Play/Player_ManiaBackgroundScreen.cs create mode 100644 osu.Game/Screens/Ranking/Statistics/ScoreHitEventGeneratorBridge.cs create mode 100644 osu.Game/Screens/SelectV2/Panel_EzKpcDisplay.cs create mode 100644 osu.Game/Screens/SelectV2/Panel_EzKpsDisplay.cs create mode 100644 osu.Game/Skinning/Ez2Skin.cs create mode 100644 osu.Game/Skinning/EzStyleProSkin.cs create mode 100644 osu.Game/Skinning/SbISkin.cs create mode 100644 publish.py create mode 100644 qodana.yaml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index fc61573416..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: ppy -custom: https://osu.ppy.sh/home/support diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml deleted file mode 100644 index a8a5d5e64b..0000000000 --- a/.github/ISSUE_TEMPLATE/bug-issue.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Bug report -description: Report a very clearly broken issue. -body: - - type: markdown - attributes: - value: | - # osu! bug report - - Important to note that your issue may have already been reported before. Please check: - - Pinned issues, at the top of https://github.com/ppy/osu/issues. - - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0). - - And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful. - - # ATTENTION LINUX USERS - - If you are having an issue and it is hardware related, **please open a [q&a discussion](https://github.com/ppy/osu/discussions/categories/q-a)** instead of an issue. There's a high chance your issue is due to your system configuration, and not our software. - - - type: dropdown - attributes: - label: Type - options: - - Crash to desktop - - Game behaviour - - Performance - - Cosmetic - - Other - validations: - required: true - - type: textarea - attributes: - label: Bug description - description: How did you find the bug? Any additional details that might help? - validations: - required: true - - type: textarea - attributes: - label: Screenshots or videos - description: Add screenshots or videos that show the bug here. - placeholder: Drag and drop the screenshots/videos into this box. - validations: - required: false - - type: input - attributes: - label: Version - description: The version you encountered this bug on. This is shown at the end of the settings overlay. - validations: - required: true - - type: markdown - attributes: - value: | - ## Logs - - Attaching log files is required for **every** issue, regardless of whether you deem them required or not. See instructions below on how to find them. - - ### Desktop platforms - - If the game has not yet been closed since you found the bug: - 1. Head on to game settings and click on "Export logs" - 2. Click the notification to locate the file - 3. Drag the generated `.zip` files into the github issue window - - ![export logs button](https://github.com/ppy/osu/assets/191335/cbfa5550-b7ed-4c5c-8dd0-8b87cc90ad9b) - - ### Mobile platforms - - The places to find the logs on mobile platforms are as follows: - - *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app. - - *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) - - - type: textarea - attributes: - label: Logs - placeholder: Drag and drop the log files into this box. - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index ec57232126..0000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,12 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Help - url: https://github.com/ppy/osu/discussions/categories/q-a - about: osu! not working or performing as you'd expect? Not sure it's a bug? Check the Q&A section! - - name: Suggestions or feature request - url: https://github.com/ppy/osu/discussions/categories/ideas - about: Got something you think should change or be added? Search for or start a new discussion! - - name: osu!stable issues - url: https://github.com/ppy/osu-stable-issues - about: For osu!(stable) - ie. the current "live" game version, check out the dedicated repository. Note that this is for serious bug reports only, not tech support. - diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 814fc81f51..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,46 +0,0 @@ -version: 2 -updates: -- package-ecosystem: nuget - directory: "/" - schedule: - interval: monthly - time: "17:00" - open-pull-requests-limit: 0 # disabled until https://github.com/dependabot/dependabot-core/issues/369 is resolved. - ignore: - - dependency-name: Microsoft.EntityFrameworkCore.Design - versions: - - "> 2.2.6" - - dependency-name: Microsoft.EntityFrameworkCore.Sqlite - versions: - - "> 2.2.6" - - dependency-name: Microsoft.EntityFrameworkCore.Sqlite.Core - versions: - - "> 2.2.6" - - dependency-name: Microsoft.Extensions.DependencyInjection - versions: - - ">= 5.a, < 6" - - dependency-name: NUnit3TestAdapter - versions: - - ">= 3.16.a, < 3.17" - - dependency-name: Microsoft.NET.Test.Sdk - versions: - - 16.9.1 - - dependency-name: Microsoft.Extensions.DependencyInjection - versions: - - 3.1.11 - - 3.1.12 - - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson - versions: - - 3.1.11 - - dependency-name: Microsoft.NETCore.Targets - versions: - - 5.0.0 - - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.MessagePack - versions: - - 5.0.2 - - dependency-name: NUnit - versions: - - 3.13.1 - - dependency-name: Microsoft.AspNetCore.SignalR.Client - versions: - - 3.1.11 diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml deleted file mode 100644 index 2f1b2cf893..0000000000 --- a/.github/workflows/_diffcalc_processor.yml +++ /dev/null @@ -1,228 +0,0 @@ -name: "🔒diffcalc (do not use)" - -on: - workflow_call: - inputs: - id: - type: string - head-sha: - type: string - pr-url: - type: string - pr-text: - type: string - dispatch-inputs: - type: string - outputs: - target: - description: The comparison target. - value: ${{ jobs.generator.outputs.target }} - sheet: - description: The comparison spreadsheet. - value: ${{ jobs.generator.outputs.sheet }} - secrets: - DIFFCALC_GOOGLE_CREDENTIALS: - required: true - -env: - GENERATOR_DIR: ${{ github.workspace }}/${{ inputs.id }} - GENERATOR_ENV: ${{ github.workspace }}/${{ inputs.id }}/.env - -defaults: - run: - shell: bash -euo pipefail {0} - -jobs: - generator: - name: Run - runs-on: self-hosted - timeout-minutes: 1440 - - outputs: - target: ${{ steps.run.outputs.target }} - sheet: ${{ steps.run.outputs.sheet }} - - steps: - - name: Checkout diffcalc-sheet-generator - uses: actions/checkout@v4 - with: - path: ${{ inputs.id }} - repository: 'smoogipoo/diffcalc-sheet-generator' - - - name: Add base environment - env: - GOOGLE_CREDS_FILE: ${{ github.workspace }}/${{ inputs.id }}/google-credentials.json - VARS_JSON: ${{ (vars != null && toJSON(vars)) || '' }} - run: | - # Required by diffcalc-sheet-generator - cp '${{ env.GENERATOR_DIR }}/.env.sample' "${{ env.GENERATOR_ENV }}" - - # Add Google credentials - echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ env.GOOGLE_CREDS_FILE }}" - - # Add repository variables - echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do - opt=$(jq -r '.key' <<< ${line}) - val=$(jq -r '.value' <<< ${line}) - - if [[ "${opt}" =~ ^DIFFCALC_ ]]; then - optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-) - sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ env.GENERATOR_ENV }}" - fi - done - - - name: Add HEAD environment - run: | - sed -i "s;^OSU_A=.*$;OSU_A=${{ inputs.head-sha }};" "${{ env.GENERATOR_ENV }}" - - - name: Add pull-request environment - if: ${{ inputs.pr-url != '' }} - run: | - sed -i "s;^OSU_B=.*$;OSU_B=${{ inputs.pr-url }};" "${{ env.GENERATOR_ENV }}" - - - name: Add comment environment - if: ${{ inputs.pr-text != '' }} - env: - PR_TEXT: ${{ inputs.pr-text }} - run: | - # Add comment environment - echo "${PR_TEXT}" | sed -r 's/\r$//' | { grep -E '^\w+=' || true; } | while read -r line; do - opt=$(echo "${line}" | cut -d '=' -f1) - sed -i "s;^${opt}=.*$;${line};" "${{ env.GENERATOR_ENV }}" - done - - - name: Add dispatch environment - if: ${{ inputs.dispatch-inputs != '' }} - env: - DISPATCH_INPUTS_JSON: ${{ inputs.dispatch-inputs }} - run: | - function get_input() { - echo "${DISPATCH_INPUTS_JSON}" | jq -r ".\"$1\"" - } - - osu_a=$(get_input 'osu-a') - osu_b=$(get_input 'osu-b') - ruleset=$(get_input 'ruleset') - generators=$(get_input 'generators') - difficulty_calculator_a=$(get_input 'difficulty-calculator-a') - difficulty_calculator_b=$(get_input 'difficulty-calculator-b') - score_processor_a=$(get_input 'score-processor-a') - score_processor_b=$(get_input 'score-processor-b') - converts=$(get_input 'converts') - ranked_only=$(get_input 'ranked-only') - - sed -i "s;^OSU_B=.*$;OSU_B=${osu_b};" "${{ env.GENERATOR_ENV }}" - sed -i "s/^RULESET=.*$/RULESET=${ruleset}/" "${{ env.GENERATOR_ENV }}" - sed -i "s/^GENERATORS=.*$/GENERATORS=${generators}/" "${{ env.GENERATOR_ENV }}" - - if [[ "${osu_a}" != 'latest' ]]; then - sed -i "s;^OSU_A=.*$;OSU_A=${osu_a};" "${{ env.GENERATOR_ENV }}" - fi - - if [[ "${difficulty_calculator_a}" != 'latest' ]]; then - sed -i "s;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${difficulty_calculator_a};" "${{ env.GENERATOR_ENV }}" - fi - - if [[ "${difficulty_calculator_b}" != 'latest' ]]; then - sed -i "s;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${difficulty_calculator_b};" "${{ env.GENERATOR_ENV }}" - fi - - if [[ "${score_processor_a}" != 'latest' ]]; then - sed -i "s;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${score_processor_a};" "${{ env.GENERATOR_ENV }}" - fi - - if [[ "${score_processor_b}" != 'latest' ]]; then - sed -i "s;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${score_processor_b};" "${{ env.GENERATOR_ENV }}" - fi - - if [[ "${converts}" == 'true' ]]; then - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ env.GENERATOR_ENV }}" - else - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ env.GENERATOR_ENV }}" - fi - - if [[ "${ranked_only}" == 'true' ]]; then - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ env.GENERATOR_ENV }}" - else - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ env.GENERATOR_ENV }}" - fi - - - name: Query latest scores - id: query-scores - run: | - ruleset=$(cat ${{ env.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-) - performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') - - echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" - echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" - echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" - - - name: Restore score cache - id: restore-score-cache - uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 - with: - path: ${{ steps.query-scores.outputs.DATA_PKG }} - key: ${{ steps.query-scores.outputs.DATA_NAME }} - - - name: Download scores - if: steps.restore-score-cache.outputs.cache-hit != 'true' - run: | - wget -q -O "${{ steps.query-scores.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-scores.outputs.DATA_PKG }}" - - - name: Extract scores - run: | - tar -I lbzip2 -xf "${{ steps.query-scores.outputs.DATA_PKG }}" - rm -r "${{ steps.query-scores.outputs.TARGET_DIR }}" - mv "${{ steps.query-scores.outputs.DATA_NAME }}" "${{ steps.query-scores.outputs.TARGET_DIR }}" - - - name: Query latest beatmaps - id: query-beatmaps - run: | - beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') - - echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}" - echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}" - echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" - - - name: Restore beatmap cache - id: restore-beatmap-cache - uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 - with: - path: ${{ steps.query-beatmaps.outputs.DATA_PKG }} - key: ${{ steps.query-beatmaps.outputs.DATA_NAME }} - - - name: Download beatmap - if: steps.restore-beatmap-cache.outputs.cache-hit != 'true' - run: | - wget -q -O "${{ steps.query-beatmaps.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-beatmaps.outputs.DATA_PKG }}" - - - name: Extract beatmap - run: | - tar -I lbzip2 -xf "${{ steps.query-beatmaps.outputs.DATA_PKG }}" - rm -r "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" - mv "${{ steps.query-beatmaps.outputs.DATA_NAME }}" "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" - - - name: Run - id: run - run: | - # Add the GitHub token. This needs to be done here because it's unique per-job. - sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ env.GENERATOR_ENV }}" - - cd "${{ env.GENERATOR_DIR }}" - - docker compose up --build --detach - docker compose logs --follow & - docker compose wait generator - - link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/') - target=$(cat "${{ env.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-) - - echo "target=${target}" >> "${GITHUB_OUTPUT}" - echo "sheet=${link}" >> "${GITHUB_OUTPUT}" - - - name: Shutdown - if: ${{ always() }} - run: | - cd "${{ env.GENERATOR_DIR }}" - docker compose down --volumes - rm -rf "${{ env.GENERATOR_DIR }}" diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000000..9d2592f23b --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,312 @@ +name: Ez2Lazer Auto Release (manual build + package + release) + +on: + workflow_dispatch: + inputs: + tag: + description: '发布使用的 tag(格式 YYYY-M-D,年为四位,月禁止前导0 如 1-12,日为1-31,例如 2026-1-14),必填。工作流会在你选择的分支最新提交上创建该 tag(如果远程不存在)。' + required: true + branch: + description: '主仓要构建的分支(默认 locmain)' + required: true + default: 'locmain' + framework_branch: + description: '可选:用于 checkout osu-framework 的分支(留空则使用上面的 branch)' + required: false + default: '' + resources_branch: + description: '可选:用于 checkout osu-resources 的分支(留空则使用上面的 branch)' + required: false + default: '' + create_zip: + description: '是否在 workflow 中创建 zip 包(true/false)。默认 false' + required: false + default: 'false' + upload_assets: + description: '是否在 workflow 完成后创建 GitHub Release 并上传 zip(true/false)。默认 false' + required: false + default: 'false' + +jobs: + build-and-package: + runs-on: windows-latest + + steps: + - name: Checkout main repo into osu/ + uses: actions/checkout@v4 + with: + fetch-depth: 0 + path: osu + ref: refs/heads/${{ github.event.inputs.branch }} + + - name: Checkout osu-framework (deps) as sibling + uses: actions/checkout@v4 + with: + repository: SK-la/osu-framework + path: osu-framework + # Use framework_branch if provided, otherwise fall back to the main `branch` input + ref: refs/heads/${{ github.event.inputs.framework_branch || github.event.inputs.branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate osu-framework checkout (Windows) + shell: pwsh + run: | + if (-not (Test-Path 'osu-framework\.git')) { + Write-Output 'actions/checkout did not populate osu-framework; attempting HTTPS clone fallback' + git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/SK-la/osu-framework.git osu-framework + Push-Location osu-framework + $FrameworkBranch = '${{ github.event.inputs.framework_branch || github.event.inputs.branch }}' + git checkout $FrameworkBranch || Write-Output "checkout $FrameworkBranch failed, continuing" + Pop-Location + } else { + Write-Output 'osu-framework already present' + } + + - name: Checkout osu-resources (resources) as sibling + uses: actions/checkout@v4 + with: + repository: SK-la/osu-resources + path: osu-resources + ref: refs/heads/${{ github.event.inputs.resources_branch || github.event.inputs.branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate osu-resources checkout (Windows) + shell: pwsh + run: | + if (-not (Test-Path 'osu-resources\.git')) { + Write-Output 'actions/checkout did not populate osu-resources; attempting HTTPS clone fallback' + git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/SK-la/osu-resources.git osu-resources + Push-Location osu-resources + $ResourcesBranch = '${{ github.event.inputs.resources_branch || github.event.inputs.branch }}' + git checkout $ResourcesBranch || Write-Output "checkout $ResourcesBranch failed, continuing" + Pop-Location + } else { + Write-Output 'osu-resources already present' + } + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Cache NuGet packages (Windows) + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: C:\\Users\\runneradmin\\.nuget\\packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props','**/*.csproj','**/packages.lock.json') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Cache MSBuild outputs (obj/bin) + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: | + **/obj + **/bin + key: msbuild-${{ runner.os }}-${{ hashFiles('**/*.csproj','**/Directory.Packages.props') }} + restore-keys: | + msbuild-${{ runner.os }}- + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Dotnet restore (desktop project) + working-directory: osu + shell: pwsh + run: | + dotnet --info + dotnet restore ./osu.Desktop/osu.Desktop.csproj --packages $env:USERPROFILE\\.nuget\\packages + + - name: Dotnet build (desktop project) + working-directory: osu + shell: pwsh + run: | + dotnet build ./osu.Desktop/osu.Desktop.csproj -c Release -p:GenerateFullPaths=true --no-restore --verbosity minimal + + - name: Prepare release tag and environment + shell: pwsh + id: export + run: | + $inputTag = '${{ github.event.inputs.tag }}' + if (-not $inputTag) { Write-Error 'Input tag is required and must follow YYYY-M-D format'; exit 1 } + # validate strict: 4-digit year, month 1-12 without leading zero, day 1-31 without leading zero + if ($inputTag -notmatch '^[0-9]{4}-(?:[1-9]|1[0-2])-(?:[1-9]|[12][0-9]|3[01])$') { Write-Error 'Tag must match YYYY-M-D with no leading zero in month (e.g. 2026-1-14)'; exit 1 } + Write-Output "RELEASE_TAG=$inputTag" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # export branch for clarity (branch chosen when triggering the workflow) + Write-Output "TARGET_BRANCH=${{ github.event.inputs.branch }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Run publish script (pack & create release) + working-directory: osu + shell: pwsh + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + PYTHONUTF8: '1' + PYTHONIOENCODING: 'utf-8' + run: | + # Use the RELEASE_TAG prepared by prior step; fail if not set to avoid fallback behavior + if (-not $env:RELEASE_TAG) { Write-Error 'RELEASE_TAG is not set; aborting to avoid fallback tag generation' ; exit 1 } + $tag = $env:RELEASE_TAG + Write-Output "Using TAG=$tag" + # Explicitly pass project, workdir, and outroot so artifacts land under osu/artifacts + $project = Join-Path (Get-Location) 'osu.Desktop\osu.Desktop.csproj' + $workdir = (Get-Location).Path + # outroot should be the working directory; publish.py will append /artifacts + $outroot = $workdir + $create_zip = '${{ github.event.inputs.create_zip }}' + if ($create_zip -eq 'true') { + python ./publish.py --project "$project" --workdir "$workdir" --outroot "$outroot" --deps-source none --resources-path "${{ github.workspace }}\osu-resources\osu.Game.Resources\Resources" --tag "$tag" + } else { + Write-Output 'create_zip is false -> running publish.py with --no-zip to avoid creating zip files' + python ./publish.py --project "$project" --workdir "$workdir" --outroot "$outroot" --deps-source none --resources-path "${{ github.workspace }}\osu-resources\osu.Game.Resources\Resources" --tag "$tag" --no-zip + } + + - name: Debug publish environment (list files + --help) + working-directory: osu + shell: pwsh + continue-on-error: true + run: | + Write-Output "PWD: $(Get-Location)" + Write-Output "Listing osu/ (first 200 entries)" + Get-ChildItem -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 200 | ForEach-Object { Write-Output $_.FullName } + Write-Output "Python version:" + python --version || Write-Output 'python --version failed' + Write-Output "Dotnet info:" + dotnet --info || Write-Output 'dotnet --info failed' + Write-Output "Show the first 200 lines of publish.py" + Get-Content publish.py -TotalCount 200 -ErrorAction SilentlyContinue || Write-Output 'cat publish.py failed' + Write-Output "Running publish.py --help" + python ./publish.py --help || Write-Output 'publish.py --help failed' + + - name: List artifacts directories for diagnostics + shell: pwsh + run: | + Write-Output 'Listing $GITHUB_WORKSPACE/artifacts:' + if (Test-Path "$env:GITHUB_WORKSPACE\artifacts") { Get-ChildItem -Path "$env:GITHUB_WORKSPACE\artifacts" -Recurse -Force | Select-Object FullName, Length | ForEach-Object { Write-Output "$($_.FullName) - $($_.Length)" } } else { Write-Output 'No root artifacts dir' } + Write-Output 'Listing $GITHUB_WORKSPACE/osu/artifacts:' + if (Test-Path "$env:GITHUB_WORKSPACE\osu\artifacts") { Get-ChildItem -Path "$env:GITHUB_WORKSPACE\osu\artifacts" -Recurse -Force | Select-Object FullName, Length | ForEach-Object { Write-Output "$($_.FullName) - $($_.Length)" } } else { Write-Output 'No osu artifacts dir' } + + - name: Find produced zip artifact + if: ${{ github.event.inputs.create_zip == 'true' }} + shell: pwsh + id: list_zips + run: | + Write-Output "Searching for zip files under $env:GITHUB_WORKSPACE and $env:GITHUB_WORKSPACE\osu\artifacts" + $repoRoot = $env:GITHUB_WORKSPACE + $paths = @() + $paths += $repoRoot + $paths += (Join-Path $repoRoot 'osu\artifacts') + $found = $null + foreach ($p in $paths) { + if (Test-Path $p) { + Write-Output "Searching in: $p" + $z = Get-ChildItem -Path $p -Filter *.zip -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | ForEach-Object { $_.FullName } + if ($z -and $z.Count -gt 0) { $found = $z; break } + } + } + if ($found) { + Write-Output "Found zip(s): $found" + $first = $found[0] + $name = Split-Path -Path $first -Leaf + Write-Output "zip_path=$first" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Output "zip_name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } else { + Write-Output "No zip files found in searched locations." + exit 1 + } + + - name: Normalize artifact names to fixed asset names + if: ${{ github.event.inputs.create_zip == 'true' }} + id: prepare_assets + shell: pwsh + run: | + $ws = $env:GITHUB_WORKSPACE + $artifacts_dir = Join-Path $ws 'artifacts' + if (-not (Test-Path $artifacts_dir)) { New-Item -ItemType Directory -Path $artifacts_dir | Out-Null } + + $release_pattern = 'Ez2Lazer_release_x64*.zip' + $debug_pattern = 'Ez2Lazer_debug_x64*.zip' + + function find-first([string]$base, [string]$pattern) { + $f = Get-ChildItem -Path $base -Filter $pattern -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | Select-Object -First 1 + return $f + } + + $rel = find-first $ws $release_pattern + if (-not $rel) { $rel = find-first (Join-Path $ws 'osu\artifacts') $release_pattern } + $dbg = find-first $ws $debug_pattern + if (-not $dbg) { $dbg = find-first (Join-Path $ws 'osu\artifacts') $debug_pattern } + + $out_rel = '' + $out_dbg = '' + if ($rel) { + $out_rel = Join-Path $artifacts_dir 'Ez2Lazer_release_x64.zip' + Copy-Item -Path $rel.FullName -Destination $out_rel -Force + Write-Output "Prepared release asset: $out_rel" + } else { Write-Output 'Release zip not found' } + + if ($dbg) { + $out_dbg = Join-Path $artifacts_dir 'Ez2Lazer_debug_x64.zip' + Copy-Item -Path $dbg.FullName -Destination $out_dbg -Force + Write-Output "Prepared debug asset: $out_dbg" + } else { Write-Output 'Debug zip not found' } + + Write-Output "release_asset_path=$out_rel" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Output "debug_asset_path=$out_dbg" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Check remote tag existence and create if missing + shell: pwsh + run: | + $tag = '${{ env.RELEASE_TAG }}' + Write-Output "Checking remote for tag $tag..." + $found = git ls-remote --tags https://github.com/${{ github.repository }}.git refs/tags/$tag | Select-String -Pattern $tag + if ($found) { Write-Output "Tag $tag exists remotely"; exit 0 } + Write-Output "Tag $tag not found remotely - creating on branch tip" + # determine commit to tag: use the branch selected when triggering the workflow + $targetBranch = '${{ github.event.inputs.branch }}' + Write-Output "Resolving latest commit of branch $targetBranch" + $commitLine = git ls-remote https://github.com/${{ github.repository }}.git refs/heads/$targetBranch + if (-not $commitLine) { Write-Error "Could not resolve branch $targetBranch"; exit 1 } + $commit = $commitLine.Split()[0] + Write-Output "Tagging commit $commit" + git -C osu config user.name "github-actions[bot]" + git -C osu config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C osu tag $tag $commit + git -C osu push https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git refs/tags/$tag + + - name: Create or get GitHub Release (create tag if needed) + if: ${{ github.event.inputs.upload_assets == 'true' }} + id: create_release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + name: ${{ env.RELEASE_TAG }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload release asset (release) + if: ${{ github.event.inputs.upload_assets == 'true' && steps.prepare_assets.outputs.release_asset_path != '' }} + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ steps.prepare_assets.outputs.release_asset_path }} + asset_name: Ez2Lazer_release_x64.zip + asset_content_type: application/zip + + - name: Upload release asset (debug) + if: ${{ github.event.inputs.upload_assets == 'true' && steps.prepare_assets.outputs.debug_asset_path != '' }} + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ${{ steps.prepare_assets.outputs.debug_asset_path }} + asset_name: Ez2Lazer_debug_x64.zip + asset_content_type: application/zip diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7dfe3d11c2..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,154 +0,0 @@ -on: [push, pull_request] -name: Continuous Integration -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - inspect-code: - name: Code Quality - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install .NET 8.0.x - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "8.0.x" - - - name: Restore Tools - run: dotnet tool restore - - - name: Restore Packages - run: dotnet restore osu.Desktop.slnf - - - name: Restore inspectcode cache - uses: actions/cache@v4 - with: - path: ${{ github.workspace }}/inspectcode - key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', 'osu*.slnf', '.editorconfig', '.globalconfig', 'CodeAnalysis/*', '**/*.csproj', '**/*.props') }} - - - name: Dotnet code style - run: dotnet build -c Debug -warnaserror osu.Desktop.slnf -p:EnforceCodeStyleInBuild=true - - - name: CodeFileSanity - run: | - # TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround. - # FIXME: Suppress warnings from templates project - exit_code=0 - while read -r line; do - if [[ ! -z "$line" ]]; then - echo "::error::$line" - exit_code=1 - fi - done <<< $(dotnet codefilesanity) - exit $exit_code - - - name: InspectCode - run: dotnet jb inspectcode $(pwd)/osu.Desktop.slnf --no-build --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN - - - name: NVika - run: dotnet nvika parsereport "${{github.workspace}}/inspectcodereport.xml" --treatwarningsaserrors - - test: - name: Test - runs-on: ${{matrix.os.fullname}} - env: - OSU_EXECUTION_MODE: ${{matrix.threadingMode}} - strategy: - fail-fast: false - matrix: - os: - - { prettyname: Windows, fullname: windows-latest } - # macOS runner performance has gotten unbearably slow so let's turn them off temporarily. - # - { prettyname: macOS, fullname: macos-latest } - - { prettyname: Linux, fullname: ubuntu-latest } - threadingMode: ['SingleThread', 'MultiThreaded'] - timeout-minutes: 120 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install .NET 8.0.x - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "8.0.x" - - - name: Compile - run: dotnet build -c Debug -warnaserror osu.Desktop.slnf - - - name: Test - run: > - dotnet test - osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll - osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll - osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll - osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll - osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll - osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll - Templates/**/*.Tests/bin/Debug/**/*.Tests.dll - --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" - -- - NUnit.ConsoleOut=0 - - # Attempt to upload results even if test fails. - # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always - - name: Upload Test Results - uses: actions/upload-artifact@v4 - if: ${{ always() }} - with: - name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} - path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx - - build-only-android: - name: Build only (Android) - runs-on: windows-latest - timeout-minutes: 60 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup JDK 11 - uses: actions/setup-java@v4 - with: - distribution: microsoft - java-version: 11 - - - name: Install .NET 8.0.x - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "8.0.x" - - - name: Install .NET workloads - run: dotnet workload install android - - - name: Compile - run: dotnet build -c Debug osu.Android.slnf - - build-only-ios: - name: Build only (iOS) - runs-on: macos-15 - timeout-minutes: 60 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install .NET 8.0.x - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "8.0.x" - - - name: Install .NET Workloads - run: dotnet workload install ios - - # https://github.com/dotnet/macios/issues/19157 - # https://github.com/actions/runner-images/issues/12758 - - name: Use Xcode 16.4 - run: sudo xcode-select -switch /Applications/Xcode_16.4.app - - - name: Build - run: dotnet build -c Debug osu.iOS.slnf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 1a921b21ae..0000000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Pack and nuget - -on: - push: - tags: - - '*' - -jobs: - notify_pending_production_deploy: - runs-on: ubuntu-latest - steps: - - name: Submit pending deployment notification - run: | - export TITLE="Pending osu Production Deployment: $GITHUB_REF_NAME" - export URL="https://github.com/ppy/osu/actions/runs/$GITHUB_RUN_ID" - export DESCRIPTION="Awaiting approval for building NuGet packages for tag $GITHUB_REF_NAME: - [View Workflow Run]($URL)" - export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID" - - BODY="$(jq --null-input '{ - "embeds": [ - { - "title": env.TITLE, - "color": 15098112, - "description": env.DESCRIPTION, - "url": env.URL, - "author": { - "name": env.GITHUB_ACTOR, - "icon_url": env.ACTOR_ICON - } - } - ] - }')" - - curl \ - -H "Content-Type: application/json" \ - -d "$BODY" \ - "${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}" - - pack: - name: Pack - runs-on: ubuntu-latest - environment: production - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set artifacts directory - id: artifactsPath - run: echo "::set-output name=nuget_artifacts::${{github.workspace}}/artifacts" - - - name: Install .NET 8.0.x - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "8.0.x" - - - name: Pack - run: | - # Replace project references in templates with package reference, because they're included as source files. - dotnet remove Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game/osu.Game.csproj - dotnet remove Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj - dotnet remove Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game/osu.Game.csproj - dotnet remove Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj - - dotnet add Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} - dotnet add Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} - dotnet add Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} - dotnet add Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }} - - # Pack - dotnet pack -c Release osu.Game /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} - dotnet pack -c Release osu.Game.Rulesets.Osu /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} - dotnet pack -c Release osu.Game.Rulesets.Taiko /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} - dotnet pack -c Release osu.Game.Rulesets.Catch /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} - dotnet pack -c Release osu.Game.Rulesets.Mania /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}} - dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}} - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: osu - path: | - ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg - ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.snupkg - - - name: Publish packages to nuget.org - run: dotnet nuget push ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml deleted file mode 100644 index 8461208a2e..0000000000 --- a/.github/workflows/diffcalc.yml +++ /dev/null @@ -1,196 +0,0 @@ -# ## Description -# -# Uses [diffcalc-sheet-generator](https://github.com/smoogipoo/diffcalc-sheet-generator) to run two builds of osu and generate an SR/PP/Score comparison spreadsheet. -# -# ## Requirements -# -# Self-hosted runner with installed: -# - `docker >= 20.10.16` -# - `docker-compose >= 2.5.1` -# - `lbzip2` -# - `jq` -# -# ## Usage -# -# The workflow can be run in two ways: -# 1. Via workflow dispatch. -# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`. -# For pull requests, the workflow will assume the pull request as the target to compare against (i.e. the `OSU_B` variable). -# Any lines in the comment of the form `KEY=VALUE` are treated as variables for the generator. -# -# ## Google Service Account -# -# Spreadsheets are uploaded to a Google Service Account, and exposed with read-only permissions to the wider audience. -# -# 1. Create a project at https://console.cloud.google.com -# 2. Enable the `Google Sheets` and `Google Drive` APIs. -# 3. Create a Service Account -# 4. Generate a key in the JSON format. -# 5. Encode the key as base64 and store as an **actions secret** with name **`DIFFCALC_GOOGLE_CREDENTIALS`** -# -# ## Environment variables -# -# The default environment may be configured via **actions variables**. -# -# Refer to [the sample environment](https://github.com/smoogipoo/diffcalc-sheet-generator/blob/master/.env.sample), and prefix each variable with `DIFFCALC_` (e.g. `DIFFCALC_THREADS`, `DIFFCALC_INNODB_BUFFER_SIZE`, etc...). - -name: Run difficulty calculation comparison - -run-name: "${{ github.event_name == 'workflow_dispatch' && format('Manual run: {0}', inputs.osu-b) || 'Automatic comment trigger' }}" - -on: - issue_comment: - types: [ created ] - workflow_dispatch: - inputs: - osu-b: - description: "The target build of ppy/osu" - type: string - required: true - ruleset: - description: "The ruleset to process" - type: choice - required: true - options: - - osu - - taiko - - catch - - mania - converts: - description: "Include converted beatmaps" - type: boolean - required: false - default: true - ranked-only: - description: "Only ranked beatmaps" - type: boolean - required: false - default: true - generators: - description: "Comma-separated list of generators (available: [sr, pp, score])" - type: string - required: false - default: 'pp,sr' - osu-a: - description: "The source build of ppy/osu" - type: string - required: false - default: 'latest' - difficulty-calculator-a: - description: "The source build of ppy/osu-difficulty-calculator" - type: string - required: false - default: 'latest' - difficulty-calculator-b: - description: "The target build of ppy/osu-difficulty-calculator" - type: string - required: false - default: 'latest' - score-processor-a: - description: "The source build of ppy/osu-queue-score-statistics" - type: string - required: false - default: 'latest' - score-processor-b: - description: "The target build of ppy/osu-queue-score-statistics" - type: string - required: false - default: 'latest' - -permissions: - pull-requests: write - -env: - EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} - -defaults: - run: - shell: bash -euo pipefail {0} - -jobs: - check-permissions: - name: Check permissions - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }} - steps: - - name: Check permissions - run: | - ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders) - for i in "${ALLOWED_USERS[@]}"; do - if [[ "${{ github.actor }}" == "$i" ]]; then - exit 0 - fi - done - exit 1 - - run-diffcalc: - name: Run spreadsheet generator - needs: check-permissions - uses: ./.github/workflows/_diffcalc_processor.yml - with: - # Can't reference env... Why GitHub, WHY? - id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} - head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }} - pr-url: ${{ github.event.issue.pull_request.html_url || '' }} - pr-text: ${{ github.event.comment.body || '' }} - dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }} - secrets: - DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }} - - create-comment: - name: Create PR comment - needs: check-permissions - runs-on: ubuntu-latest - if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} - steps: - - name: Create comment - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 - with: - comment_tag: ${{ env.EXECUTION_ID }} - message: | - Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - *This comment will update on completion* - - output-cli: - name: Info - needs: run-diffcalc - runs-on: ubuntu-latest - steps: - - name: Output info - run: | - echo "Target: ${{ needs.run-diffcalc.outputs.target }}" - echo "Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}" - - update-comment: - name: Update PR comment - needs: [ create-comment, run-diffcalc ] - runs-on: ubuntu-latest - if: ${{ always() && needs.create-comment.result == 'success' }} - steps: - - name: Update comment on success - if: ${{ needs.run-diffcalc.result == 'success' }} - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 - with: - comment_tag: ${{ env.EXECUTION_ID }} - mode: recreate - message: | - Target: ${{ needs.run-diffcalc.outputs.target }} - Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }} - - - name: Update comment on failure - if: ${{ needs.run-diffcalc.result == 'failure' }} - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 - with: - comment_tag: ${{ env.EXECUTION_ID }} - mode: recreate - message: | - Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - - name: Update comment on cancellation - if: ${{ needs.run-diffcalc.result == 'cancelled' }} - uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 - with: - comment_tag: ${{ env.EXECUTION_ID }} - mode: delete - message: '.' # Appears to be required by this action for non-error status code. diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml new file mode 100644 index 0000000000..9feb9846cf --- /dev/null +++ b/.github/workflows/qodana_code_quality.yml @@ -0,0 +1,28 @@ +name: Qodana +on: + workflow_dispatch: + pull_request: + push: + branches: # Specify your branches here + - locmain # The 'locmain' branch + - 'releases/*' # The release branches + +jobs: + qodana: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + checks: write + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit + fetch-depth: 0 # a full history is required for pull request analysis + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2025.2 + with: + pr-mode: false + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN_1172857956 }} + QODANA_ENDPOINT: 'https://qodana.cloud' diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml deleted file mode 100644 index 14f0208fc8..0000000000 --- a/.github/workflows/report-nunit.yml +++ /dev/null @@ -1,45 +0,0 @@ -# This is a workaround to allow PRs to report their coverage. This will run inside the base repository. -# See: -# * https://github.com/dorny/test-reporter#recommended-setup-for-public-repositories -# * https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token -name: Annotate CI run with test results -on: - workflow_run: - workflows: [ "Continuous Integration" ] - types: - - completed - -permissions: - contents: read - actions: read - checks: write - -jobs: - annotate: - name: Annotate CI run with test results - runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - repository: ${{ github.event.workflow_run.repository.full_name }} - ref: ${{ github.event.workflow_run.head_sha }} - - - name: Download results - uses: actions/download-artifact@v4 - with: - pattern: osu-test-results-* - merge-multiple: true - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Annotate CI run with test results - uses: dorny/test-reporter@v1.8.0 - with: - name: Results - path: "*.trx" - reporter: dotnet-trx - list-suites: 'failed' - list-tests: 'failed' diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml deleted file mode 100644 index be104d0fd3..0000000000 --- a/.github/workflows/sentry-release.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Add Release to Sentry - -on: - push: - tags: - - '*' - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - sentry_release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Create Sentry release - uses: getsentry/action-release@v1 - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ppy - SENTRY_PROJECT: osu - SENTRY_URL: https://sentry.ppy.sh/ - with: - environment: production - version: osu@${{ github.ref_name }} diff --git a/.github/workflows/update-web-mod-definitions.yml b/.github/workflows/update-web-mod-definitions.yml deleted file mode 100644 index b19f03ad7d..0000000000 --- a/.github/workflows/update-web-mod-definitions.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Update osu-web mod definitions -on: - push: - tags: - - '*' - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - update-mod-definitions: - name: Update osu-web mod definitions - runs-on: ubuntu-latest - steps: - - name: Install .NET 8.0.x - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "8.0.x" - - - name: Checkout ppy/osu - uses: actions/checkout@v4 - with: - path: osu - - - name: Checkout ppy/osu-tools - uses: actions/checkout@v4 - with: - repository: ppy/osu-tools - path: osu-tools - - - name: Checkout ppy/osu-web - uses: actions/checkout@v4 - with: - repository: ppy/osu-web - path: osu-web - - - name: Setup local game checkout for tools - run: ./UseLocalOsu.sh - working-directory: ./osu-tools - - - name: Regenerate mod definitions - run: dotnet run --project PerformanceCalculator -- mods > ../osu-web/database/mods.json - working-directory: ./osu-tools - - - name: Create pull request with changes - uses: peter-evans/create-pull-request@v6 - with: - title: Update mod definitions - body: "This PR has been auto-generated to update the mod definitions to match ppy/osu@${{ github.ref_name }}." - branch: update-mod-definitions - commit-message: Update mod definitions - path: osu-web - token: ${{ secrets.OSU_WEB_PULL_REQUEST_PAT }} diff --git a/.gitignore b/.gitignore index 1fec94d82b..983916800b 100644 --- a/.gitignore +++ b/.gitignore @@ -344,3 +344,8 @@ FodyWeavers.xsd .idea/.idea.osu.Desktop/.idea/misc.xml .idea/.idea.osu.Android/.idea/deploymentTargetDropDown.xml + +# MacOS literally shits anywhere lmao +*.DS_Store + +.github/ \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/vcs.xml b/.idea/.idea.osu.Desktop/.idea/vcs.xml index 3de04b744c..a3eeefe61a 100644 --- a/.idea/.idea.osu.Desktop/.idea/vcs.xml +++ b/.idea/.idea.osu.Desktop/.idea/vcs.xml @@ -12,5 +12,7 @@ + + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 7c5225cff7..60e31fc869 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/osu!.dll" + "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/Ez2osu!.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Debug)", @@ -19,7 +19,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Desktop/bin/Release/net8.0/osu!.dll" + "${workspaceRoot}/osu.Desktop/bin/Release/net8.0/Ez2osu!.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Release)", @@ -55,7 +55,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/osu!.dll", + "${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/Ez2osu!.dll", "--tournament" ], "cwd": "${workspaceRoot}", diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 347e0f558a..e954e74761 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,90 +1,57 @@ # Contributing Guidelines -Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. +感谢你对本项目的关注!再开始之前,请先阅读以下指南及代码的行为守则。在收到补丁后,我会默认你已经阅读了以下内容,并接受我会提出的审阅意见。 -## Table of contents +## 第三方发行版说明 -1. [Reporting bugs](#reporting-bugs) -2. [Providing general feedback](#providing-general-feedback) -3. [Issue or discussion?](#issue-or-discussion) -4. [Submitting pull requests](#submitting-pull-requests) -5. [Resources](#resources) +当前仓库基于由 [ppy](https://github.com/ppy) 的 [osu!](https://github.com/ppy/osu) 项目修改。 +- 由于本人能力有限,无法保证所有功能在每个版本均能正常使用。(我无法每次都进行完整的功能测试) +- 可能存在与原版不同的行为。 +- 原则上可以与官方osu使用统一的数据库,不干扰配置文件。我使用client_XX.realm文件存储数据,官方使用client.realm(但建议自行备份原文件以防数据不兼容、文件损坏等)。 +- 原则上可以与官方osu使用同一账号,release版可以登录并连接官方服务器,可以正常下图、下载排行榜、保存到处成绩等; +- 绝对不支持向官方服务器做出任何上传行为(上传谱面、成绩等)和多人游戏。 +- 可以使用第三方服务器(如有),我只能做有限支持,不保证每个版本的可用性。 +- 大部分精力会放在mania模式的功能性开发上,接受其他模式功能性补丁。 +- 我非常乐意接受任何反馈和建议,通过任何渠道(GitHub issues、邮件、QQ等),但不保证每个建议都能被采纳。 -## Reporting bugs +如果你想提交补丁,我希望你能通过某些方式在发布PR前与我联系,这能够节省我大量的时间和精力。 +- 在你提交PR前,请先通过邮件或QQ与我联系,说明你想做的改动。 +- 我需要先确认我本地的代码是否上传到了GitHub。(否则我需要花费大量精力解决冲突问题) -A **bug** is a situation in which there is something clearly *and objectively* wrong with the game. Examples of applicable bug reports are: +## 代码的行为守则 -- The game crashes to desktop when I start a beatmap -- Friends appear twice in the friend listing -- The game slows down a lot when I play this specific map -- A piece of text is overlapping another piece of text on the screen +- 所有新创建的文件必须包含ppy的版权声明。新建文件时,项目应该会自动添加。 +- 遵循现有的代码风格和惯例,在提交前经过测试。 +- 新建文件尽可能放在独立文件夹中,在我提供的唯一一级分类文件夹下(如osu.Game.Rulesets.Mania.LAsEzMania),创建带有byID字样的二级文件夹 +- 尽量不要出现硬编码字符串,尽量不要使用反射,除非别无选择。 +- 除using外,尽量不要到处拉屎(比如一个地方改一行,然后到处修改)。 +- 尽量不要增加新的第三方依赖,除非功能很棒且没有更好的替代品。 +- 本地化支持:统一使用EzLocalizationManager及相关继承类进行本地化字符串管理。 -To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following: +# English Version -- Before opening the issue, please search for any similar existing issues using the text search bar and the issue labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been released). -- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to include logs and screenshots as much as possible. The instructions on how to find the log files are included in the issue template. -- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide follow-up info if we request it. +## Third-Party Distribution Instructions -If we cannot reproduce the issue, it is deemed low priority, or it is deemed to be specific to your setup in some way, the issue may be downgraded to a discussion. This will be done by a maintainer for you. +The current repository is based on [ppy](https://github.com/ppy)'s [osu!](https://github.com/ppy/osu) Project modifications. +- Due to my limited ability, I cannot guarantee that all functions will work properly in each version. (I can't do a full functional test every time) +- There may be different behavior from the original. +- In principle, it is possible to use a unified database with the official OSU without interfering with the configuration file. I use client_XX.realm file to store data, and the official uses client.realm (but it is recommended to back up the original file yourself in case of data incompatibility, file corruption, etc.). +- In principle, you can use the same account as the official OSU, the release version can log in and connect to the official server, you can download the picture normally, download the leaderboard, save the results everywhere, etc. +- Any uploads to official servers (uploading beatmaps, scores, etc.) and multiplayer games are absolutely not supported. +- Third-party servers (if any) are available, I can only do limited support and do not guarantee the availability of each version. +- Most of the effort will be focused on the functional development of the mania mode, accepting functional patches for other modes. +- I am more than happy to accept any feedback and suggestions through any channel (GitHub issues, email, QQ, etc.), but there is no guarantee that every suggestion will be adopted. -## Providing general feedback +If you want to submit a patch, I would like you to contact me before publishing a PR in some way, which will save me a lot of time and effort. +- Before you submit a PR, please contact me via email or QQ with the changes you want to make. +- I need to check if my local code is uploaded to GitHub. (Otherwise I would have to spend a lot of energy resolving conflict issues) -If you wish to: +## Code of Conduct for Code -- provide *subjective* feedback on the game (about how the UI looks, about how the default skin works, about game mechanics, about how the PP and scoring systems work, etc.), -- suggest a new feature to be added to the game, -- report a non-specific problem with the game that you think may be connected to your hardware or operating system specifically, - -then it is generally best to start with a **discussion** first. Discussions are a good avenue to group subjective feedback on a single topic, or gauge interest in a particular feature request. - -When opening a discussion, please keep in mind the following: - -- Use the search function to see if your idea has been proposed before, or if there is already a thread about a particular issue you wish to raise. -- If proposing a feature, please try to explain the feature in as much detail as possible. -- If you're reporting a non-specific problem, please provide applicable logs, screenshots, or video that illustrate the issue. - -If a discussion gathers enough traction, then it may be converted into an issue. This will be done by a maintainer for you. - -## Issue or discussion? - -We realise that the line between an issue and a discussion may be fuzzy, so while we ask you to use your best judgement based on the description above, please don't think about it too hard either. Feedback in a slightly wrong place is better than no feedback at all. - -When in doubt, it's probably best to start with a discussion first. We will escalate to issues as needed. - -## Submitting pull requests - -While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change. - -The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive. - -If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library). - -Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes: - -- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your development environment. -- Make sure you are familiar with [git](https://git-scm.com/) and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests). -- Please do not make code changes via the GitHub web interface. -- Please add tests for your changes. We expect most new features and bugfixes to have test coverage, unless the effort of adding them is prohibitive. The visual testing methodology we use is described in more detail [here](https://github.com/ppy/osu-framework/wiki/Development-and-Testing). -- Please run tests and code style analysis (via `InspectCode.{ps1,sh}` scripts in the root of this repository) before opening the PR. This is particularly important if you're a first-time contributor, as CI will not run for your PR until we allow it to do so. -- **Do not run the game in release configuration at any point during your testing** (the sole exception to this being benchmarks). Using release is an unnecessary and harmful practice, and can even lead to you losing your local realm database if you start making changes to the schema. The debug configuration has a completely separated full-stack environment, including a development website instance at https://dev.ppy.sh/. It is permitted to register an account on that development instance for testing purposes and not worry about multi-accounting infractions. - -After you're done with your changes and you wish to open the PR, please observe the following recommendations: - -- Please submit the pull request from a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary. -- Please pick the following target branch for your pull request: - - `pp-dev`, if the change impacts star rating or performance points calculations for any of the rulesets, - - `master`, otherwise. -- Please avoid pushing untested or incomplete code. -- Please do not force-push or rebase unless we ask you to. -- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change is ready for merge. - -We are highly committed to quality when it comes to the osu! project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience. - -If you're uncertain about some part of the codebase or some inner workings of the game and framework, please reach out either by leaving a comment in the relevant issue, discussion, or PR thread, or by posting a message in the [development Discord server](https://discord.gg/ppy). We will try to help you as much as we can. - -## Resources - -- [Development roadmap](https://github.com/orgs/ppy/projects/7/views/6): What the core team is currently working on -- [`ppy/osu-framework` wiki](https://github.com/ppy/osu-framework/wiki): Contains introductory information about osu!framework, the bespoke 2D game framework we use for the game -- [`ppy/osu` wiki](https://github.com/ppy/osu/wiki): Contains articles about various technical aspects of the game -- [Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library): Contains finished and draft designs for osu! +- All newly created files must include a copyright notice from PPY. When you create a new file, the project should be added automatically. +- Follow existing code styles and conventions, tested before committing. +- Place new files in a separate folder as much as possible, under the only first-level classification folder I provide (e.g. osu. Game.Rulesets.Mania.LAsEzMania), creating a secondary folder with the word byID +- Try not to appear hardcoded strings and try not to use reflections unless there is no other choice. +- Try not to shit everywhere except using (like changing a line in one place and then changing it everywhere). +- Try not to add new third-party dependencies unless the functionality is great and there are no better alternatives. +- Localization support: EzLocalizationManager and related inheritance classes are used for localized string management. \ No newline at end of file diff --git a/README.md b/README.md index d87ca31f72..7315f9a40b 100644 --- a/README.md +++ b/README.md @@ -2,141 +2,144 @@ osu! logo

-# osu! +# Ez2Lazer! -[![Build status](https://github.com/ppy/osu/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/ppy/osu/actions/workflows/ci.yml) -[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest) -[![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) -[![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) -[![Crowdin](https://d322cqt584bo4o.cloudfront.net/osu-web/localized.svg)](https://crowdin.com/project/osu-web) +This is always a pre-release version, maintained by me personally -A free-to-win rhythm game. Rhythm is just a *click* away! +## Latest release: [Windows 10+ (x64)](https://github.com/SK-la/Ez2Lazer/releases) +- **Setup [EzResources](https://la1225-my.sharepoint.com/:f:/g/personal/la_la1225_onmicrosoft_com/EiosAbw_1C9ErYCNRD1PQvkBaYvhflOkt8G9ZKHNYuppLg?e=DWY1kn) Pack to osu datebase path.** -This is the future – and final – iteration of the [osu!](https://osu.ppy.sh) game client which marks the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge. +- A desktop platform with the [.NET 8.0 RunTime](https://dotnet.microsoft.com/download) installed. -## Status - -This project is under constant development, but we do our best to keep things in a stable state. Players are encouraged to install from a release alongside their stable *osu!* client. This project will continue to evolve until we eventually reach the point where most users prefer it over the previous "osu!stable" release. - -A few resources are available as starting points to getting involved and understanding the project: - -- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). -- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). -- Track our current efforts [towards improving the game](https://github.com/orgs/ppy/projects/7/views/6). - -## Running osu! - -If you are just looking to give the game a whirl, you can grab the latest release for your platform: - -### Latest release: - -| [Windows 10+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 12+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 13.4+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | -|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------- | ------------- | ------------- | - -You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download). - -If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below. - -**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation. - -## Developing a custom ruleset - -osu! is designed to allow user-created gameplay variations, called "rulesets". Building one of these allows a developer to harness the power of the osu! beatmap library, game engine, and general UX for a new style of gameplay. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates). - -You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096). - -## Developing osu! - -### Prerequisites - -Please make sure you have the following prerequisites: - -- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed. +- Develop modifications using Rider + VS Code When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed. -### Downloading the source code +## Build Instructions +- Clone the repository +```bash -Clone the repository: +git clone SK-la/Ez2Lazer +git clone SK-la/osu-framework +git clone SK-la/osu-resources +// There is a lack of special texture resources in Resource, so it is recommended that you use the DLL in the release package to replace it after building -```shell -git clone https://github.com/ppy/osu -cd osu +build Ez2Lazer ``` -To update the source code to the latest commit, run the following command inside the `osu` directory: +## Feature support +(It's not always updated here) -```shell -git pull -``` +### Vedio Main Background +- Support vedio as main background (.webm) +img_10 +img_13 -### Building -#### From an IDE +### SongSelect Ez to Filter +- Keys Filter (One\Multi) +- Notes by column +- Avg\Max KPS +img_12 -You should load the solution via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will reduce dependencies and hide platforms that you don't care about. Valid `.slnf` files are: -- `osu.Desktop.slnf` (most common) -- `osu.Android.slnf` -- `osu.iOS.slnf` +### Freedom Speed Adjust System -Run configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `osu! (Tests)` project/configuration. More information on this is provided [below](#contributing). +| - | Speed | + | +|--|-----------|--| +| ← | Base Speed | → | +| 0 | Scroll Speed | 401 | -To build for mobile platforms, you will likely need to run `sudo dotnet workload restore` if you haven't done so previously. This will install Android/iOS tooling required to complete the build. +Base Speed - Setting Speed(0~401) * MPS(Gaming ±Speed) -#### From CLI +img_11 -You can also build and run *osu!* from the command-line with a single command: -```shell -dotnet run --project osu.Desktop -``` -When running locally to do any kind of performance testing, make sure to add `-c Release` to the build command, as the overhead of running with the default `Debug` configuration can be large (especially when testing with local framework modifications as below). +### New Skin System +- Ez Pro SKin System + - New Ez Style SKin Sprites - 全新Ez风格皮肤素材 + - New Dynamic real-time preview SKin Options - 全新动态实时预览皮肤选项 + - Built-in skin.ini settings - 内置skin.ini设置 + - New color settings, column type setting system - 全新颜色设置、列类型设置系统 -If the build fails, try to restore NuGet packages with `dotnet restore`. + img_5 + img_4 -### Testing with resource/framework modifications +- Preload skin resources when entering the game interface to reduce lag in the early stages of the game +- Change to the Smart Subfolder drop-down list + Snipaste_2025-12-07_21-37-22 -Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands: +- Mania Playfield Support Blur and Dim Effect -Windows: + Snipaste_2025-12-07_21-29-40 -```ps -UseLocalFramework.ps1 -UseLocalResources.ps1 -``` +- HUD Components +- img_16 -macOS / Linux: -```ps -UseLocalFramework.sh -UseLocalResources.sh -``` +### Pool Judgment (Empty Judgment) -Note that these commands assume you have the relevant project(s) checked out in adjacent directories: +- Pool判定不影响ACC、Combo,仅严格扣血,连续的Pool判将累加扣血幅度. +- The pool hit result does not affect ACC and Combo, only strict blood deduction, and continuous pools will accumulate the blood deduction amplitude. +> -500 < -Pool < miss < +Pool < +150 +> +> img_9 -``` -|- osu // this repository -|- osu-framework -|- osu-resources -``` -### Code analysis +### New Judgment Mode -Before committing your code, please run a code formatter. This can be achieved by running `dotnet format` in the command line, or using the `Format code` command in your IDE. +> For the time being, only the settings are implemented, and the actual parameters will be matched in the future +> +> 暂时仅实现设置,未来匹配实际参数 -We have adopted some cross-platform, compiler integrated analyzers. They can provide warnings when you are editing, building inside IDE or from command line, as-if they are provided by the compiler itself. +img_14 -JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it from PowerShell with `.\InspectCode.ps1`. Alternatively, you can install ReSharper or use Rider to get inline support in your IDE of choice. +- Ez2AC: LN-NoRelease (Press and hold LN-tail to perfect) +> { 18.0, 32.0, 64.0, 80.0, 100.0, 120.0 } -## Contributing +- O2Jam: None-Press is miss +> coolRange = 7500.0 / bpm; +> goodRange = 22500.0 / bpm; +> badRange = 31250.0 / bpm; -When it comes to contributing to the project, the two main things you can do to help out are reporting issues and submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in the most effective way possible. +- IIDX (instant): LN-NoRelease +> { 20.0, 40.0, 60.0, 80.0, 100.0, 120.0 } + +- Malody (instant): LN-NoRelease +> { 20.0, 40.0, 60.0, 80.0, 100.0, 120.0 } + +Audio System + +- 增加采样打断重放(防止全key音谱多音轨重叠变成噪音) +- Added sampling interruption playback (to prevent overlapping multiple tracks of the full key note spectrum from becoming noise) +- 选歌界面增加预览keysound和故事板背景音乐 +- Added preview keysound and storyboard background music to the song selection interface + +### Static Score +- Space Graph + img_7 + + +- Column One by One + img_8 + + +### Other +- Scale Only Mode + img_15 + + +## Mod + +img_1 + + +## Special Thanks +- [osu!](https://github.com/ppy/osu): The original game and framework. The code is very strong and elegant. +- [YuLiangSSS](https://osu.ppy.sh/users/15889644): Many fun mods contributed. -If you wish to help with localisation efforts, head over to [crowdin](https://crowdin.com/project/osu-web). -We love to reward quality contributions. If you have made a large contribution, or are a regular contributor, you are welcome to [submit an expense via opencollective](https://opencollective.com/ppy/expenses/new). If you have any questions, feel free to [reach out to peppy](mailto:pe@ppy.sh) before doing so. ## Licence diff --git a/SkinScriptingImplementation/CHANGES.md b/SkinScriptingImplementation/CHANGES.md new file mode 100644 index 0000000000..d9769142f0 --- /dev/null +++ b/SkinScriptingImplementation/CHANGES.md @@ -0,0 +1,96 @@ +# 需要修改的文件列表 + +以下是实现皮肤脚本系统所需修改的所有文件列表,包括修改内容的简要说明。 + +## 修改的现有文件 + +### 1. `osu.Game/Skinning/Skin.cs` + +**主要修改:** +- 添加了 `Scripts` 属性,用于存储加载的脚本 +- 添加了 `LoadComplete()` 和 `LoadScripts()` 方法,用于加载皮肤脚本 +- 添加了 `GetScriptFiles()` 和 `GetStream()` 虚拟方法,供子类实现 +- 修改了 `Dispose()` 方法,确保脚本资源被正确释放 + +### 2. `osu.Game/Skinning/LegacySkin.cs` + +**主要修改:** +- 重写了 `GetScriptFiles()` 方法,实现从皮肤文件中查找 .lua 脚本文件 + +### 3. `osu.Game/Skinning/SkinManager.cs` + +**主要修改:** +- 添加了 `scriptManager` 字段,用于存储脚本管理器实例 +- 在构造函数中初始化脚本管理器并注册到 RealmAccess +- 确保皮肤切换时脚本也得到更新 + +### 4. `osu.Game/Skinning/SkinnableDrawable.cs` + +**主要修改:** +- 添加了 `[Resolved]` 依赖注入 `SkinScriptManager` +- 添加了 `LoadComplete()` 方法,在组件加载完成后通知脚本 +- 添加了 `NotifyScriptsOfComponentLoad()` 方法,用于通知脚本管理器组件已加载 + +## 新增的文件 + +### 1. `osu.Game/Skinning/Scripting/ISkinScriptHost.cs` + +提供脚本与游戏交互的主机接口,定义了脚本可以访问的资源和功能。 + +### 2. `osu.Game/Skinning/Scripting/SkinScript.cs` + +表示单个Lua脚本,包含加载、执行脚本及处理各种事件的逻辑。 + +### 3. `osu.Game/Skinning/Scripting/SkinScriptInterface.cs` + +为Lua脚本提供API,封装对游戏系统的调用,确保安全且受控的访问。 + +### 4. `osu.Game/Skinning/Scripting/SkinScriptManager.cs` + +管理所有活跃的皮肤脚本,协调脚本加载和事件分发。 + +### 5. `osu.Game/Skinning/Scripting/SkinScriptingConfig.cs` + +管理脚本配置设置,包括启用/禁用脚本和权限列表。 + +### 6. `osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs` + +提供脚本设置用户界面,允许用户启用/禁用脚本并导入新脚本。 + +### 7. `osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs` + +在游戏启动时注册脚本设置到设置界面。 + +### 8. `osu.Game/Overlays/Dialog/FileImportFaultDialog.cs` + +用于显示文件导入错误的对话框。 + +### 9. `osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs` + +为Mania模式提供特定的脚本扩展,允许脚本访问和修改Mania特有的元素。 + +## 示例文件 + +### 1. `ExampleSkinScript.lua` + +通用皮肤脚本示例,演示基本功能和API用法。 + +### 2. `ExampleManiaSkinScript.lua` + +Mania模式特定皮肤脚本示例,演示如何使用Mania特有的API。 + +## 需要安装的依赖 + +- MoonSharp.Interpreter (2.0.0) - Lua脚本引擎 + +## 实施步骤 + +1. 安装必要的NuGet包 +2. 添加新文件到项目中 +3. 按照列表修改现有文件 +4. 编译并测试基本功能 +5. 测试示例脚本 + +## 对现有代码的影响 + +修改尽量保持最小化,主要通过添加方法和属性来扩展现有类,而不是修改核心逻辑。所有脚本执行都在try-catch块中,确保脚本错误不会影响游戏稳定性。 diff --git a/SkinScriptingImplementation/Dependencies/nuget-packages.txt b/SkinScriptingImplementation/Dependencies/nuget-packages.txt new file mode 100644 index 0000000000..b962708303 --- /dev/null +++ b/SkinScriptingImplementation/Dependencies/nuget-packages.txt @@ -0,0 +1 @@ +MoonSharp.Interpreter 2.0.0 diff --git a/SkinScriptingImplementation/ExampleManiaSkinScript.lua b/SkinScriptingImplementation/ExampleManiaSkinScript.lua new file mode 100644 index 0000000000..c0adafdd20 --- /dev/null +++ b/SkinScriptingImplementation/ExampleManiaSkinScript.lua @@ -0,0 +1,160 @@ +-- Mania-specific skin script example +-- This script shows how to customize Mania mode skin components + +-- Script description and metadata +SCRIPT_DESCRIPTION = "Mania模式特定的皮肤脚本示例,展示如何自定义下落式键盘模式的外观和行为" +SCRIPT_VERSION = "1.0" +SCRIPT_AUTHOR = "osu!team" + +-- Cache for column information +local columnData = {} + +-- Called when the script is first loaded +function onLoad() + osu.Log("Mania skin script loaded!", "info") + osu.SubscribeToEvent("ManiaColumnHit") + osu.SubscribeToEvent("ManiaHoldActivated") + + -- Initialize column data if we're in mania mode + if osu.GetRulesetName() == "mania" then + local columnCount = mania.GetColumnCount() + osu.Log("Mania mode detected with " .. columnCount .. " columns", "info") + + -- Store information about each column + for i = 0, columnCount - 1 do + columnData[i] = { + binding = mania.GetColumnBinding(i), + width = mania.GetColumnWidth(i), + lastHitTime = 0, + isHolding = false + } + osu.Log("Column " .. i .. " has binding " .. columnData[i].binding, "debug") + } + end +end + +-- Called when a component is loaded +function onComponentLoaded(component) + if component.Type and component.Type.Name == "ManiaStageComponent" then + osu.Log("Mania stage component loaded", "info") + + -- Here you could modify the appearance of the mania stage + -- For example, change colors, sizes, etc. + elseif component.Type and component.Type.Name == "ManiaNote" then + osu.Log("Mania note component loaded", "debug") + + -- You could customize individual notes here + -- For example, change the color based on the column + local note = component + if note.Column ~= nil then + local columnIndex = mania.GetNoteColumn(note) + + -- Example: Apply different styling to different columns + if columnIndex % 2 == 0 then + -- Even columns get one style + note.Colour = {R = 0.9, G = 0.4, B = 0.4, A = 1.0} + else + -- Odd columns get another style + note.Colour = {R = 0.4, G = 0.4, B = 0.9, A = 1.0} + end + end + end +end + +-- Called when a game event occurs +function onGameEvent(eventName, data) + if eventName == "ManiaColumnHit" then + local columnIndex = data.ColumnIndex + + if columnData[columnIndex] then + columnData[columnIndex].lastHitTime = osu.GetCurrentTime() + + -- Example: Create a visual effect when a column is hit + -- This would require a custom component to be defined elsewhere + osu.Log("Hit on column " .. columnIndex, "debug") + end + elseif eventName == "ManiaHoldActivated" then + local columnIndex = data.ColumnIndex + + if columnData[columnIndex] then + columnData[columnIndex].isHolding = true + + -- Example: Apply a continuous effect while holding + osu.Log("Hold started on column " .. columnIndex, "debug") + end + elseif eventName == "ManiaHoldReleased" then + local columnIndex = data.ColumnIndex + + if columnData[columnIndex] then + columnData[columnIndex].isHolding = false + + -- Example: End continuous effects when holding stops + osu.Log("Hold released on column " .. columnIndex, "debug") + end + end +end + +-- Called when a judgement result is received +function onJudgement(result) + if result.HitObject and result.HitObject.Column ~= nil then + local columnIndex = result.HitObject.Column + + -- Example: Play different sounds based on column and hit result + if result.Type == "Perfect" then + osu.Log("Perfect hit on column " .. columnIndex, "info") + + -- Example: Custom sound per column + if columnIndex % 2 == 0 then + osu.PlaySample("normal-hitnormal") + else + osu.PlaySample("normal-hitwhistle") + end + end + end +end + +-- Called when an input event occurs +function onInputEvent(event) + -- Example: Map keyboard events to column effects + if event.Key then + -- Check if the key corresponds to a column binding + for i = 0, #columnData do + if columnData[i] and columnData[i].binding == tostring(event.Key) then + osu.Log("Input detected for column " .. i, "debug") + + -- Here you could create custom input visualizations + -- This is especially useful for key overlay effects + end + end + end +end + +-- Called every frame for continuous effects +function update() + local currentTime = osu.GetCurrentTime() + + -- Example: Create pulsing effects on recently hit columns + for i = 0, #columnData do + if columnData[i] then + local timeSinceHit = currentTime - columnData[i].lastHitTime + + if timeSinceHit < 500 then -- 500ms of effect + -- Calculate a decay effect (1.0 -> 0.0 over 500ms) + local intensity = 1.0 - (timeSinceHit / 500) + + -- Here you would apply the effect to column visualizations + -- Example: column.Glow = intensity + end + + -- Apply continuous effects to held columns + if columnData[i].isHolding then + -- Example: Create pulsing or glowing effects while holding + -- local pulseAmount = math.sin(currentTime / 100) * 0.2 + 0.8 + -- column.HoldEffectIntensity = pulseAmount + end + end + end +end + +-- Return true to indicate the script loaded successfully +return true diff --git a/SkinScriptingImplementation/ExampleSkinScript.lua b/SkinScriptingImplementation/ExampleSkinScript.lua new file mode 100644 index 0000000000..856d7fea46 --- /dev/null +++ b/SkinScriptingImplementation/ExampleSkinScript.lua @@ -0,0 +1,66 @@ +-- Example Skin Script for osu! +-- This script shows how to customize skin components with Lua scripting + +-- Script description and metadata +SCRIPT_DESCRIPTION = "基础皮肤脚本示例,展示如何自定义皮肤组件外观和行为" +SCRIPT_VERSION = "1.0" +SCRIPT_AUTHOR = "osu!team" + +-- Called when the script is first loaded +-- This is where you can set up any initial state or subscribe to events +function onLoad() + osu.Log("Skin script loaded!", "info") + osu.SubscribeToEvent("HitEvent") + osu.SubscribeToEvent("InputEvent") +end + +-- Called when a skinnable component is loaded +-- You can modify components or react to their creation +function onComponentLoaded(component) + osu.Log("Component loaded: " .. tostring(component), "debug") + + -- Example: Make combo counter text larger if it's a DefaultComboCounter + if component.Type and component.Type.Name == "DefaultComboCounter" then + if component.CountDisplay then + component.CountDisplay.Scale = {X = 1.5, Y = 1.5} + osu.Log("Modified combo counter size", "info") + end + end +end + +-- Called when a game event occurs +-- Events include things like hit events, misses, combo breaks, etc. +function onGameEvent(eventName, data) + if eventName == "HitEvent" then + osu.Log("Hit event received!", "debug") + -- You can trigger sound effects or visual effects here + if data.Result and data.Result.Type == "Great" then + osu.PlaySample("applause") + end + end +end + +-- Called when a judgement result is received +-- This includes hit results, misses, etc. +function onJudgement(result) + -- Example: Play a custom sound on perfect hits + if result.Type == "Perfect" then + osu.Log("Perfect hit!", "info") + end +end + +-- Called when an input event occurs +-- This includes key presses, mouse clicks, etc. +function onInputEvent(event) + osu.Log("Input event: " .. tostring(event), "debug") +end + +-- Called every frame +-- Use this for continuous animations or effects +function update() + -- Example: Create pulsing effects or continuous animations + -- Note: Be careful with performance in this function +end + +-- Return true to indicate the script loaded successfully +return true diff --git a/SkinScriptingImplementation/README.md b/SkinScriptingImplementation/README.md new file mode 100644 index 0000000000..e93b554240 --- /dev/null +++ b/SkinScriptingImplementation/README.md @@ -0,0 +1,169 @@ +# 皮肤脚本系统 (Skin Scripting System) + +这个实现添加了对外部Lua脚本的支持,允许皮肤制作者通过脚本定制皮肤的行为和外观。 + +## 实现概述 + +这个系统使用MoonSharp作为Lua脚本引擎,并通过以下关键组件实现: + +1. **脚本接口** - 为皮肤脚本提供与游戏交互的API +2. **脚本管理器** - 负责加载、执行和管理皮肤脚本 +3. **对现有代码的修改** - 在关键点调用脚本回调函数 + +## 安装和使用 + +### 安装 + +1. 安装MoonSharp NuGet包: + ``` + dotnet add package MoonSharp.Interpreter --version 2.0.0 + ``` + +2. 将`SkinScriptingImplementation`文件夹中的所有文件复制到对应的项目文件夹中,保持相同的目录结构。 + +### 创建皮肤脚本 + +1. 创建一个`.lua`扩展名的文件 +2. 将该文件放入你的皮肤文件夹中 +3. 当皮肤加载时,脚本会自动被加载和执行 + +### 管理脚本 + +皮肤脚本系统提供了用户界面来管理皮肤脚本: + +1. 转到`设置 -> 皮肤 -> 皮肤脚本`部分 +2. 使用`启用皮肤脚本`选项来全局启用或禁用脚本功能 +3. 使用`从文件导入脚本`按钮将新脚本添加到当前皮肤 +4. 在`可用脚本`列表中,可以单独启用或禁用每个脚本 + +### 脚本元数据 + +脚本可以包含以下元数据变量: + +```lua +-- 脚本描述信息,将显示在设置中 +SCRIPT_DESCRIPTION = "这个脚本的功能描述" +SCRIPT_VERSION = "1.0" +SCRIPT_AUTHOR = "作者名称" +``` + +## Lua脚本API + +脚本可以实现以下回调函数: + +```lua +-- 脚本加载时调用 +function onLoad() + -- 初始化工作,订阅事件等 +end + +-- 当皮肤组件被加载时调用 +function onComponentLoaded(component) + -- 你可以修改组件或对其创建做出反应 +end + +-- 当游戏事件发生时调用 +function onGameEvent(eventName, data) + -- 处理游戏事件 +end + +-- 当判定结果产生时调用 +function onJudgement(result) + -- 根据判定结果创建效果 +end + +-- 当输入事件发生时调用 +function onInputEvent(event) + -- 对输入事件做出反应 +end + +-- 每帧调用,用于连续动画或效果 +function update() + -- 创建连续动画或效果 +end +``` + +### 全局API + +所有脚本都可以访问通过`osu`对象的以下功能: + +```lua +-- 获取当前谱面标题 +osu.GetBeatmapTitle() + +-- 获取当前谱面艺术家 +osu.GetBeatmapArtist() + +-- 获取当前规则集名称 +osu.GetRulesetName() + +-- 创建新组件 +osu.CreateComponent(componentType) + +-- 获取纹理 +osu.GetTexture(name) + +-- 获取音频样本 +osu.GetSample(name) + +-- 播放音频样本 +osu.PlaySample(name) + +-- 订阅游戏事件 +osu.SubscribeToEvent(eventName) + +-- 记录日志 +osu.Log(message, level) -- level可以是"debug", "info", "warning", "error" +``` + +### Mania模式特定API + +在Mania模式下,脚本还可以通过`mania`对象访问以下功能: + +```lua +-- 获取列数 +mania.GetColumnCount() + +-- 获取音符所在的列 +mania.GetNoteColumn(note) + +-- 获取列绑定 +mania.GetColumnBinding(column) + +-- 获取列宽度 +mania.GetColumnWidth(column) +``` + +## 示例脚本 + +请参考提供的示例脚本: +- [ExampleSkinScript.lua](ExampleSkinScript.lua) - 通用皮肤脚本示例 +- [ExampleManiaSkinScript.lua](ExampleManiaSkinScript.lua) - Mania模式特定皮肤脚本示例 + +## 修改说明 + +以下文件已被修改以支持皮肤脚本系统: + +1. `osu.Game/Skinning/Skin.cs` - 添加了脚本加载和管理功能 +2. `osu.Game/Skinning/LegacySkin.cs` - 实现了脚本文件查找 +3. `osu.Game/Skinning/SkinManager.cs` - 初始化脚本管理器 +4. `osu.Game/Skinning/SkinnableDrawable.cs` - 添加了组件加载通知 + +新增的文件: + +1. `osu.Game/Skinning/Scripting/*.cs` - 脚本系统核心类 +2. `osu.Game/Rulesets/Mania/Skinning/Scripting/*.cs` - Mania模式特定脚本扩展 + +## 限制和注意事项 + +1. 脚本在沙箱环境中运行,访问权限有限 +2. 过于复杂的脚本可能会影响性能 +3. 脚本API可能会随着游戏更新而变化 + +## 故障排除 + +如果脚本无法正常工作: + +1. 检查游戏日志中的脚本错误信息 +2. 确保脚本文件格式正确(UTF-8编码,无BOM) +3. 确保脚本没有语法错误 diff --git a/SkinScriptingImplementation/osu.Game/OsuGame_SkinScripting.cs b/SkinScriptingImplementation/osu.Game/OsuGame_SkinScripting.cs new file mode 100644 index 0000000000..dd4e91753a --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/OsuGame_SkinScripting.cs @@ -0,0 +1,17 @@ +using osu.Framework.Allocation; +using osu.Game.Skinning.Scripting; + +namespace osu.Game +{ + public partial class OsuGame + { + private SkinScriptingOverlayRegistration scriptingRegistration; + + [BackgroundDependencyLoader] + private void loadSkinScripting() + { + // 添加皮肤脚本设置注册组件 + Add(scriptingRegistration = new SkinScriptingOverlayRegistration()); + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Overlays/Dialog/FileImportFaultDialog.cs b/SkinScriptingImplementation/osu.Game/Overlays/Dialog/FileImportFaultDialog.cs new file mode 100644 index 0000000000..58cb5c61b5 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Overlays/Dialog/FileImportFaultDialog.cs @@ -0,0 +1,31 @@ +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Overlays.Dialog +{ + /// + /// 文件导入失败时显示的对话框。 + /// + public partial class FileImportFaultDialog : PopupDialog + { + /// + /// 初始化 类的新实例。 + /// + /// 错误信息。 + public FileImportFaultDialog(string errorMessage) + { + Icon = FontAwesome.Regular.TimesCircle; + HeaderText = "导入失败"; + BodyText = errorMessage; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = "确定", + } + }; + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/SkinScriptingImplementation/osu.Game/Overlays/Settings/Sections/SkinSection.cs new file mode 100644 index 0000000000..775cce0b38 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -0,0 +1,316 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays.SkinEditor; +using osu.Game.Screens.Select; +using osu.Game.Skinning; +using osuTK; +using Realms; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; +using osu.Game.Skinning.Scripting; +using osu.Game.Skinning.Scripting.Overlays; + +namespace osu.Game.Overlays.Settings.Sections +{ + public partial class SkinSection : SettingsSection + { + private SkinSettingsDropdown skinDropdown; + private SkinScriptingSettingsSection scriptingSection; + + public override LocalisableString Header => SkinSettingsStrings.SkinSectionHeader; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = OsuIcon.SkinB + }; + + private static readonly Live random_skin_info = new SkinInfo + { + ID = SkinInfo.RANDOM_SKIN, + Name = "", + }.ToLiveUnmanaged(); + + private readonly List> dropdownItems = new List>(); + + [Resolved] + private SkinManager skins { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } + + private IDisposable realmSubscription; [BackgroundDependencyLoader(permitNulls: true)] + private void load([CanBeNull] SkinEditorOverlay skinEditor) + { + Children = new Drawable[] + { + skinDropdown = new SkinSettingsDropdown + { + AlwaysShowSearchBar = true, + AllowNonContiguousMatching = true, + LabelText = SkinSettingsStrings.CurrentSkin, + Current = skins.CurrentSkinInfo, + Keywords = new[] { @"skins" }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, + Children = new Drawable[] + { + // This is all super-temporary until we move skin settings to their own panel / overlay. + new RenameSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, + new ExportSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 }, + new DeleteSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 110 }, + } + }, + new SettingsButton + { + Text = SkinSettingsStrings.SkinLayoutEditor, + Action = () => skinEditor?.ToggleVisibility(), + }, + scriptingSection = new SkinScriptingSettingsSection(), + }; + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + realmSubscription = realm.RegisterForNotifications(_ => realm.Realm.All() + .Where(s => !s.DeletePending) + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase), skinsChanged); + + skinDropdown.Current.BindValueChanged(skin => + { + if (skin.NewValue == random_skin_info) + { + // before selecting random, set the skin back to the previous selection. + // this is done because at this point it will be random_skin_info, and would + // cause SelectRandomSkin to be unable to skip the previous selection. + skins.CurrentSkinInfo.Value = skin.OldValue; + skins.SelectRandomSkin(); + } + }); + } + + private void skinsChanged(IRealmCollection sender, ChangeSet changes) + { + // This can only mean that realm is recycling, else we would see the protected skins. + // Because we are using `Live<>` in this class, we don't need to worry about this scenario too much. + if (!sender.Any()) + return; + + // For simplicity repopulate the full list. + // In the future we should change this to properly handle ChangeSet events. + dropdownItems.Clear(); + + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.EZ2_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.SBI_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_PRO_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.CLASSIC_SKIN).ToLive(realm)); + + dropdownItems.Add(random_skin_info); + + foreach (var skin in sender.Where(s => !s.Protected)) + dropdownItems.Add(skin.ToLive(realm)); + + Schedule(() => skinDropdown.Items = dropdownItems); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + realmSubscription?.Dispose(); + } + + private partial class SkinSettingsDropdown : SettingsDropdown> + { + protected override OsuDropdown> CreateDropdown() => new SkinDropdownControl(); + + private partial class SkinDropdownControl : DropdownControl + { + protected override LocalisableString GenerateItemText(Live item) => item.ToString(); + } + } + + public partial class RenameSkinButton : SettingsButton, IHasPopover + { + [Resolved] + private SkinManager skins { get; set; } + + private Bindable currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = CommonStrings.Rename; + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + } + + public Popover GetPopover() + { + return new RenameSkinPopover(); + } + } + + public partial class ExportSkinButton : SettingsButton + { + [Resolved] + private SkinManager skins { get; set; } + + private Bindable currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = CommonStrings.Export; + Action = export; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + } + + private void export() + { + try + { + skins.ExportCurrentSkin(); + } + catch (Exception e) + { + Logger.Log($"Could not export current skin: {e.Message}", level: LogLevel.Error); + } + } + } + + public partial class DeleteSkinButton : DangerousSettingsButton + { + [Resolved] + private SkinManager skins { get; set; } + + [Resolved(CanBeNull = true)] + private IDialogOverlay dialogOverlay { get; set; } + + private Bindable currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = WebCommonStrings.ButtonsDelete; + Action = delete; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); + } + + private void delete() + { + dialogOverlay?.Push(new SkinDeleteDialog(currentSkin.Value)); + } + } + + public partial class RenameSkinPopover : OsuPopover + { + [Resolved] + private SkinManager skins { get; set; } + + private readonly FocusedTextBox textBox; + + public RenameSkinPopover() + { + AutoSizeAxes = Axes.Both; + Origin = Anchor.TopCentre; + + RoundedButton renameButton; + + Child = new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + Width = 250, + Spacing = new Vector2(10f), + Children = new Drawable[] + { + textBox = new FocusedTextBox + { + PlaceholderText = @"Skin name", + FontSize = OsuFont.DEFAULT_FONT_SIZE, + RelativeSizeAxes = Axes.X, + SelectAllOnFocus = true, + }, + renameButton = new RoundedButton + { + Height = 40, + RelativeSizeAxes = Axes.X, + MatchingFilter = true, + Text = "Save", + } + } + }; + + renameButton.Action += rename; + textBox.OnCommit += (_, _) => rename(); + } + + protected override void PopIn() + { + textBox.Text = skins.CurrentSkinInfo.Value.Value.Name; + textBox.TakeFocus(); + + base.PopIn(); + } + + private void rename() => skins.CurrentSkinInfo.Value.PerformWrite(skin => + { + skin.Name = textBox.Text; + PopOut(); + }); + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs b/SkinScriptingImplementation/osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs new file mode 100644 index 0000000000..a6a5ac7609 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs @@ -0,0 +1,81 @@ +using MoonSharp.Interpreter; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Skinning.Scripting +{ + /// + /// Provides mania-specific extensions for skin scripts. + /// + [MoonSharpUserData] + public class ManiaSkinScriptExtensions + { + private readonly ManiaAction[] columnBindings; + private readonly StageDefinition stageDefinition; + + /// + /// Initializes a new instance of the class. + /// + /// The stage this extension is for. + public ManiaSkinScriptExtensions(Stage stage) + { + stageDefinition = stage.Definition; + + // Store column bindings + columnBindings = new ManiaAction[stageDefinition.Columns]; + for (int i = 0; i < stageDefinition.Columns; i++) + { + columnBindings[i] = stageDefinition.GetActionForColumn(i); + } + } + + /// + /// Gets the number of columns in the stage. + /// + /// The number of columns. + [MoonSharpVisible(true)] + public int GetColumnCount() + { + return stageDefinition.Columns; + } + + /// + /// Gets the column index for a specific note. + /// + /// The note. + /// The column index. + [MoonSharpVisible(true)] + public int GetNoteColumn(Note note) + { + return note.Column; + } + + /// + /// Gets the binding (action) for a specific column. + /// + /// The column index. + /// The binding action as a string. + [MoonSharpVisible(true)] + public string GetColumnBinding(int column) + { + if (column < 0 || column >= columnBindings.Length) + return "Invalid"; + + return columnBindings[column].ToString(); + } + + /// + /// Gets the width of a specific column. + /// + /// The column index. + /// The column width. + [MoonSharpVisible(true)] + public float GetColumnWidth(int column) + { + if (column < 0 || column >= stageDefinition.Columns) + return 0; + + return stageDefinition.ColumnWidths[column]; + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/LegacySkin.cs b/SkinScriptingImplementation/osu.Game/Skinning/LegacySkin.cs new file mode 100644 index 0000000000..18f1606e82 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/LegacySkin.cs @@ -0,0 +1,369 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class LegacySkin : Skin + { + protected virtual bool AllowManiaConfigLookups => true; + + /// + /// Whether this skin can use samples with a custom bank (custom sample set in stable terminology). + /// Added in order to match sample lookup logic from stable (in stable, only the beatmap skin could use samples with a custom sample bank). + /// + protected virtual bool UseCustomSampleBanks => false; + + private readonly Dictionary maniaConfigurations = new Dictionary(); + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public LegacySkin(SkinInfo skin, IStorageResourceProvider resources) + : this(skin, resources, null) + { + } + + /// + /// Construct a new legacy skin instance. + /// + /// The model for this skin. + /// Access to raw game resources. + /// An optional fallback store which will be used for file lookups that are not serviced by realm user storage. + /// The user-facing filename of the configuration file to be parsed. Can accept an .osu or skin.ini file. + protected LegacySkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? fallbackStore, string configurationFilename = @"skin.ini") + : base(skin, resources, fallbackStore, configurationFilename) + { + } + + protected override IResourceStore CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore storage) + => new LegacyTextureLoaderStore(base.CreateTextureLoaderStore(resources, storage)); + + protected override void ParseConfigurationStream(Stream stream) + { + base.ParseConfigurationStream(stream); + + stream.Seek(0, SeekOrigin.Begin); + + using (LineBufferedReader reader = new LineBufferedReader(stream)) + { + var maniaList = new LegacyManiaSkinDecoder().Decode(reader); + + foreach (var config in maniaList) + maniaConfigurations[config.Keys] = config; + } + } + + /// + /// Gets a list of script files in the skin. + /// + /// A list of script file names. + protected override IEnumerable GetScriptFiles() + { + // Look for .lua script files in the skin + return SkinInfo.Files.Where(f => f.Filename.EndsWith(".lua", StringComparison.OrdinalIgnoreCase)) + .Select(f => f.Filename); + } + + [SuppressMessage("ReSharper", "RedundantAssignment")] // for `wasHit` assignments used in `finally` debug logic + public override IBindable? GetConfig(TLookup lookup) + { + bool wasHit = true; + + try + { + switch (lookup) + { + case GlobalSkinColours colour: + switch (colour) + { + case GlobalSkinColours.ComboColours: + var comboColours = Configuration.ComboColours; + if (comboColours != null) + return SkinUtils.As(new Bindable>(comboColours)); + + break; + + default: + return SkinUtils.As(getCustomColour(Configuration, colour.ToString())); + } + + break; + + case SkinConfiguration.LegacySetting setting: + switch (setting) + { + case SkinConfiguration.LegacySetting.Version: + return SkinUtils.As(new Bindable(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION)); + } + + break; + + // handled by ruleset-specific skin classes. + case LegacyManiaSkinConfigurationLookup maniaLookup: + wasHit = false; + break; + + case SkinCustomColourLookup customColour: + return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString())); + + case LegacySkinHitCircleLookup legacyHitCircleLookup: + switch (legacyHitCircleLookup.Detail) + { + case LegacySkinHitCircleLookup.DetailType.HitCircleNormalPathTint: + return SkinUtils.As(new Bindable(Configuration.HitCircleNormalPathTint ?? Color4.White)); + + case LegacySkinHitCircleLookup.DetailType.HitCircleHoverPathTint: + return SkinUtils.As(new Bindable(Configuration.HitCircleHoverPathTint ?? Color4.White)); + + case LegacySkinHitCircleLookup.DetailType.Count: + wasHit = false; + break; + } + + break; + + case LegacySkinNoteSheetLookup legacyNoteSheetLookup: + return SkinUtils.As(new Bindable(Configuration.NoteBodyWidth ?? 128)); + + case SkinConfigurationLookup skinLookup: + return handleLegacySkinLookup(skinLookup); + } + + wasHit = false; + return null; + } + finally + { + LogLookupDebug(this, lookup, wasHit ? LookupDebugType.Hit : LookupDebugType.Miss); + } + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + if (base.GetDrawableComponent(lookup) is Drawable d) + return d; + + switch (lookup) + { + case SkinnableSprite.SpriteComponentLookup sprite: + return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize); + + case SkinComponentsContainerLookup _: + return null; + + case GameplaySkinComponentLookup resultComponent: + return getResult(resultComponent.Component); + + case GameplaySkinComponentLookup bhe: + if (Configuration.LegacyVersion < 2.2m) + return null; + + break; + + case JudgementLineStyleLookup judgementLine: + return findProvider(nameof(JudgementLineStyleLookup.Type), judgementLine.Type); + + default: + return findProvider(nameof(ISkinComponentLookup.Lookup), lookup.Lookup); + } + + return null; + } + + private Drawable? findProvider(string lookupName, object lookupValue) + { + var providedType = GetType().Assembly.GetTypes() + .Where(t => !t.IsInterface && !t.IsAbstract) + .FirstOrDefault(t => + { + var interfaces = t.GetInterfaces(); + + return interfaces.Any(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ILegacySkinComponentProvider<,>) && + i.GenericTypeArguments[1].GetProperty(lookupName)?.PropertyType == lookupValue.GetType()); + }); + + if (providedType == null) + return null; + + var constructor = providedType.GetConstructor(new[] { typeof(LegacySkinConfiguration), typeof(ISkin) }); + + if (constructor == null) + return null; + + var instance = constructor.Invoke(new object[] { Configuration, this }); + + var interfaceType = instance.GetType().GetInterfaces() + .First(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ILegacySkinComponentProvider<,>) && + i.GenericTypeArguments[1].GetProperty(lookupName)?.PropertyType == lookupValue.GetType()); + + var providerType = interfaceType.GetGenericTypeDefinition().MakeGenericType(interfaceType.GenericTypeArguments[0], lookupValue.GetType()); + + var methodInfo = providerType.GetMethod(nameof(ILegacySkinComponentProvider.GetDrawableComponent), + new[] { lookupValue.GetType() }); + + var component = methodInfo?.Invoke(instance, new[] { lookupValue }) as Drawable; + + return component; + } + + private IBindable? handleLegacySkinLookup(SkinConfigurationLookup lookup) + { + switch (lookup.Lookup) + { + case SkinConfiguration.SliderStyle: + { + var style = Configuration.SliderStyle ?? (Configuration.Version < 2.0m ? SliderStyle.Segmented : SliderStyle.Gradient); + return SkinUtils.As(new Bindable(style)); + } + + case SkinConfiguration.ScoringVisible: + return SkinUtils.As(new Bindable(Configuration.ScoringVisible ?? true)); + + case SkinConfiguration.ComboPerformed: + return SkinUtils.As(new Bindable(Configuration.ComboPerformed ?? true)); + + case SkinConfiguration.ComboTaskbarPopover: + return SkinUtils.As(new Bindable(Configuration.ComboTaskbarPopover ?? true)); + + case SkinConfiguration.HitErrorStyle: + return SkinUtils.As(new Bindable(Configuration.HitErrorStyle ?? HitErrorStyle.Bottom)); + + case SkinConfiguration.MainHUDLayoutMode: + return SkinUtils.As(new Bindable(Configuration.MainHUDLayoutMode ?? HUDLayoutMode.New)); + + case SkinConfiguration.InputOverlayMode: + return SkinUtils.As(new Bindable(Configuration.InputOverlayMode ?? InputOverlayMode.Bottom)); + + case SkinConfiguration.SongMetadataView: + return SkinUtils.As(new Bindable(Configuration.SongMetadataView ?? SongMetadataView.Default)); + } + + return null; + } + + private IBindable? getCustomColour(LegacySkinConfiguration configuration, string lookup) + { + if (configuration.CustomColours != null && + configuration.CustomColours.TryGetValue(lookup, out Color4 col)) + return new Bindable(col); + + return null; + } + + [CanBeNull] + protected virtual Drawable? getResult(HitResult result) + { + return null; + } + + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + float ratio = 2; + var texture = Textures?.Get($"{componentName}@2x", wrapModeS, wrapModeT); + + if (texture == null) + { + ratio = 1; + texture = Textures?.Get(componentName, wrapModeS, wrapModeT); + } + + if (texture == null && !componentName.EndsWith(@"@2x", StringComparison.Ordinal)) + { + componentName = componentName.Replace(@"@2x", string.Empty); + + string twoTimesFilename = $"{Path.ChangeExtension(componentName, null)}@2x{Path.GetExtension(componentName)}"; + + texture = Textures?.Get(twoTimesFilename, wrapModeS, wrapModeT); + + if (texture != null) + ratio = 2; + } + + texture ??= Textures?.Get(componentName, wrapModeS, wrapModeT); + + if (texture != null) + texture.ScaleAdjust = ratio; + + return texture; + } + + public override ISample? GetSample(ISampleInfo sampleInfo) + { + IEnumerable lookupNames; + + if (sampleInfo is HitSampleInfo hitSample) + lookupNames = getLegacyLookupNames(hitSample); + else + { + lookupNames = sampleInfo.LookupNames.SelectMany(getFallbackSampleNames); + } + + foreach (string lookup in lookupNames) + { + var sample = Samples?.Get(lookup); + + if (sample != null) + { + return sample; + } + } + + return null; + } + + private IEnumerable getLegacyLookupNames(HitSampleInfo hitSample) + { + var lookupNames = hitSample.LookupNames.SelectMany(getFallbackSampleNames); + + if (!UseCustomSampleBanks && !string.IsNullOrEmpty(hitSample.Suffix)) + { + // for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin. + // using .EndsWith() is intentional as it ensures parity in all edge cases + // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not). + lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); + } + + foreach (string l in lookupNames) + yield return l; + + // also for compatibility, try falling back to non-bank samples (so-called "universal" samples) as the last resort. + // going forward specifying banks shall always be required, even for elements that wouldn't require it on stable, + // which is why this is done locally here. + yield return hitSample.Name; + } + + private IEnumerable getFallbackSampleNames(string name) + { + // May be something like "Gameplay/normal-hitnormal" from lazer. + yield return name; + + // Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/normal-hitnormal" -> "normal-hitnormal"). + yield return name.Split('/').Last(); + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/ISkinScriptHost.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/ISkinScriptHost.cs new file mode 100644 index 0000000000..3020823bd1 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/ISkinScriptHost.cs @@ -0,0 +1,82 @@ +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Scoring; + +namespace osu.Game.Skinning.Scripting +{ + /// + /// Interface for communication between the game and skin scripts. + /// + public interface ISkinScriptHost + { + /// + /// Gets the current beatmap. + /// + IBeatmap CurrentBeatmap { get; } + + /// + /// Gets the audio manager for sound playback. + /// + IAudioManager AudioManager { get; } + + /// + /// Gets the current skin. + /// + ISkin CurrentSkin { get; } + + /// + /// Gets the current ruleset info. + /// + IRulesetInfo CurrentRuleset { get; } + + /// + /// Creates a new drawable component of the specified type. + /// + /// The type of component to create. + /// The created component. + Drawable CreateComponent(string componentType); + + /// + /// Gets a texture from the current skin. + /// + /// The name of the texture. + /// The texture, or null if not found. + Texture GetTexture(string name); + + /// + /// Gets a sample from the current skin. + /// + /// The name of the sample. + /// The sample, or null if not found. + ISample GetSample(string name); + + /// + /// Subscribe to a game event. + /// + /// The name of the event to subscribe to. + void SubscribeToEvent(string eventName); + + /// + /// Log a message to the osu! log. + /// + /// The message to log. + /// The log level. + void Log(string message, LogLevel level = LogLevel.Information); + } + + /// + /// Log levels for skin script messages. + /// + public enum LogLevel + { + Debug, + Information, + Warning, + Error + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs new file mode 100644 index 0000000000..69265c2fbc --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK; + +namespace osu.Game.Skinning.Scripting.Overlays +{ + public class SkinScriptingSettingsSection : SettingsSection + { + protected override string Header => "皮肤脚本"; + + [Resolved] + private SkinManager skinManager { get; set; } + + [Resolved] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved] + private GameHost host { get; set; } + + [Resolved] + private Storage storage { get; set; } + + [Resolved(CanBeNull = true)] + private SkinScriptingConfig scriptConfig { get; set; } + + private readonly Bindable scriptingEnabled = new Bindable(true); + private readonly BindableList allowedScripts = new BindableList(); + private readonly BindableList blockedScripts = new BindableList(); + + private FillFlowContainer scriptListFlow; + private OsuButton importButton; + + [BackgroundDependencyLoader] private void load() + { + if (scriptConfig != null) + { + scriptConfig.BindWith(SkinScriptingSettings.ScriptingEnabled, scriptingEnabled); + scriptConfig.BindWith(SkinScriptingSettings.AllowedScripts, allowedScripts); + scriptConfig.BindWith(SkinScriptingSettings.BlockedScripts, blockedScripts); + } + + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "启用皮肤脚本", + TooltipText = "允许皮肤使用Lua脚本来自定义外观和行为", + Current = scriptingEnabled + }, + new SettingsButton + { + Text = "从文件导入脚本", + Action = ImportScriptFromFile + }, + new OsuSpriteText + { + Text = "可用脚本", + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold), + Margin = new MarginPadding { Top = 20, Bottom = 10 } + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.X, + Height = 300, + Child = scriptListFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5) + } + } + }; + + // 监听皮肤变化 + skinManager.CurrentSkin.BindValueChanged(_ => RefreshScriptList(), true); + } + + private void RefreshScriptList() + { + scriptListFlow.Clear(); + + if (skinManager.CurrentSkin.Value is LegacySkin skin) + { + var scripts = skin.Scripts; + if (scripts.Count == 0) + { + scriptListFlow.Add(new OsuSpriteText + { + Text = "当前皮肤没有可用的脚本", + Font = OsuFont.GetFont(size: 16), + Colour = Colours.Gray9 + }); + } + else + { + foreach (var script in scripts) + { + scriptListFlow.Add(new ScriptListItem(script, allowedScripts, blockedScripts)); + } + } + } + else + { + scriptListFlow.Add(new OsuSpriteText + { + Text = "当前皮肤不支持脚本", + Font = OsuFont.GetFont(size: 16), + Colour = Colours.Gray9 + }); + } + } + + private void ImportScriptFromFile() + { + Task.Run(async () => + { + try + { + string[] paths = await host.PickFilesAsync(new FilePickerOptions + { + Title = "选择Lua脚本文件", + FileTypes = new[] { ".lua" } + }).ConfigureAwait(false); + + if (paths == null || paths.Length == 0) + return; + + Schedule(() => + { + foreach (string path in paths) + { + try + { + // 获取目标路径(当前皮肤文件夹) + if (skinManager.CurrentSkin.Value is not LegacySkin skin || skin.SkinInfo.Files == null) + { + dialogOverlay.Push(new FileImportFaultDialog("当前皮肤不支持脚本导入")); + return; + } + + string fileName = Path.GetFileName(path); + string destPath = Path.Combine(storage.GetFullPath($"skins/{skin.SkinInfo.ID}/{fileName}")); + + // 复制文件 + File.Copy(path, destPath, true); + + // 刷新皮肤(重新加载脚本) + skinManager.RefreshCurrentSkin(); + + // 刷新脚本列表 + RefreshScriptList(); + } + catch (Exception ex) + { + Logger.Error(ex, $"导入脚本失败: {ex.Message}"); + dialogOverlay.Push(new FileImportFaultDialog(ex.Message)); + } + } + }); + } + catch (Exception ex) + { + Logger.Error(ex, "选择脚本文件失败"); + Schedule(() => dialogOverlay.Push(new FileImportFaultDialog(ex.Message))); + } + }); + } + + private class ScriptListItem : CompositeDrawable + { + private readonly SkinScript script; + private readonly BindableList allowedScripts; + private readonly BindableList blockedScripts; + + private readonly BindableBool isEnabled = new BindableBool(true); + + public ScriptListItem(SkinScript script, BindableList allowedScripts, BindableList blockedScripts) + { + this.script = script; + this.allowedScripts = allowedScripts; + this.blockedScripts = blockedScripts; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + // 根据配置设置初始状态 + string scriptName = script.ScriptName; + if (blockedScripts.Contains(scriptName)) + isEnabled.Value = false; + else if (allowedScripts.Count > 0 && !allowedScripts.Contains(scriptName)) + isEnabled.Value = false; + else + isEnabled.Value = true; + + isEnabled.ValueChanged += e => + { + if (e.NewValue) + { + // 启用脚本 + blockedScripts.Remove(scriptName); + if (allowedScripts.Count > 0) + allowedScripts.Add(scriptName); + } + else + { + // 禁用脚本 + blockedScripts.Add(scriptName); + allowedScripts.Remove(scriptName); + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Vertical = 5 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new OsuCheckbox + { + Current = isEnabled, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + Width = 0.9f, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = script.ScriptName, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold) + }, + new OsuSpriteText + { + Text = $"脚本描述: {script.Description ?? "无描述"}", + Font = OsuFont.GetFont(size: 14), + Colour = colours.Gray9 + } + } + } + } + } + } + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + this.FadeColour(Colour4.LightGray, 200); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + this.FadeColour(Colour4.White, 200); + base.OnHoverLost(e); + } + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScript.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScript.cs new file mode 100644 index 0000000000..52344090db --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScript.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; +using MoonSharp.Interpreter; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Scoring; + +namespace osu.Game.Skinning.Scripting +{ + /// + /// Represents a Lua script that can customize skin behavior. + /// + public class SkinScript : IDisposable + { + private readonly Script luaScript; + private readonly ISkinScriptHost host; + + /// + /// Gets the name of the script (usually the filename). + /// + public string ScriptName { get; } + + /// + /// Gets the description of the script, if provided. + /// + public string Description { get; private set; } + + /// + /// Gets a value indicating whether the script is enabled. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Initializes a new instance of the class. + /// + /// The Lua script content. + /// The name of the script (usually the filename). + /// The host interface for the script. + public SkinScript(string scriptContent, string scriptName, ISkinScriptHost host) + { + this.ScriptName = scriptName; + this.host = host; + + // Configure MoonSharp for maximum safety + luaScript = new Script(CoreModules.Preset_SoftSandbox); + + // Register our host API with MoonSharp + var scriptInterface = new SkinScriptInterface(host); + UserData.RegisterType(); + luaScript.Globals["osu"] = scriptInterface; try + { + // Execute the script + luaScript.DoString(scriptContent); + + // Extract script description if available + if (luaScript.Globals.Get("SCRIPT_DESCRIPTION").Type != DataType.Nil) + Description = luaScript.Globals.Get("SCRIPT_DESCRIPTION").String; + else + Description = "No description provided"; + + // Call onLoad function if it exists + if (luaScript.Globals.Get("onLoad").Type != DataType.Nil) + { + try + { + luaScript.Call(luaScript.Globals.Get("onLoad")); + } + catch (Exception ex) + { + host.Log($"Error in {ScriptName}.onLoad: {ex.Message}", LogLevel.Error); + } + } + } + catch (Exception ex) + { + host.Log($"Error loading script {ScriptName}: {ex.Message}", LogLevel.Error); + } + } /// + /// Creates a new skin script from a file. + /// + /// The path to the Lua script file. + /// The host interface for the script. + /// A new instance of the class. + public static SkinScript FromFile(string filePath, ISkinScriptHost host) + { + string scriptContent = File.ReadAllText(filePath); + string scriptName = Path.GetFileName(filePath); + return new SkinScript(scriptContent, scriptName, host); + } /// + /// Notifies the script that a component has been loaded. + /// + /// The loaded component. + public void NotifyComponentLoaded(Drawable component) + { + if (!IsEnabled) + return; + + try + { + DynValue result = luaScript.Call(luaScript.Globals.Get("onComponentLoaded"), component); + } + catch (Exception ex) + { + host.Log($"Error in {ScriptName}.onComponentLoaded: {ex.Message}", LogLevel.Error); + } + } + + /// + /// Notifies the script of a game event. + /// + /// The name of the event. + /// The event data. + public void NotifyGameEvent(string eventName, object data) + { + if (!IsEnabled) + return; + + try + { + DynValue result = luaScript.Call(luaScript.Globals.Get("onGameEvent"), eventName, data); + } + catch (Exception ex) + { + host.Log($"Error in {ScriptName}.onGameEvent: {ex.Message}", LogLevel.Error); + } + } /// + /// Notifies the script of a judgement result. + /// + /// The judgement result. + public void NotifyJudgement(JudgementResult result) + { + if (!IsEnabled) + return; + + try + { + DynValue dynResult = luaScript.Call(luaScript.Globals.Get("onJudgement"), result); + } + catch (Exception ex) + { + host.Log($"Error in {ScriptName}.onJudgement: {ex.Message}", LogLevel.Error); + } + } + + /// + /// Notifies the script of an input event. + /// + /// The input event. + public void NotifyInputEvent(InputEvent inputEvent) + { + if (!IsEnabled) + return; + + try + { + DynValue result = luaScript.Call(luaScript.Globals.Get("onInputEvent"), inputEvent); + } + catch (Exception ex) + { + host.Log($"Error in {ScriptName}.onInputEvent: {ex.Message}", LogLevel.Error); + } + } + + /// + /// Updates the script. + /// + public void Update() + { + if (!IsEnabled) + return; + + try + { + DynValue result = luaScript.Call(luaScript.Globals.Get("update")); + } + catch (Exception ex) + { + host.Log($"Error in {ScriptName}.update: {ex.Message}", LogLevel.Error); + } + } + + /// + /// Releases all resources used by the script. + /// + public void Dispose() + { + // Release any resources held by the script + luaScript.Globals.Clear(); + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptInterface.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptInterface.cs new file mode 100644 index 0000000000..44fe173471 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptInterface.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using MoonSharp.Interpreter; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Skinning.Scripting +{ + /// + /// Provides an interface for Lua scripts to interact with the game. + /// + [MoonSharpUserData] + public class SkinScriptInterface + { + private readonly ISkinScriptHost host; + private readonly Dictionary eventHandlers = new Dictionary(); + + /// + /// Initializes a new instance of the class. + /// + /// The host interface for the script. + public SkinScriptInterface(ISkinScriptHost host) + { + this.host = host; + } + + /// + /// Gets the beatmap's title. + /// + /// The beatmap's title. + [MoonSharpVisible(true)] + public string GetBeatmapTitle() + { + return host.CurrentBeatmap?.Metadata?.Title ?? "Unknown"; + } + + /// + /// Gets the beatmap's artist. + /// + /// The beatmap's artist. + [MoonSharpVisible(true)] + public string GetBeatmapArtist() + { + return host.CurrentBeatmap?.Metadata?.Artist ?? "Unknown"; + } + + /// + /// Gets the current ruleset's name. + /// + /// The ruleset's name. + [MoonSharpVisible(true)] + public string GetRulesetName() + { + return host.CurrentRuleset?.Name ?? "Unknown"; + } + + /// + /// Creates a new component of the specified type. + /// + /// The component type name. + /// The created component. + [MoonSharpVisible(true)] + public object CreateComponent(string componentType) + { + return host.CreateComponent(componentType); + } + + /// + /// Gets a texture from the current skin. + /// + /// The name of the texture. + /// The texture, or null if not found. + [MoonSharpVisible(true)] + public object GetTexture(string name) + { + return host.GetTexture(name); + } + + /// + /// Gets a sample from the current skin. + /// + /// The name of the sample. + /// The sample, or null if not found. + [MoonSharpVisible(true)] + public object GetSample(string name) + { + return host.GetSample(name); + } + + /// + /// Plays a sample. + /// + /// The name of the sample. + [MoonSharpVisible(true)] + public void PlaySample(string name) + { + var sample = host.GetSample(name); + sample?.Play(); + } + + /// + /// Subscribes to a game event. + /// + /// The name of the event to subscribe to. + [MoonSharpVisible(true)] + public void SubscribeToEvent(string eventName) + { + host.SubscribeToEvent(eventName); + } + + /// + /// Logs a message to the osu! log. + /// + /// The message to log. + /// The log level (debug, info, warning, error). + [MoonSharpVisible(true)] + public void Log(string message, string level = "info") + { + LogLevel logLevel = LogLevel.Information; + + switch (level.ToLower()) + { + case "debug": + logLevel = LogLevel.Debug; + break; + case "warning": + logLevel = LogLevel.Warning; + break; + case "error": + logLevel = LogLevel.Error; + break; + } + + host.Log(message, logLevel); + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptManager.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptManager.cs new file mode 100644 index 0000000000..51170f150b --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptManager.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Scoring; + +namespace osu.Game.Skinning.Scripting +{ + /// + /// Manages skin scripts for the current skin. + /// + [Cached] + public class SkinScriptManager : Component, ISkinScriptHost + { + private readonly List activeScripts = new List(); + + [Resolved] + private AudioManager audioManager { get; set; } + + [Resolved] + private SkinManager skinManager { get; set; } + + [Resolved] + private IBindable beatmap { get; set; } + + [Resolved] + private IBindable ruleset { get; set; } + + [Resolved] + private Storage storage { get; set; } + + private SkinScriptingConfig scriptingConfig; + + private Bindable scriptingEnabled; + private BindableList allowedScripts; + private BindableList blockedScripts; /// + /// Gets the current beatmap. + /// + public IBeatmap CurrentBeatmap => beatmap.Value?.Beatmap; + + /// + /// Gets the audio manager for sound playback. + /// + public IAudioManager AudioManager => audioManager; + + /// + /// Gets the current skin. + /// + public ISkin CurrentSkin => skinManager.CurrentSkin.Value; + + /// + /// Gets the current ruleset info. + /// + public IRulesetInfo CurrentRuleset => ruleset.Value; + + [BackgroundDependencyLoader] + private void load() + { + // Initialize scripting configuration + scriptingConfig = new SkinScriptingConfig(storage); + scriptingEnabled = scriptingConfig.GetBindable(SkinScriptingSettings.ScriptingEnabled); + allowedScripts = scriptingConfig.GetBindable>(SkinScriptingSettings.AllowedScripts).GetBoundCopy(); + blockedScripts = scriptingConfig.GetBindable>(SkinScriptingSettings.BlockedScripts).GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Subscribe to skin changes + skinManager.CurrentSkinInfo.BindValueChanged(skinChanged); + + // Subscribe to scripting configuration changes + scriptingEnabled.BindValueChanged(_ => updateScriptStates(), true); + allowedScripts.BindCollectionChanged((_, __) => updateScriptStates(), true); + blockedScripts.BindCollectionChanged((_, __) => updateScriptStates(), true); + } + + private void updateScriptStates() + { + if (!scriptingEnabled.Value) + { + // Disable all scripts when scripting is disabled + foreach (var script in activeScripts) + script.IsEnabled = false; + + return; + } + + foreach (var script in activeScripts) + { + string scriptName = script.ScriptName; + + if (blockedScripts.Contains(scriptName)) + script.IsEnabled = false; + else if (allowedScripts.Count > 0) + script.IsEnabled = allowedScripts.Contains(scriptName); + else + script.IsEnabled = true; + } + } + + private void skinChanged(ValueChangedEvent skin) + { + // Clear existing scripts + foreach (var script in activeScripts) + script.Dispose(); + + activeScripts.Clear(); + + if (scriptingEnabled.Value) + { + // Load scripts from the new skin + loadScriptsFromSkin(skinManager.CurrentSkin.Value); + } + } private void loadScriptsFromSkin(ISkin skin) + { + if (skin is Skin skinWithFiles) + { + // Look for Lua script files + foreach (var file in skinWithFiles.Files.Where(f => Path.GetExtension(f.Filename).Equals(".lua", StringComparison.OrdinalIgnoreCase))) + { + try + { + using (Stream stream = skinWithFiles.GetStream(file.Filename)) + using (StreamReader reader = new StreamReader(stream)) + { + string scriptContent = reader.ReadToEnd(); + SkinScript script = new SkinScript(scriptContent, file.Filename, this); + + // 设置脚本的启用状态 + string scriptName = file.Filename; + if (blockedScripts.Contains(scriptName)) + script.IsEnabled = false; + else if (allowedScripts.Count > 0) + script.IsEnabled = allowedScripts.Contains(scriptName); + else + script.IsEnabled = true; + + activeScripts.Add(script); + + Log($"Loaded skin script: {file.Filename}", LogLevel.Information); + } + } + catch (Exception ex) + { + Log($"Failed to load skin script {file.Filename}: {ex.Message}", LogLevel.Error); + } + } + } + } + + /// + /// Notifies scripts that a component has been loaded. + /// + /// The loaded component. + public void NotifyComponentLoaded(Drawable component) + { + foreach (var script in activeScripts) + script.NotifyComponentLoaded(component); + } + + /// + /// Notifies scripts of a game event. + /// + /// The name of the event. + /// The event data. + public void NotifyGameEvent(string eventName, object data) + { + foreach (var script in activeScripts) + script.NotifyGameEvent(eventName, data); + } + + /// + /// Notifies scripts of a judgement result. + /// + /// The judgement result. + public void NotifyJudgement(JudgementResult result) + { + foreach (var script in activeScripts) + script.NotifyJudgement(result); + } + + /// + /// Notifies scripts of an input event. + /// + /// The input event. + public void NotifyInputEvent(InputEvent inputEvent) + { + foreach (var script in activeScripts) + script.NotifyInputEvent(inputEvent); + } + + /// + /// Updates all scripts. + /// + protected override void Update() + { + base.Update(); + + foreach (var script in activeScripts) + script.Update(); + } + + #region ISkinScriptHost Implementation + + /// + /// Creates a new drawable component of the specified type. + /// + /// The type of component to create. + /// The created component. + public Drawable CreateComponent(string componentType) + { + // This would need to be expanded with actual component types + switch (componentType) + { + case "Container": + return new Container(); + // Add more component types as needed + default: + Log($"Unknown component type: {componentType}", LogLevel.Warning); + return new Container(); + } + } + + /// + /// Gets a texture from the current skin. + /// + /// The name of the texture. + /// The texture, or null if not found. + public Texture GetTexture(string name) + { + return skinManager.CurrentSkin.Value.GetTexture(name); + } + + /// + /// Gets a sample from the current skin. + /// + /// The name of the sample. + /// The sample, or null if not found. + public ISample GetSample(string name) + { + return skinManager.CurrentSkin.Value.GetSample(name); + } + + /// + /// Subscribe to a game event. + /// + /// The name of the event to subscribe to. + public void SubscribeToEvent(string eventName) + { + // Implementation would depend on available events + Log($"Script subscribed to event: {eventName}", LogLevel.Debug); + } + + /// + /// Log a message to the osu! log. + /// + /// The message to log. + /// The log level. + public void Log(string message, LogLevel level = LogLevel.Information) + { + switch (level) + { + case LogLevel.Debug: + Logger.Log(message, level: LogLevel.Debug); + break; + case LogLevel.Information: + Logger.Log(message); + break; + case LogLevel.Warning: + Logger.Log(message, level: Framework.Logging.LogLevel.Important); + break; + case LogLevel.Error: + Logger.Error(message); + break; + } + } + + #endregion + + protected override void Dispose(bool isDisposing) + { + foreach (var script in activeScripts) + script.Dispose(); + + activeScripts.Clear(); + + base.Dispose(isDisposing); + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingConfig.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingConfig.cs new file mode 100644 index 0000000000..24f6bf86fa --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingConfig.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Game.Configuration; + +namespace osu.Game.Skinning.Scripting +{ + public class SkinScriptingConfig : IniConfigManager + { + public SkinScriptingConfig(Storage storage) : base(storage) + { + } + + protected override void InitialiseDefaults() + { + base.InitialiseDefaults(); + + Set(SkinScriptingSettings.ScriptingEnabled, true); + Set(SkinScriptingSettings.AllowedScripts, new List()); + Set(SkinScriptingSettings.BlockedScripts, new List()); + } + } + + public enum SkinScriptingSettings + { + // 全局启用/禁用脚本功能 + ScriptingEnabled, + + // 允许的脚本列表 + AllowedScripts, + + // 禁止的脚本列表 + BlockedScripts + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs new file mode 100644 index 0000000000..953674e3ca --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs @@ -0,0 +1,25 @@ +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections; +using osu.Game.Skinning.Scripting.Overlays; + +namespace osu.Game.Skinning.Scripting +{ + /// + /// 负责注册皮肤脚本设置到设置界面。 + /// + public class SkinScriptingOverlayRegistration : Component + { + [Resolved] + private SettingsOverlay settingsOverlay { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + // 皮肤脚本设置部分已经在SkinSection中集成,无需额外操作 + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/Skin.cs b/SkinScriptingImplementation/osu.Game/Skinning/Skin.cs new file mode 100644 index 0000000000..996042d4ed --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/Skin.cs @@ -0,0 +1,460 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using Newtonsoft.Json; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; +using osu.Game.Audio; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.Rulesets; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning.Scripting; + +namespace osu.Game.Skinning +{ + public abstract class Skin : IDisposable, ISkin + { + private readonly IStorageResourceProvider? resources; + + /// + /// A texture store which can be used to perform user file lookups for this skin. + /// + protected TextureStore? Textures { get; } + + /// + /// A sample store which can be used to perform user file lookups for this skin. + /// + protected ISampleStore? Samples { get; } + + public readonly Live SkinInfo; + + public SkinConfiguration Configuration { get; set; } + + public IDictionary LayoutInfos => layoutInfos; + + private readonly Dictionary layoutInfos = + new Dictionary(); + + /// + /// The list of loaded scripts for this skin. + /// + public List Scripts { get; private set; } = new List(); + + public abstract ISample? GetSample(ISampleInfo sampleInfo); + + public Texture? GetTexture(string componentName) => GetTexture(componentName, default, default); + + public abstract Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT); + + public abstract IBindable? GetConfig(TLookup lookup) + where TLookup : notnull + where TValue : notnull; + + private readonly ResourceStore store = new ResourceStore(); + + public string Name { get; } + + /// + /// Construct a new skin. + /// + /// The skin's metadata. Usually a live realm object. + /// Access to game-wide resources. + /// An optional fallback store which will be used for file lookups that are not serviced by realm user storage. + /// An optional filename to read the skin configuration from. If not provided, the configuration will be retrieved from the storage using "skin.ini". + protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? fallbackStore = null, string configurationFilename = @"skin.ini") + { + this.resources = resources; + + Name = skin.Name; + + if (resources != null) + { + SkinInfo = skin.ToLive(resources.RealmAccess); + + store.AddStore(new RealmBackedResourceStore(SkinInfo, resources.Files, resources.RealmAccess)); + + var samples = resources.AudioManager?.GetSampleStore(store); + + if (samples != null) + { + samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + + // osu-stable performs audio lookups in order of wav -> mp3 -> ogg. + // The GetSampleStore() call above internally adds wav and mp3, so ogg is added at the end to ensure expected ordering. + samples.AddExtension(@"ogg"); + } + + Samples = samples; + Textures = new TextureStore(resources.Renderer, CreateTextureLoaderStore(resources, store)); + } + else + { + // Generally only used for tests. + SkinInfo = skin.ToLiveUnmanaged(); + } + + if (fallbackStore != null) + store.AddStore(fallbackStore); + + var configurationStream = store.GetStream(configurationFilename); + + if (configurationStream != null) + { + // stream will be closed after use by LineBufferedReader. + ParseConfigurationStream(configurationStream); + Debug.Assert(Configuration != null); + } + else + { + Configuration = new SkinConfiguration + { + // generally won't be hit as we always write a `skin.ini` on import, but best be safe than sorry. + // see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298 + LegacyVersion = SkinConfiguration.LATEST_VERSION, + }; + } + + // skininfo files may be null for default skin. + foreach (GlobalSkinnableContainers skinnableTarget in Enum.GetValues()) + { + string filename = $"{skinnableTarget}.json"; + + byte[]? bytes = store?.Get(filename); + + if (bytes == null) + continue; + + try + { + string jsonContent = Encoding.UTF8.GetString(bytes); + + var layoutInfo = parseLayoutInfo(jsonContent, skinnableTarget); + if (layoutInfo == null) + continue; + + LayoutInfos[skinnableTarget] = layoutInfo; + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load skin configuration."); + } + } + } + + /// + /// Called when skin resources have been loaded. + /// This is the place to load any script files. + /// + protected virtual void LoadComplete() + { + // Load skin scripts if any + LoadScripts(); + } + + /// + /// Loads any script files associated with this skin. + /// + protected virtual void LoadScripts() + { + if (!(resources?.RealmAccess?.Realm.Find() is SkinScriptManager scriptManager)) + return; + + foreach (var file in GetScriptFiles()) + { + try + { + using (Stream stream = GetStream(file)) + using (StreamReader reader = new StreamReader(stream)) + { + string scriptContent = reader.ReadToEnd(); + SkinScript script = new SkinScript(scriptContent, file, scriptManager); + Scripts.Add(script); + + Logger.Log($"Loaded skin script: {file}", LogLevel.Information); + } + } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to load skin script {file}"); + } + } + } + + /// + /// Gets a list of script files in the skin. + /// + /// A list of script file names. + protected virtual IEnumerable GetScriptFiles() + { + return new string[0]; + } + + /// + /// Gets a stream for the specified file. + /// + /// The name of the file. + /// The stream, or null if the file was not found. + protected virtual Stream GetStream(string filename) + { + return store.GetStream(filename); + } + + protected virtual IResourceStore CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore storage) + => new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage)); + + protected virtual void ParseConfigurationStream(Stream stream) + { + using (LineBufferedReader reader = new LineBufferedReader(stream, true)) + Configuration = new LegacySkinDecoder().Decode(reader); + } + + /// + /// Remove all stored customisations for the provided target. + /// + /// The target container to reset. + public void ResetDrawableTarget(SkinnableContainer targetContainer) + { + LayoutInfos.Remove(targetContainer.Lookup.Lookup); + } + + /// + /// Update serialised information for the provided target. + /// + /// The target container to serialise to this skin. + public void UpdateDrawableTarget(SkinnableContainer targetContainer) + { + if (!LayoutInfos.TryGetValue(targetContainer.Lookup.Lookup, out var layoutInfo)) + layoutInfos[targetContainer.Lookup.Lookup] = layoutInfo = new SkinLayoutInfo(); + + layoutInfo.Update(targetContainer.Lookup.Ruleset, ((ISerialisableDrawableContainer)targetContainer).CreateSerialisedInfo().ToArray()); + } + + public virtual Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + // This fallback is important for user skins which use SkinnableSprites. + case SkinnableSprite.SpriteComponentLookup sprite: + return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize); + + case UserSkinComponentLookup userLookup: + switch (userLookup.Component) + { + case GlobalSkinnableContainerLookup containerLookup: + // It is important to return null if the user has not configured this yet. + // This allows skin transformers the opportunity to provide default components. + if (!LayoutInfos.TryGetValue(containerLookup.Lookup, out var layoutInfo)) return null; + if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null; + + return new Container + { + RelativeSizeAxes = Axes.Both, + ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance()) + }; + } + + break; + } + + return null; + } + + #region Deserialisation & Migration + + private SkinLayoutInfo? parseLayoutInfo(string jsonContent, GlobalSkinnableContainers target) + { + SkinLayoutInfo? layout = null; + + // handle namespace changes... + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); + jsonContent = jsonContent.Replace(@"osu.Game.Skinning.LegacyComboCounter", @"osu.Game.Skinning.LegacyDefaultComboCounter"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter"); + + try + { + // First attempt to deserialise using the new SkinLayoutInfo format + layout = JsonConvert.DeserializeObject(jsonContent); + } + catch + { + } + + // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. + if (layout == null) + { + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + if (deserializedContent == null) + return null; + + layout = new SkinLayoutInfo { Version = 0 }; + layout.Update(null, deserializedContent.ToArray()); + + Logger.Log($"Ferrying {deserializedContent.Count()} components in {target} to global section of new {nameof(SkinLayoutInfo)} format"); + } + + for (int i = layout.Version + 1; i <= SkinLayoutInfo.LATEST_VERSION; i++) + applyMigration(layout, target, i); + + layout.Version = SkinLayoutInfo.LATEST_VERSION; + + foreach (var kvp in layout.DrawableInfo.ToArray()) + { + foreach (var di in kvp.Value) + { + if (!isValidDrawable(di)) + layout.DrawableInfo[kvp.Key] = kvp.Value.Where(i => i.Type != di.Type).ToArray(); + } + } + + return layout; + } + + private bool isValidDrawable(SerialisedDrawableInfo di) + { + if (!typeof(ISerialisableDrawable).IsAssignableFrom(di.Type)) + return false; + + foreach (var child in di.Children) + { + if (!isValidDrawable(child)) + return false; + } + + return true; + } + + private void applyMigration(SkinLayoutInfo layout, GlobalSkinnableContainers target, int version) + { + switch (version) + { + case 1: + { + // Combo counters were moved out of the global HUD components into per-ruleset. + // This is to allow some rulesets to customise further (ie. mania and catch moving the combo to within their play area). + if (target != GlobalSkinnableContainers.MainHUDComponents || + !layout.TryGetDrawableInfo(null, out var globalHUDComponents) || + resources == null) + break; + + var comboCounters = globalHUDComponents.Where(c => + c.Type.Name == nameof(LegacyDefaultComboCounter) || + c.Type.Name == nameof(DefaultComboCounter) || + c.Type.Name == nameof(ArgonComboCounter)).ToArray(); + + layout.Update(null, globalHUDComponents.Except(comboCounters).ToArray()); + + resources.RealmAccess.Run(r => + { + foreach (var ruleset in r.All()) + { + layout.Update(ruleset, layout.TryGetDrawableInfo(ruleset, out var rulesetHUDComponents) + ? rulesetHUDComponents.Concat(comboCounters).ToArray() + : comboCounters); + } + }); + + break; + } + } + } + + #endregion + + #region Disposal + + ~Skin() + { + // required to potentially clean up sample store from audio hierarchy. + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private bool isDisposed; + + protected virtual void Dispose(bool isDisposing) + { + if (isDisposed) + return; + + isDisposed = true; + + foreach (var script in Scripts) + script.Dispose(); + + Scripts.Clear(); + + Textures?.Dispose(); + Samples?.Dispose(); + + store.Dispose(); + } + + #endregion + + public override string ToString() => $"{GetType().ReadableName()} {{ Name: {Name} }}"; + + private static readonly ThreadLocal nested_level = new ThreadLocal(() => 0); + + [Conditional("SKIN_LOOKUP_DEBUG")] + internal static void LogLookupDebug(object callingClass, object lookup, LookupDebugType type, [CallerMemberName] string callerMethod = "") + { + string icon = string.Empty; + int level = nested_level.Value; + + switch (type) + { + case LookupDebugType.Hit: + icon = "🟢 hit"; + break; + + case LookupDebugType.Miss: + icon = "🔴 miss"; + break; + + case LookupDebugType.Enter: + nested_level.Value++; + break; + + case LookupDebugType.Exit: + nested_level.Value--; + if (nested_level.Value == 0) + Logger.Log(string.Empty); + return; + } + + string lookupString = lookup.ToString() ?? string.Empty; + string callingClassString = callingClass.ToString() ?? string.Empty; + + Logger.Log($"{string.Join(null, Enumerable.Repeat("|-", level))}{callingClassString}.{callerMethod}(lookup: {lookupString}) {icon}"); + } + + internal enum LookupDebugType + { + Hit, + Miss, + Enter, + Exit + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/SkinManager.cs b/SkinScriptingImplementation/osu.Game/Skinning/SkinManager.cs new file mode 100644 index 0000000000..fae5e25de9 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/SkinManager.cs @@ -0,0 +1,504 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.Overlays.Notifications; +using osu.Game.Skinning.Scripting; +using osu.Game.Utils; + +namespace osu.Game.Skinning +{ + /// + /// Handles the storage and retrieval of s. + /// + /// + /// This is also exposed and cached as to allow for any component to potentially have skinning support. + /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. + /// + public class SkinManager : ModelManager, ISkinSource, IStorageResourceProvider, IModelImporter + { + /// + /// The default "classic" skin. + /// + public Skin DefaultClassicSkin { get; } + + private readonly AudioManager audio; + + private readonly Scheduler scheduler; + + private readonly GameHost host; + + private readonly IResourceStore resources; + + public readonly Bindable CurrentSkin = new Bindable(); + + public readonly Bindable> CurrentSkinInfo = new Bindable>(ArgonSkin.CreateInfo().ToLiveUnmanaged()); + + private readonly SkinImporter skinImporter; + + private readonly LegacySkinExporter skinExporter; + + private readonly IResourceStore userFiles; + + private Skin argonSkin { get; } + + private Skin trianglesSkin { get; } + + private SkinScriptManager scriptManager; + + public override bool PauseImports + { + get => base.PauseImports; + set + { + base.PauseImports = value; + skinImporter.PauseImports = value; + } + } + + public SkinManager(Storage storage, RealmAccess realm, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler) + : base(storage, realm) + { + this.audio = audio; + this.scheduler = scheduler; + this.host = host; + this.resources = resources; + + userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files")); + + skinImporter = new SkinImporter(storage, realm, this) + { + PostNotification = obj => PostNotification?.Invoke(obj), + }; + + var defaultSkins = new[] + { + DefaultClassicSkin = new DefaultLegacySkin(this), + trianglesSkin = new TrianglesSkin(this), + argonSkin = new ArgonSkin(this), + new ArgonProSkin(this), + new Ez2Skin(this), + new SbISkin(this), + }; + + skinExporter = new LegacySkinExporter(storage) + { + PostNotification = obj => PostNotification?.Invoke(obj) + }; + + // Initialize the script manager + scriptManager = new SkinScriptManager(); + realm.RegisterCustomObject(scriptManager); + + CurrentSkinInfo.ValueChanged += skin => + { + CurrentSkin.Value = getSkin(skin.NewValue); + CurrentSkinInfoChanged?.Invoke(); + }; + + try + { + // Start with non-user skins to ensure they are present. + foreach (var skin in defaultSkins) + { + if (skin.SkinInfo.ID != SkinInfo.ARGON_SKIN && skin.SkinInfo.ID != SkinInfo.TRIANGLES_SKIN) + continue; + + // if the user has a modified copy of the default, use it instead. + var existingSkin = realm.Run(r => r.All().FirstOrDefault(s => s.ID == skin.SkinInfo.ID)); + + if (existingSkin != null) + continue; + + realm.Write(r => + { + skin.SkinInfo.Protected = true; + r.Add(new SkinInfo + { + ID = skin.SkinInfo.ID, + Name = skin.SkinInfo.Name, + Creator = skin.SkinInfo.Creator, + Protected = true, + InstantiationInfo = skin.SkinInfo.InstantiationInfo + }); + }); + } + } + catch + { + // May fail due to incomplete or breaking migrations. + } + } + + /// + /// Returns a list of all usable s. Includes the special default and random skins. + /// + /// A list of available s. + public List> GetAllUsableSkins() + { + return Realm.Run(r => + { + // First display all skins. + var instances = r.All() + .Where(s => !s.DeletePending) + .OrderBy(s => s.Protected) + .ThenBy(s => s.Name) + .ToList(); + + // Then add all default skin entries. + var defaultSkins = r.All() + .Where(s => s.ID == SkinInfo.ARGON_SKIN || s.ID == SkinInfo.TRIANGLES_SKIN) + .ToList(); + + foreach (var s in defaultSkins) + instances.Insert(instances.FindIndex(s2 => s2.Protected) + defaultSkins.IndexOf(s), s); + + return instances.Distinct().Select(s => r.Find(s.ID)?.ToLive(Realm.Realm)).Where(s => s != null).ToList(); + }); + } + + public event Action CurrentSkinInfoChanged; + + public Skin CurrentSkinInfo { get; private set; } + + public void RefreshCurrentSkin() => CurrentSkinInfo.TriggerChange(); + + private Skin getSkin(Live skinInfo) + { + if (skinInfo == null) + return null; + + Skin skin; + + try + { + switch (skinInfo.ID) + { + case SkinInfo.ARGON_SKIN: + skin = argonSkin; + break; + + case SkinInfo.TRIANGLES_SKIN: + skin = trianglesSkin; + break; + + default: + skin = skinInfo.CreateInstance(this); + break; + } + } + catch (Exception ex) + { + Logger.Error(ex, $"Unable to load skin \"{skinInfo.ToString()}\""); + return DefaultClassicSkin; + } + + return skin; + } + + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => GetDrawableComponent(CurrentSkin.Value, lookup); + + public Drawable GetDrawableComponent(ISkin skin, ISkinComponentLookup lookup) + { + return skin?.GetDrawableComponent(lookup); + } + + public ISample GetSample(ISampleInfo sampleInfo) => GetSample(CurrentSkin.Value, sampleInfo); + + public ISample GetSample(ISkin skin, ISampleInfo sampleInfo) + { + IEnumerable lookupDebug = null; + + if (DebugDisplay.Value || (lookupDebug ??= GetIpcData>("Debug:SkinLookupTypes"))?.Contains(Skin.LookupDebugType.None) != true) + Skin.LogLookupDebug(this, sampleInfo, Skin.LookupDebugType.Enter); + + try + { + var sample = skin?.GetSample(sampleInfo); + + if (sample != null) + return sample; + + foreach (var skSource in AllSources) + { + sample = skSource.GetSample(sampleInfo); + if (sample != null) + return sample; + } + + return null; + } + finally + { + Skin.LogLookupDebug(this, sampleInfo, Skin.LookupDebugType.Exit); + } + } + + public Texture GetTexture(string componentName) => GetTexture(CurrentSkin.Value, componentName); + + public Texture GetTexture(ISkin skin, string componentName) + { + Skin.LogLookupDebug(this, componentName, Skin.LookupDebugType.Enter); + + try + { + var texture = skin?.GetTexture(componentName); + + if (texture != null) + return texture; + + foreach (var skSource in AllSources) + { + texture = skSource.GetTexture(componentName); + if (texture != null) + return texture; + } + + return null; + } + finally + { + Skin.LogLookupDebug(this, componentName, Skin.LookupDebugType.Exit); + } + } + + public IBindable GetConfig(TLookup lookup) => GetConfig(CurrentSkin.Value, lookup); + + public IBindable GetConfig(ISkin skin, TLookup lookup) + where TLookup : notnull + where TValue : notnull + { + Skin.LogLookupDebug(this, lookup, Skin.LookupDebugType.Enter); + + try + { + var bindable = skin?.GetConfig(lookup); + + if (bindable != null) + return bindable; + + foreach (var source in AllSources) + { + bindable = source.GetConfig(lookup); + if (bindable != null) + return bindable; + } + + return null; + } + finally + { + Skin.LogLookupDebug(this, lookup, Skin.LookupDebugType.Exit); + } + } + + public IEnumerable AllSources + { + get + { + yield return DefaultClassicSkin; + } + } + + public IEnumerable GetAllConfigs(TLookup lookup) + where TLookup : notnull + where TValue : notnull + { + var sources = new List(); + var items = new List(); + + addFromSource(CurrentSkin.Value, this); + + // This is not sane. + foreach (var s in AllSources) + addFromSource(s, default); + + return items; + + void addFromSource(ISkin source, object lookupFunction) + { + if (source == null) return; + + if (sources.Contains(source)) return; + + sources.Add(source); + + if (lookupFunction != null) + Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Enter); + + try + { + // check for direct value + if (source.GetConfig(lookup)?.Value is TValue val) + items.Add(val); + } + finally + { + if (lookupFunction != null) + Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Exit); + } + } + } + + public IBindable FindConfig(params Func>[] lookups) + where TValue : notnull + { + Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter); + + try + { + return FindConfig(CurrentSkin.Value, lookups) ?? FindConfig(AllSources, lookups); + } + finally + { + Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit); + } + } + + public IBindable FindConfig(ISkin skin, params Func>[] lookups) + where TValue : notnull + { + Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter); + + try + { + if (skin == null) + return null; + + foreach (var l in lookups) + { + var bindable = l(skin); + + if (bindable != null) + return bindable; + } + + return null; + } + finally + { + Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit); + } + } + + public IBindable FindConfig(IEnumerable allSources, params Func>[] lookups) + where TValue : notnull + { + Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter); + + try + { + foreach (var source in allSources) + { + var bindable = FindConfig(source, lookups); + + if (bindable != null) + return bindable; + } + + return null; + } + finally + { + Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit); + } + } + + #region IResourceStorageProvider + + IRenderer IStorageResourceProvider.Renderer => host.Renderer; + AudioManager IStorageResourceProvider.AudioManager => audio; + IResourceStore IStorageResourceProvider.Resources => resources; + IResourceStore IStorageResourceProvider.Files => userFiles; + RealmAccess IStorageResourceProvider.RealmAccess => Realm; + IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); + + #endregion + + #region Implementation of IModelImporter + + public Action>> PresentImport + { + set => skinImporter.PresentImport = value; + } + + public Task Import(params string[] paths) => skinImporter.Import(paths); + + public Task Import(ImportTask[] imports, ImportParameters parameters = default) => skinImporter.Import(imports, parameters); + + public IEnumerable HandledExtensions => skinImporter.HandledExtensions; + + public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => + skinImporter.Import(notification, tasks, parameters); + + public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => + skinImporter.ImportAsUpdate(notification, task, original); + + public Task> BeginExternalEditing(SkinInfo model) => skinImporter.BeginExternalEditing(model); + + public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => + skinImporter.Import(task, parameters, cancellationToken); + + public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value); + + public Task ExportSkin(Live skin) => skinExporter.ExportAsync(skin); + + #endregion + + public void Delete([CanBeNull] Expression> filter = null, bool silent = false) + { + Realm.Run(r => + { + var items = r.All() + .Where(s => !s.Protected && !s.DeletePending); + if (filter != null) + items = items.Where(filter); + + // check the removed skin is not the current user choice. if it is, switch back to default. + Guid currentUserSkin = CurrentSkinInfo.Value.ID; + + if (items.Any(s => s.ID == currentUserSkin)) + scheduler.Add(() => CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged()); + + Delete(items.ToList(), silent); + }); + } + + public void SetSkinFromConfiguration(string guidString) + { + Live skinInfo = null; + + if (Guid.TryParse(guidString, out var guid)) + skinInfo = Query(s => s.ID == guid); + + if (skinInfo == null) + { + if (guid == SkinInfo.CLASSIC_SKIN) + skinInfo = DefaultClassicSkin.SkinInfo; + } + + CurrentSkinInfo.Value = skinInfo ?? trianglesSkin.SkinInfo; + } + } +} diff --git a/SkinScriptingImplementation/osu.Game/Skinning/SkinnableDrawable.cs b/SkinScriptingImplementation/osu.Game/Skinning/SkinnableDrawable.cs new file mode 100644 index 0000000000..5f8a599b62 --- /dev/null +++ b/SkinScriptingImplementation/osu.Game/Skinning/SkinnableDrawable.cs @@ -0,0 +1,157 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Game.Skinning.Scripting; +using osuTK; + +namespace osu.Game.Skinning +{ + /// + /// A drawable which can be skinned via an . + /// + public partial class SkinnableDrawable : SkinReloadableDrawable + { + /// + /// The displayed component. + /// + public Drawable Drawable { get; private set; } = null!; + + /// + /// Whether the drawable component should be centered in available space. + /// Defaults to true. + /// + public bool CentreComponent = true; + + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set => base.AutoSizeAxes = value; + } + + protected readonly ISkinComponentLookup ComponentLookup; + + private readonly ConfineMode confineMode; + + [Resolved(CanBeNull = true)] + private SkinScriptManager scriptManager { get; set; } + + /// + /// Create a new skinnable drawable. + /// + /// The namespace-complete resource name for this skinnable element. + /// A function to create the default skin implementation of this element. + /// How (if at all) the should be resize to fit within our own bounds. + public SkinnableDrawable(ISkinComponentLookup lookup, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : this(lookup, confineMode) + { + createDefault = defaultImplementation; + } + + protected SkinnableDrawable(ISkinComponentLookup lookup, ConfineMode confineMode = ConfineMode.NoScaling) + { + ComponentLookup = lookup; + this.confineMode = confineMode; + + RelativeSizeAxes = Axes.Both; + } + + /// + /// Seeks to the 0-th frame if the content of this is an . + /// + public void ResetAnimation() => (Drawable as IFramedAnimation)?.GotoFrame(0); + + private readonly Func? createDefault; + + private readonly Cached scaling = new Cached(); + + private bool isDefault; + + protected virtual Drawable CreateDefault(ISkinComponentLookup lookup) => createDefault?.Invoke(lookup) ?? Empty(); + + /// + /// Whether to apply size restrictions (specified via ) to the default implementation. + /// + protected virtual bool ApplySizeRestrictionsToDefault => false; + + protected override void SkinChanged(ISkinSource skin) + { + var retrieved = skin.GetDrawableComponent(ComponentLookup); + + if (retrieved == null) + { + Drawable = CreateDefault(ComponentLookup); + isDefault = true; + } + else + { + Drawable = retrieved; + isDefault = false; + } + + scaling.Invalidate(); + + if (CentreComponent) + { + Drawable.Origin = Anchor.Centre; + Drawable.Anchor = Anchor.Centre; + } + + InternalChild = Drawable; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Notify scripts that a component has been loaded + NotifyScriptsOfComponentLoad(); + } + + private void NotifyScriptsOfComponentLoad() + { + // Notify the script manager about the component being loaded + scriptManager?.NotifyComponentLoaded(Drawable); + } + + protected override void Update() + { + base.Update(); + + if (!scaling.IsValid) + { + try + { + if (isDefault && !ApplySizeRestrictionsToDefault) return; + + switch (confineMode) + { + case ConfineMode.ScaleToFit: + Drawable.RelativeSizeAxes = Axes.Both; + Drawable.Size = Vector2.One; + Drawable.Scale = Vector2.One; + Drawable.FillMode = FillMode.Fit; + break; + } + } + finally + { + scaling.Validate(); + } + } + } + } + + public enum ConfineMode + { + /// + /// Don't apply any scaling. This allows the user element to be of any size, exceeding specified bounds. + /// + NoScaling, + ScaleToFit, + } +} diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000000..ed48a997e8 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,32 @@ +clone_depth: 1 +version: '{branch}-{build}' +image: Visual Studio 2022 +cache: + - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml' + +dotnet_csproj: + patch: true + file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects + version: '0.0.{build}' + +before_build: + - cmd: dotnet --info # Useful when version mismatch between CI and local + - cmd: dotnet workload install maui-android # Change to `dotnet workload restore` once there's no old projects + - cmd: dotnet workload install maui-ios # Change to `dotnet workload restore` once there's no old projects + - cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects + +build: + project: osu.sln + parallel: true + verbosity: minimal + publish_nuget: true + +after_build: + - ps: .\InspectCode.ps1 + +test: + assemblies: + except: + - '**\*Android*' + - '**\*iOS*' + - 'build\**\*' diff --git a/appveyor_deploy.yml b/appveyor_deploy.yml new file mode 100644 index 0000000000..175c8d0f1b --- /dev/null +++ b/appveyor_deploy.yml @@ -0,0 +1,86 @@ +clone_depth: 1 +version: '{build}' +image: Visual Studio 2022 +test: off +skip_non_tags: true +configuration: Release + +environment: + matrix: + - job_name: osu-game + - job_name: osu-ruleset + job_depends_on: osu-game + - job_name: taiko-ruleset + job_depends_on: osu-game + - job_name: catch-ruleset + job_depends_on: osu-game + - job_name: mania-ruleset + job_depends_on: osu-game + - job_name: templates + job_depends_on: osu-game + +nuget: + project_feed: true + +for: + - + matrix: + only: + - job_name: osu-game + build_script: + - cmd: dotnet pack osu.Game\osu.Game.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% + - + matrix: + only: + - job_name: osu-ruleset + build_script: + - cmd: dotnet remove osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet add osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet pack osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% + - + matrix: + only: + - job_name: taiko-ruleset + build_script: + - cmd: dotnet remove osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet add osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet pack osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% + - + matrix: + only: + - job_name: catch-ruleset + build_script: + - cmd: dotnet remove osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet add osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet pack osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% + - + matrix: + only: + - job_name: mania-ruleset + build_script: + - cmd: dotnet remove osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet add osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet pack osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% + - + matrix: + only: + - job_name: templates + build_script: + - cmd: dotnet remove Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet remove Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj + + - cmd: dotnet add Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet add Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + + - cmd: dotnet pack Templates\osu.Game.Templates.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% + +artifacts: + - path: '**\*.nupkg' + +deploy: + - provider: Environment + name: nuget diff --git a/osu.Desktop.slnf b/osu.Desktop.slnf index 606988ccdf..7a092948f0 100644 --- a/osu.Desktop.slnf +++ b/osu.Desktop.slnf @@ -1,19 +1,17 @@ -{ +{ "solution": { "path": "osu.sln", "projects": [ + "..\\osu-framework\\osu.Framework\\osu.Framework.csproj", + "..\\osu-resources\\osu.Game.Resources\\osu.Game.Resources.csproj", "osu.Desktop\\osu.Desktop.csproj", "osu.Game.Benchmarks\\osu.Game.Benchmarks.csproj", - "osu.Game.Rulesets.Catch.Tests\\osu.Game.Rulesets.Catch.Tests.csproj", "osu.Game.Rulesets.Catch\\osu.Game.Rulesets.Catch.csproj", "osu.Game.Rulesets.Mania.Tests\\osu.Game.Rulesets.Mania.Tests.csproj", "osu.Game.Rulesets.Mania\\osu.Game.Rulesets.Mania.csproj", - "osu.Game.Rulesets.Osu.Tests\\osu.Game.Rulesets.Osu.Tests.csproj", "osu.Game.Rulesets.Osu\\osu.Game.Rulesets.Osu.csproj", - "osu.Game.Rulesets.Taiko.Tests\\osu.Game.Rulesets.Taiko.Tests.csproj", "osu.Game.Rulesets.Taiko\\osu.Game.Rulesets.Taiko.csproj", "osu.Game.Tests\\osu.Game.Tests.csproj", - "osu.Game.Tournament.Tests\\osu.Game.Tournament.Tests.csproj", "osu.Game.Tournament\\osu.Game.Tournament.csproj", "osu.Game\\osu.Game.csproj", "Templates\\Rulesets\\ruleset-empty\\osu.Game.Rulesets.EmptyFreeform.Tests\\osu.Game.Rulesets.EmptyFreeform.Tests.csproj", diff --git a/osu.Desktop/EzMacOS/GameplaySpotlightBlocker.cs b/osu.Desktop/EzMacOS/GameplaySpotlightBlocker.cs new file mode 100644 index 0000000000..a7b9eef328 --- /dev/null +++ b/osu.Desktop/EzMacOS/GameplaySpotlightBlocker.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Runtime.Versioning; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osuTK.Input; +using osu.Game.Screens.Play; +using osu.Game.LAsEzExtensions.Configuration; + +namespace osu.Desktop.EzMacOS +{ + [SupportedOSPlatform("macos")] + public partial class GameplaySpotlightBlocker : Drawable + { + private Bindable disableCmdSpace = null!; + private IBindable localUserPlaying = null!; + private IBindable isActive = null!; + + [Resolved] + private GameHost host { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(ILocalUserPlayInfo localUserInfo, Ez2ConfigManager ezConfig) + { + RelativeSizeAxes = Axes.Both; + AlwaysPresent = true; + + localUserPlaying = localUserInfo.PlayingState.GetBoundCopy(); + localUserPlaying.BindValueChanged(_ => updateBlocking()); + + isActive = host.IsActive.GetBoundCopy(); + isActive.BindValueChanged(_ => updateBlocking()); + + disableCmdSpace = ezConfig.GetBindable(Ez2Setting.GameplayDisableCmdSpace); + disableCmdSpace.BindValueChanged(_ => updateBlocking(), true); + } + + private void updateBlocking() + { + // Block during active gameplay, including breaks; only allow when NotPlaying. + bool shouldDisable = isActive.Value && disableCmdSpace.Value && localUserPlaying.Value != LocalUserPlayingState.NotPlaying; + + if (shouldDisable) + host.InputThread.Scheduler.Add(SpotlightKey.Disable); + else + host.InputThread.Scheduler.Add(SpotlightKey.Enable); + } + + public override bool HandleNonPositionalInput => true; + + protected override bool OnKeyDown(KeyDownEvent e) + { + // As a fallback for "read input" only, swallow Space when Command is held + // to avoid triggering in-game actions while Spotlight is opening. + bool shouldDisable = isActive.Value && disableCmdSpace.Value && localUserPlaying.Value != LocalUserPlayingState.NotPlaying; + if (shouldDisable && e.Key == Key.Space && e.SuperPressed) + return true; // handled: don't propagate Space to game + + return base.OnKeyDown(e); + } + } +} diff --git a/osu.Desktop/EzMacOS/SpotlightKey.cs b/osu.Desktop/EzMacOS/SpotlightKey.cs new file mode 100644 index 0000000000..09762ed0ae --- /dev/null +++ b/osu.Desktop/EzMacOS/SpotlightKey.cs @@ -0,0 +1,184 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using osu.Framework.Logging; + +namespace osu.Desktop.EzMacOS +{ + [SupportedOSPlatform("macos")] + public static class SpotlightKey + { + private static IntPtr eventTap; + private static IntPtr runLoopSource; + private static IntPtr runLoopMode; + private static bool isDisabled; + private static EventTapCallback? callbackDelegate; + + private const int kVK_Space = 49; + private const ulong kCGEventFlagMaskCommand = 1UL << 20; + + public static void Disable() + { + if (isDisabled) + return; + + try + { + Logger.Log("Attempting to disable Cmd+Space (Spotlight) via Accessibility tap...", LoggingTarget.Runtime, LogLevel.Debug); + + ulong mask = CGEventMaskBit(CGEventType.KeyDown); + callbackDelegate = OnEventTap; + + eventTap = CGEventTapCreate( + kCGHIDEventTap, + kCGHeadInsertEventTap, + kCGEventTapOptionDefault, + mask, + callbackDelegate, + IntPtr.Zero); + + if (eventTap == IntPtr.Zero) + { + Logger.Log("Failed to create event tap. Ensure Accessibility permission is granted (System Settings → Privacy & Security → Accessibility).", LoggingTarget.Runtime, LogLevel.Error); + return; + } + + runLoopSource = CFMachPortCreateRunLoopSource(IntPtr.Zero, eventTap, 0); + IntPtr runLoop = CFRunLoopGetMain(); + runLoopMode = CFStringCreateWithCString(IntPtr.Zero, "kCFRunLoopDefaultMode", kCFStringEncodingUTF8); + CFRunLoopAddSource(runLoop, runLoopSource, runLoopMode); + + CGEventTapEnable(eventTap, true); + isDisabled = true; + Logger.Log("Cmd+Space (Spotlight) blocking enabled during gameplay.", LoggingTarget.Runtime, LogLevel.Verbose); + } + catch (Exception ex) + { + Logger.Log($"Failed to disable Cmd+Space: {ex.Message}\n{ex.StackTrace}", LoggingTarget.Runtime, LogLevel.Error); + } + } + + public static void Enable() + { + if (!isDisabled) + return; + + try + { + Logger.Log("Re-enabling Cmd+Space (Spotlight)...", LoggingTarget.Runtime, LogLevel.Debug); + + if (eventTap != IntPtr.Zero) + { + CGEventTapEnable(eventTap, false); + CFMachPortInvalidate(eventTap); + } + + if (runLoopSource != IntPtr.Zero) + { + IntPtr runLoop = CFRunLoopGetMain(); + CFRunLoopRemoveSource(runLoop, runLoopSource, runLoopMode); + CFRelease(runLoopSource); + } + + if (runLoopMode != IntPtr.Zero) + CFRelease(runLoopMode); + + eventTap = IntPtr.Zero; + runLoopSource = IntPtr.Zero; + runLoopMode = IntPtr.Zero; + callbackDelegate = null; + + isDisabled = false; + Logger.Log("Cmd+Space (Spotlight) blocking disabled.", LoggingTarget.Runtime, LogLevel.Verbose); + } + catch (Exception ex) + { + Logger.Log($"Failed to enable Cmd+Space: {ex.Message}\n{ex.StackTrace}", LoggingTarget.Runtime, LogLevel.Error); + } + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr EventTapCallback(IntPtr proxy, CGEventType type, IntPtr @event, IntPtr userInfo); + + private static IntPtr OnEventTap(IntPtr proxy, CGEventType type, IntPtr @event, IntPtr userInfo) + { + try + { + if (type == CGEventType.KeyDown) + { + ulong flags = CGEventGetFlags(@event); + long keyCode = CGEventGetIntegerValueField(@event, kCGKeyboardEventKeycode); + + if ((flags & kCGEventFlagMaskCommand) != 0 && keyCode == kVK_Space) + { + Logger.Log("Blocked Cmd+Space via Accessibility tap", LoggingTarget.Runtime, LogLevel.Debug); + return IntPtr.Zero; // swallow event + } + } + } + catch (Exception ex) + { + Logger.Log($"Error in event tap: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + + return @event; + } + + private static ulong CGEventMaskBit(CGEventType type) => 1UL << (int)type; + + private const int kCGHIDEventTap = 0; // HID system-wide events + private const int kCGHeadInsertEventTap = 0; // Insert at head + private const int kCGEventTapOptionDefault = 0; // Listen-only/active + + private const int kCGKeyboardEventKeycode = 9; // Field for keycode + + private const uint kCFStringEncodingUTF8 = 0x08000100; + + #region Native Methods + + [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] + private static extern IntPtr CGEventTapCreate(int tap, int place, int options, ulong eventsOfInterest, EventTapCallback callback, IntPtr userInfo); + + [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] + private static extern void CGEventTapEnable(IntPtr tap, bool enable); + + [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] + private static extern ulong CGEventGetFlags(IntPtr eventRef); + + [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] + private static extern long CGEventGetIntegerValueField(IntPtr eventRef, int field); + + [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] + private static extern void CFMachPortInvalidate(IntPtr port); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern IntPtr CFMachPortCreateRunLoopSource(IntPtr allocator, IntPtr port, Int32 order); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern IntPtr CFRunLoopGetMain(); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern void CFRunLoopAddSource(IntPtr rl, IntPtr source, IntPtr mode); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern void CFRunLoopRemoveSource(IntPtr rl, IntPtr source, IntPtr mode); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, uint encoding); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern void CFRelease(IntPtr cf); + + #endregion + + private enum CGEventType + { + KeyDown = 10, + KeyUp = 11 + } + } +} + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 885ee0620e..9755c9f49f 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -16,6 +16,7 @@ using osu.Framework; using osu.Framework.Logging; using osu.Game.Updater; using osu.Desktop.Windows; +using osu.Desktop.EzMacOS; using osu.Framework.Allocation; using osu.Game.Configuration; using osu.Game.IO; @@ -136,6 +137,9 @@ namespace osu.Desktop if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) LoadComponentAsync(new GameplayWinKeyBlocker(), Add); + if (OperatingSystem.IsMacOS()) + LoadComponentAsync(new GameplaySpotlightBlocker(), Add); + LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 612edb2470..94cea8d45d 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -21,9 +21,9 @@ namespace osu.Desktop public static class Program { #if DEBUG - private const string base_game_name = @"osu-development"; + private const string base_game_name = @"osu-Ez2Lazer-development"; #else - private const string base_game_name = @"osu"; + private const string base_game_name = @"osu-Ez2Lazer"; #endif private static LegacyTcpIpcProvider? legacyIpc; diff --git a/osu.Desktop/Properties/launchSettings.json b/osu.Desktop/Properties/launchSettings.json index 5e768ec9fa..c7cbc0141f 100644 --- a/osu.Desktop/Properties/launchSettings.json +++ b/osu.Desktop/Properties/launchSettings.json @@ -1,7 +1,10 @@ { "profiles": { "osu! Desktop": { - "commandName": "Project" + "commandName": "Project", + "environmentVariables": { + "OSU_SDL3": "1" + } }, "osu! Tournament": { "commandName": "Project", diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index b0c5c953d4..a2e407005e 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -4,10 +4,10 @@ WinExe true A free-to-win rhythm game. Rhythm is just a *click* away! - osu! - osu!(lazer) - osu! - osu!(lazer) + Ez2osu! + osu!(Ez2lazer) + Ez2lazer! + osu!(Ez2lazer) lazer.ico 0.0.0 0.0.0 diff --git a/osu.Game.Rulesets.Mania.Tests/Analysis/HalfHoldBeatmapFactory.cs b/osu.Game.Rulesets.Mania.Tests/Analysis/HalfHoldBeatmapFactory.cs new file mode 100644 index 0000000000..e0e348e6b5 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Analysis/HalfHoldBeatmapFactory.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; + +namespace osu.Game.Rulesets.Mania.Tests.Analysis +{ + public static class HalfHoldBeatmapSets + { + public static IBeatmap[] CreateTen(int columns = 4, int notesPerColumn = 32) + { + // use fixed seeds to produce deterministic but different beatmaps + return Enumerable.Range(0, 10).Select(i => HalfHoldBeatmapFactory.Create(columns, notesPerColumn, 1000 + i)).ToArray(); + } + } + + public static class HalfHoldBeatmapFactory + { + /// + /// Create a Mania beatmap with the specified number of columns and notes per column. + /// Approximately half of the hit objects will be long notes (HoldNote) and half simple notes. + /// + public static IBeatmap Create(int columns = 4, int notesPerColumn = 32, int seed = 1234) + { + var beatmap = new ManiaBeatmap(new StageDefinition(columns)) + { + BeatmapInfo = new BeatmapInfo + { + Ruleset = new ManiaRuleset().RulesetInfo, + Difficulty = new BeatmapDifficulty + { + DrainRate = 6, + OverallDifficulty = 6, + ApproachRate = 6, + CircleSize = columns + } + }, + ControlPointInfo = new ControlPointInfo() + }; + + var rnd = new Random(seed); + + for (int col = 0; col < columns; col++) + { + for (int i = 0; i < notesPerColumn; i++) + { + // spread notes in time with a base spacing and random jitter + double baseTime = col * 2000 + i * 300; + double jitter = rnd.NextDouble() * 200 - 100; // ±100ms + double startTime = baseTime + jitter; + + // randomly decide whether this object is a hold note, target ~50% + bool isHold = (i % 2 == 0); + + if (isHold) + { + var hold = new HoldNote + { + StartTime = startTime, + Column = col, + Duration = 200 + rnd.Next(0, 800) // 200-1000ms + }; + beatmap.HitObjects.Add(hold); + } + else + { + var note = new Note + { + StartTime = startTime, + Column = col + }; + beatmap.HitObjects.Add(note); + } + } + } + + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Analysis/SRCalculatorTunable.cs b/osu.Game.Rulesets.Mania.Tests/Analysis/SRCalculatorTunable.cs new file mode 100644 index 0000000000..67bc1a1629 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Analysis/SRCalculatorTunable.cs @@ -0,0 +1,1126 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania.Analysis; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Mania.Tests.Analysis +{ + public static class SRCalculatorTunable + { + // Tunable parameters (defaults mirror original SRCalculator) + public static class Tunables + { + // keyUsage / anchor + public static double KeyUsageBaseContribution = 3.75; // original base_contribution + public static double KeyUsageDurationCap = 1500; // original clampedDuration cap + public static double KeyUsageExtensionDivisor = 150.0; // original extension divisor + + // LN representation + public static double LNRepHeadBoost = 1.3; + public static double LNRepMidNeg = -0.3; + public static double LNRepTailNeg = -1.0; + public static double LNRepFallbackBase = 2.5; + public static double LNRepFallbackOffsetMult = 0.5; + + // pBar / lnIntegral + public static double PBarLnMultiplier = 6 * 0.001; // 0.006 original + + // rBar / jack penalty + public static double RBarStrengthBase = 0.08; // original + public static double JackPenaltyMultiplier = 35.0; // original + + // final note adjustments + public static double FinalLNLenCap = 1000; // original + public static double FinalLNToNotesFactor = 0.5; // original multiplication + public static double FinalLNLenDivisor = 200.0; // original + public static double TotalNotesOffset = 60.0; // original denominator offset + public static double FinalScale = 0.975; // original + } + + // Public API similar to SRCalculator + public static double CalculateSR(IBeatmap beatmap) + { + return ComputeInternalXxySR(beatmap, 1.0).sr; + } + + public static double CalculateSR(IBeatmap beatmap, double clockRate) + { + return ComputeInternalXxySR(beatmap, clockRate).sr; + } + + private static (double sr, Dictionary times) ComputeInternalXxySR(IBeatmap beatmap, double clockRate = 1.0) + { + var stopwatch = Stopwatch.StartNew(); + ManiaBeatmap maniaBeatmap = (ManiaBeatmap)beatmap; + int keyCount = Math.Max(1, maniaBeatmap.TotalColumns > 0 ? maniaBeatmap.TotalColumns : (int)Math.Round(maniaBeatmap.BeatmapInfo.Difficulty.CircleSize)); + + double sr = XxySRCalculateCoreTunable(maniaBeatmap, keyCount, clockRate); + stopwatch.Stop(); + + var timings = new Dictionary { ["Total"] = stopwatch.ElapsedMilliseconds }; + return (sr, timings); + } + + #region NoteStruct / LNRep + + private readonly record struct NoteStruct + { + public NoteStruct(int column, int headTime, int tailTime) + { + Column = column; + HeadTime = headTime; + TailTime = tailTime; + } + + public int Column { get; } + public int HeadTime { get; } + public int TailTime { get; } + public bool IsLongNote => TailTime >= 0 && TailTime > HeadTime; + } + + private readonly struct LNRepStruct + { + public LNRepStruct(int[] points, double[] cumulative, double[] values) + { + Points = points; + Cumulative = cumulative; + Values = values; + } + + public int[] Points { get; } + public double[] Cumulative { get; } + public double[] Values { get; } + } + + #endregion + + private static readonly Comparison note_comparer = compareNotes; + + // Core: copy of XxySRCalculateCore but using Tunables + public static double XxySRCalculateCoreTunable(ManiaBeatmap maniaBeatmap, int keyCount, double clockRate = 1.0) + { + double[]? cross = CrossMatrixProvider.GetMatrix(keyCount); + if (cross == null || cross[0] == -1) throw new NotSupportedException($"Key mode {keyCount}k is not supported by the SR algorithm."); + + int estimatedNotes = maniaBeatmap.HitObjects.Count; + if (estimatedNotes == 0) return 0.0; + + var notes = new List(estimatedNotes); + var notesByColumn = new List[keyCount]; + for (int i = 0; i < keyCount; i++) notesByColumn[i] = new List(estimatedNotes / keyCount + 1); + + foreach (var hitObject in maniaBeatmap.HitObjects) + { + int column = Math.Clamp(hitObject.Column, 0, keyCount - 1); + int head = (int)Math.Round(hitObject.StartTime / clockRate); + int tail = (int)Math.Round(hitObject.GetEndTime() / clockRate); + if ((hitObject as IHasDuration)?.EndTime == null) tail = -1; + if (tail <= head) tail = -1; + var note = new NoteStruct(column, head, tail); + notes.Add(note); + notesByColumn[column].Add(note); + } + + notes.Sort(note_comparer); + foreach (var colNotes in notesByColumn) colNotes.Sort(note_comparer); + + var longNotes = notes.Where(n => n.IsLongNote).ToList(); + var longNotesByTails = longNotes.OrderBy(n => n.TailTime).ToList(); + + double od = maniaBeatmap.BeatmapInfo.Difficulty.OverallDifficulty; + double x = computeHitLeniency(od); + + int maxHead = notes.Max(n => n.HeadTime); + int maxTail = longNotes.Count > 0 ? longNotes.Max(n => n.TailTime) : maxHead; + int totalTime = Math.Max(maxHead, maxTail) + 1; + + (double[] allCorners, double[] baseCorners, double[] aCorners) = buildCorners(totalTime, notes); + bool[][] keyUsage = buildKeyUsage(keyCount, totalTime, notes, baseCorners); + int[][] activeColumns = deriveActiveColumns(keyUsage); + + double[][] keyUsage400 = buildKeyUsage400_tunable(keyCount, totalTime, notes, baseCorners); + double[] anchorBase = computeAnchor(keyCount, keyUsage400, baseCorners); + + LNRepStruct? lnRep = longNotes.Count > 0 ? buildLNRepresentation_tunable(longNotes, totalTime) : null; + + (double[][] deltaKs, double[] jBarBase) = computeJBar(keyCount, totalTime, x, notesByColumn, baseCorners); + double[] jBar = interpValues(allCorners, baseCorners, jBarBase); + + double[] xBarBase = computeXBar(keyCount, totalTime, x, notesByColumn, activeColumns, baseCorners, cross); + double[] xBar = interpValues(allCorners, baseCorners, xBarBase); + + double[] pBarBase = computePBar_tunable(keyCount, totalTime, x, notes, lnRep, anchorBase, baseCorners); + double[] pBar = interpValues(allCorners, baseCorners, pBarBase); + + double[] aBarBase = computeABar(keyCount, totalTime, deltaKs, activeColumns, aCorners, baseCorners); + double[] aBar = interpValues(allCorners, aCorners, aBarBase); + + double[] rBarBase = computeRBar_tunable(keyCount, totalTime, x, notesByColumn, longNotesByTails, baseCorners); + double[] rBar = interpValues(allCorners, baseCorners, rBarBase); + + (double[] cStep, double[] ksStep) = computeCAndKs(keyCount, notes, keyUsage, baseCorners); + double[] cArr = stepInterp(allCorners, baseCorners, cStep); + double[] ksArr = stepInterp(allCorners, baseCorners, ksStep); + + double[] gaps = computeGaps(allCorners); + double[] effectiveWeights = new double[allCorners.Length]; + for (int i = 0; i < allCorners.Length; i++) effectiveWeights[i] = cArr[i] * gaps[i]; + + double[] dAll = new double[allCorners.Length]; + + Parallel.For(0, allCorners.Length, i => + { + double abarExponent = 3.0 / Math.Max(ksArr[i], 1e-6); + double abarPow = aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], abarExponent); + double minCandidateContribution = 0.85 * jBar[i]; + double minCandidate = 8 + minCandidateContribution; + double minJ = Math.Min(jBar[i], minCandidate); + double jackComponent = abarPow * minJ; + double term1 = 0.4 * (jackComponent <= 0 ? 0 : Math.Pow(jackComponent, 1.5)); + + double scaledP = 0.8 * pBar[i]; + double jackPenalty = rBar[i] * Tunables.JackPenaltyMultiplier; + double ratio = jackPenalty / (cArr[i] + 8); + double pComponent = scaledP + ratio; + double powerBase = (aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], 2.0 / 3.0)) * pComponent; + double term2 = 0.6 * (powerBase <= 0 ? 0 : Math.Pow(powerBase, 1.5)); + + double sumTerms = term1 + term2; + double s = sumTerms <= 0 ? 0 : Math.Pow(sumTerms, 2.0 / 3.0); + double numerator = abarPow * xBar[i]; + double denominator = xBar[i] + s + 1; + double tValue = denominator <= 0 ? 0 : numerator / denominator; + double sqrtComponent = Math.Sqrt(Math.Max(s, 0)); + double primaryImpact = 2.7 * sqrtComponent * (tValue <= 0 ? 0 : Math.Pow(tValue, 1.5)); + double secondaryImpact = s * 0.27; + + dAll[i] = primaryImpact + secondaryImpact; + }); + + double sr = finaliseDifficulty_tunable(dAll.ToList(), effectiveWeights.ToList(), notes, longNotes); + return sr; + } + + #region Tunable helpers (variants of original methods) + + private static double[][] buildKeyUsage400_tunable(int keyCount, int totalTime, List notes, double[] baseCorners) + { + double[][] usage = new double[keyCount][]; + for (int k = 0; k < keyCount; k++) usage[k] = new double[baseCorners.Length]; + + double base_contribution = Tunables.KeyUsageBaseContribution; + double falloff = Tunables.KeyUsageBaseContribution / (400.0 * 400.0); + + foreach (var note in notes) + { + int startTime = Math.Max(note.HeadTime, 0); + int endTime = note.IsLongNote ? Math.Min(note.TailTime, totalTime - 1) : note.HeadTime; + + int left400 = lowerBound(baseCorners, startTime - 400); + int left = lowerBound(baseCorners, startTime); + int right = lowerBound(baseCorners, endTime); + int right400 = lowerBound(baseCorners, endTime + 400); + + int duration = endTime - startTime; + double clampedDuration = Math.Min(duration, Tunables.KeyUsageDurationCap); + double extension = clampedDuration / Tunables.KeyUsageExtensionDivisor; + double contribution = base_contribution + extension; + + for (int idx = left; idx < right; idx++) usage[note.Column][idx] += contribution; + + for (int idx = left400; idx < left; idx++) + { + double offset = baseCorners[idx] - startTime; + double falloffContribution = falloff * Math.Pow(offset, 2); + double value = base_contribution - falloffContribution; + double clamped = Math.Max(value, 0); + usage[note.Column][idx] += clamped; + } + + for (int idx = right; idx < right400; idx++) + { + double offset = baseCorners[idx] - endTime; + double falloffContribution = falloff * Math.Pow(offset, 2); + double value = base_contribution - falloffContribution; + double clamped = Math.Max(value, 0); + usage[note.Column][idx] += clamped; + } + } + + return usage; + } + + private static double[] computeAnchor(int keyCount, double[][] keyUsage400, double[] baseCorners) + { + double[] anchor = new double[baseCorners.Length]; + + for (int i = 0; i < baseCorners.Length; i++) + { + double[] counts = new double[keyCount]; + for (int k = 0; k < keyCount; k++) + counts[k] = keyUsage400[k][i]; + + Array.Sort(counts); + Array.Reverse(counts); + + double[] nonZero = counts.Where(c => c > 0).ToArray(); + + if (nonZero.Length <= 1) + { + anchor[i] = 0; + continue; + } + + double walk = 0; + double maxWalk = 0; + + for (int idx = 0; idx < nonZero.Length - 1; idx++) + { + double current = nonZero[idx]; + double next = nonZero[idx + 1]; + double ratio = next / current; + double offset = 0.5 - ratio; + double offsetPenalty = 4 * Math.Pow(offset, 2); + double damping = 1 - offsetPenalty; + walk += current * damping; + maxWalk += current; + } + + double value = maxWalk <= 0 ? 0 : walk / maxWalk; + anchor[i] = 1 + Math.Min(value - 0.18, 5 * Math.Pow(value - 0.22, 3)); + } + + return anchor; + } + + private static LNRepStruct buildLNRepresentation_tunable(List longNotes, int totalTime) + { + var diff = new Dictionary(); + + foreach (var note in longNotes) + { + int t0 = Math.Min(note.HeadTime + 60, note.TailTime); + int t1 = Math.Min(note.HeadTime + 120, note.TailTime); + + addToMap(diff, t0, Tunables.LNRepHeadBoost); + addToMap(diff, t1, Tunables.LNRepMidNeg); + addToMap(diff, note.TailTime, Tunables.LNRepTailNeg); + } + + var pointsSet = new SortedSet { 0, totalTime }; + foreach (int key in diff.Keys) pointsSet.Add(key); + + int[] points = pointsSet.ToArray(); + double[] cumulative = new double[points.Length]; + double[] values = new double[points.Length - 1]; + + double current = 0; + + for (int i = 0; i < points.Length - 1; i++) + { + if (diff.TryGetValue(points[i], out double delta)) current += delta; + + double fallbackOffset = Tunables.LNRepFallbackOffsetMult * current; + double fallback = Tunables.LNRepFallbackBase + fallbackOffset; + double transformed = Math.Min(current, fallback); + values[i] = transformed; + + int length = points[i + 1] - points[i]; + double segment = length * transformed; + cumulative[i + 1] = cumulative[i] + segment; + } + + return new LNRepStruct(points, cumulative, values); + } + + private static double[] computePBar_tunable(int keyCount, int totalTime, double x, List notes, LNRepStruct? lnRep, double[] anchor, double[] baseCorners) + { + double[] pStep = new double[baseCorners.Length]; + + for (int i = 0; i < notes.Count - 1; i++) + { + var leftNote = notes[i]; + var rightNote = notes[i + 1]; + int deltaTime = rightNote.HeadTime - leftNote.HeadTime; + + if (deltaTime <= 0) + { + double invX = 1.0 / Math.Max(x, 1e-6); + double spikeInnerBase = 4 * invX; + double spikeInner = spikeInnerBase - 24; + double spikeBase = 0.02 * spikeInner; + if (spikeBase <= 0) continue; + + double spikeMagnitude = Math.Pow(spikeBase, 0.25); + double spike = 1000 * spikeMagnitude; + int leftIdx = lowerBound(baseCorners, leftNote.HeadTime); + int rightIdx = upperBound(baseCorners, leftNote.HeadTime); + for (int idx = leftIdx; idx < rightIdx; idx++) pStep[idx] += spike; + continue; + } + + int left = lowerBound(baseCorners, leftNote.HeadTime); + int right = lowerBound(baseCorners, rightNote.HeadTime); + if (right <= left) continue; + + double delta = 0.001 * deltaTime; + double v = 1; + if (lnRep.HasValue) v += Tunables.PBarLnMultiplier * lnIntegral(lnRep.Value, leftNote.HeadTime, rightNote.HeadTime); + + double booster = streamBooster(delta); + double effective = Math.Max(booster, v); + + double inc; + + if (delta < 2 * x / 3) + { + double invX = 1.0 / Math.Max(x, 1e-6); + double halfX = x / 2.0; + double deltaCentre = delta - halfX; + double deltaTerm = 24 * invX * Math.Pow(deltaCentre, 2); + double inner = 0.08 * invX * (1 - deltaTerm); + double innerClamp = Math.Max(inner, 0); + double magnitude = Math.Pow(innerClamp, 0.25); + inc = magnitude / Math.Max(delta, 1e-6) * effective; + } + else + { + double invX = 1.0 / Math.Max(x, 1e-6); + double centreTerm = Math.Pow(x / 6.0, 2); + double deltaTerm = 24 * invX * centreTerm; + double inner = 0.08 * invX * (1 - deltaTerm); + double innerClamp = Math.Max(inner, 0); + double magnitude = Math.Pow(innerClamp, 0.25); + inc = magnitude / Math.Max(delta, 1e-6) * effective; + } + + for (int idx = left; idx < right; idx++) + { + double doubled = inc * 2; + double limit = Math.Max(inc, doubled - 10); + double anchored = inc * anchor[idx]; + double contribution = Math.Min(anchored, limit); + pStep[idx] += contribution; + } + } + + return SmoothOnCorners(baseCorners, pStep, 500, 0.001, SmoothMode.Sum); + } + + private static double[] computeRBar_tunable(int keyCount, int totalTime, double x, List[] notesByColumn, List tailNotes, double[] baseCorners) + { + if (tailNotes.Count < 2) return new double[baseCorners.Length]; + + double[] iList = new double[tailNotes.Count]; + + for (int idx = 0; idx < tailNotes.Count; idx++) + { + var note = tailNotes[idx]; + var next = findNextColumnNote(note, notesByColumn); + double nextHead = next?.HeadTime ?? 1_000_000_000; + + double ih = 0.001 * Math.Abs(note.TailTime - note.HeadTime - 80) / Math.Max(x, 1e-6); + double it = 0.001 * Math.Abs(nextHead - note.TailTime - 80) / Math.Max(x, 1e-6); + + iList[idx] = 2 / (2 + Math.Exp(-5 * (ih - 0.75)) + Math.Exp(-5 * (it - 0.75))); + } + + double[] rStep = new double[baseCorners.Length]; + + for (int idx = 0; idx < tailNotes.Count - 1; idx++) + { + var current = tailNotes[idx]; + var next = tailNotes[idx + 1]; + int left = lowerBound(baseCorners, current.TailTime); + int right = lowerBound(baseCorners, next.TailTime); + if (right <= left) continue; + + double delta = 0.001 * Math.Max(next.TailTime - current.TailTime, 1e-6); + double invSqrtDelta = Math.Pow(delta, -0.5); + double invX = 1.0 / Math.Max(x, 1e-6); + double blend = iList[idx] + iList[idx + 1]; + double blendContribution = 0.8 * blend; + double modulation = 1 + blendContribution; + double strength = Tunables.RBarStrengthBase * invSqrtDelta * invX * modulation; + + for (int baseIdx = left; baseIdx < right; baseIdx++) rStep[baseIdx] = Math.Max(rStep[baseIdx], strength); + } + + return SmoothOnCorners(baseCorners, rStep, 500, 0.001, SmoothMode.Sum); + } + + private static double finaliseDifficulty_tunable(List difficulties, List weights, List notes, List longNotes) + { + // reuse original finaliseDifficulty logic but replace LN-related constants + var combined = difficulties.Zip(weights, (d, w) => (d, w)).OrderBy(pair => pair.d).ToList(); + if (combined.Count == 0) return 0; + + double[] sortedD = combined.Select(p => p.d).ToArray(); + double[] sortedWeights = combined.Select(p => Math.Max(p.w, 0)).ToArray(); + + double[] cumulative = new double[sortedWeights.Length]; + cumulative[0] = sortedWeights[0]; + for (int i = 1; i < sortedWeights.Length; i++) cumulative[i] = cumulative[i - 1] + sortedWeights[i]; + + double totalWeight = Math.Max(cumulative[^1], 1e-9); + double[] norm = cumulative.Select(v => v / totalWeight).ToArray(); + + double[] targets = { 0.945, 0.935, 0.925, 0.915, 0.845, 0.835, 0.825, 0.815 }; + double percentile93 = 0; + double percentile83 = 0; + + for (int i = 0; i < 4; i++) + { + int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1); + percentile93 += sortedD[index]; + } + + percentile93 /= 4.0; + + for (int i = 4; i < 8; i++) + { + int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1); + percentile83 += sortedD[index]; + } + + percentile83 /= 4.0; + + double weightedMeanNumerator = 0; + for (int i = 0; i < sortedD.Length; i++) weightedMeanNumerator += Math.Pow(sortedD[i], 5) * sortedWeights[i]; + double weightedMean = Math.Pow(Math.Max(weightedMeanNumerator / totalWeight, 0), 0.2); + + double topComponent = 0.25 * 0.88 * percentile93; + double middleComponent = 0.2 * 0.94 * percentile83; + double meanComponent = 0.55 * weightedMean; + double sr = topComponent + middleComponent + meanComponent; + sr = Math.Pow(sr, 1.0) / Math.Pow(8, 1.0) * 8; + + double totalNotes = notes.Count; + + foreach (var ln in longNotes) + { + double len = Math.Min(ln.TailTime - ln.HeadTime, Tunables.FinalLNLenCap); + totalNotes += Tunables.FinalLNToNotesFactor * (len / Tunables.FinalLNLenDivisor); + } + + sr *= totalNotes / (totalNotes + Tunables.TotalNotesOffset); + sr = rescaleHigh(sr); + sr *= Tunables.FinalScale; + + return sr; + } + + #endregion + + #region Reused helper methods (copied from original) + + private static double computeHitLeniency(double overallDifficulty) + { + double leniency = 0.3 * Math.Sqrt((64.5 - Math.Ceiling(overallDifficulty * 3.0)) / 500.0); + double offset = leniency - 0.09; + double scaledOffset = 0.6 * offset; + double adjustedWindow = scaledOffset + 0.09; + return Math.Min(leniency, adjustedWindow); + } + + private static (double[] allCorners, double[] baseCorners, double[] aCorners) buildCorners(int totalTime, List notes) + { + var baseSet = new HashSet(); + + foreach (var note in notes) + { + baseSet.Add(note.HeadTime); + if (note.IsLongNote) baseSet.Add(note.TailTime); + } + + foreach (int value in baseSet.ToArray()) + { + baseSet.Add(value + 501); + baseSet.Add(value - 499); + baseSet.Add(value + 1); + } + + baseSet.Add(0); + baseSet.Add(totalTime); + double[] baseCorners = baseSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray(); + + var aSet = new HashSet(); + + foreach (var note in notes) + { + aSet.Add(note.HeadTime); + if (note.IsLongNote) aSet.Add(note.TailTime); + } + + foreach (int value in aSet.ToArray()) + { + aSet.Add(value + 1000); + aSet.Add(value - 1000); + } + + aSet.Add(0); + aSet.Add(totalTime); + double[] aCorners = aSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray(); + + double[] allCorners = baseCorners.Concat(aCorners).Distinct().OrderBy(v => v).ToArray(); + return (allCorners, baseCorners, aCorners); + } + + private static bool[][] buildKeyUsage(int keyCount, int totalTime, List notes, double[] baseCorners) + { + bool[][] keyUsage = new bool[keyCount][]; + for (int i = 0; i < keyCount; i++) keyUsage[i] = new bool[baseCorners.Length]; + + foreach (var note in notes) + { + int start = Math.Max(note.HeadTime - 150, 0); + int end = note.IsLongNote ? Math.Min(note.TailTime + 150, totalTime - 1) : Math.Min(note.HeadTime + 150, totalTime - 1); + int left = lowerBound(baseCorners, start); + int right = lowerBound(baseCorners, end); + for (int idx = left; idx < right; idx++) keyUsage[note.Column][idx] = true; + } + + return keyUsage; + } + + private static int[][] deriveActiveColumns(bool[][] keyUsage) + { + int length = keyUsage[0].Length; + int[][] active = new int[length][]; + + for (int i = 0; i < length; i++) + { + var list = new List(); + + for (int col = 0; col < keyUsage.Length; col++) + { + if (keyUsage[col][i]) + list.Add(col); + } + + active[i] = list.ToArray(); + } + + return active; + } + + private static (double[][] deltaKs, double[] jBar) computeJBar(int keyCount, int totalTime, double x, List[] notesByColumn, double[] baseCorners) + { + const double default_delta = 1e9; + double[][] deltaKs = new double[keyCount][]; + double[][] jKs = new double[keyCount][]; + Parallel.For(0, keyCount, k => + { + deltaKs[k] = Enumerable.Repeat(default_delta, baseCorners.Length).ToArray(); + jKs[k] = new double[baseCorners.Length]; + var columnNotes = notesByColumn[k]; + + for (int i = 0; i < columnNotes.Count - 1; i++) + { + var current = columnNotes[i]; + var next = columnNotes[i + 1]; + int left = lowerBound(baseCorners, current.HeadTime); + int right = lowerBound(baseCorners, next.HeadTime); + if (right <= left) continue; + + double headGap = Math.Max(next.HeadTime - current.HeadTime, 1e-6); + double delta = 0.001 * headGap; + double deltaShift = Math.Abs(delta - 0.08); + double penalty = 0.15 + deltaShift; + double attenuation = Math.Pow(penalty, -4); + double nerfFactor = 7e-5 * attenuation; + double jackNerfer = 1 - nerfFactor; + double xRoot = Math.Pow(x, 0.25); + double rootScale = 0.11 * xRoot; + double jackBase = delta + rootScale; + double inverseJack = Math.Pow(jackBase, -1); + double inverseDelta = 1.0 / delta; + double value = inverseDelta * inverseJack * jackNerfer; + + for (int idx = left; idx < right; idx++) + { + deltaKs[k][idx] = Math.Min(deltaKs[k][idx], delta); + jKs[k][idx] = value; + } + } + + jKs[k] = SmoothOnCorners(baseCorners, jKs[k], 500, 0.001, SmoothMode.Sum); + }); + + double[] jBar = new double[baseCorners.Length]; + + for (int idx = 0; idx < baseCorners.Length; idx++) + { + double numerator = 0; + double denominator = 0; + + for (int k = 0; k < keyCount; k++) + { + double v = Math.Max(jKs[k][idx], 0); + double weight = 1.0 / Math.Max(deltaKs[k][idx], 1e-9); + numerator += Math.Pow(v, 5) * weight; + denominator += weight; + } + + double combined = denominator <= 0 ? 0 : numerator / denominator; + jBar[idx] = Math.Pow(Math.Max(combined, 0), 0.2); + } + + return (deltaKs, jBar); + } + + private static double[] computeXBar(int keyCount, int totalTime, double x, List[] notesByColumn, int[][] activeColumns, double[] baseCorners, double[] cross) + { + double[][] xKs = new double[keyCount + 1][]; + double[][] fastCross = new double[keyCount + 1][]; + + for (int i = 0; i < xKs.Length; i++) + { + xKs[i] = new double[baseCorners.Length]; + fastCross[i] = new double[baseCorners.Length]; + } + + Parallel.For(0, keyCount + 1, k => + { + var pair = new List(); + + if (k == 0) pair.AddRange(notesByColumn[0]); + else if (k == keyCount) pair.AddRange(notesByColumn[keyCount - 1]); + else + { + pair.AddRange(notesByColumn[k - 1]); + pair.AddRange(notesByColumn[k]); + } + + pair.Sort(note_comparer); + if (pair.Count < 2) return; + + for (int i = 1; i < pair.Count; i++) + { + var prev = pair[i - 1]; + var current = pair[i]; + int left = lowerBound(baseCorners, prev.HeadTime); + int right = lowerBound(baseCorners, current.HeadTime); + if (right <= left) continue; + + double delta = 0.001 * Math.Max(current.HeadTime - prev.HeadTime, 1e-6); + double val = 0.16 * Math.Pow(Math.Max(x, delta), -2); + int idxStart = Math.Min(left, baseCorners.Length - 1); + int idxEnd = Math.Min(Math.Max(right, 0), baseCorners.Length - 1); + bool condition1 = !contains(activeColumns[idxStart], k - 1) && !contains(activeColumns[idxEnd], k - 1); + bool condition2 = !contains(activeColumns[idxStart], k) && !contains(activeColumns[idxEnd], k); + if (condition1 || condition2) val *= 1 - cross[Math.Min(k, cross.Length - 1)]; + + for (int idx = left; idx < right; idx++) + { + xKs[k][idx] = val; + fastCross[k][idx] = Math.Max(0, 0.4 * Math.Pow(Math.Max(Math.Max(delta, 0.06), 0.75 * x), -2) - 80); + } + } + }); + + double[] xBase = new double[baseCorners.Length]; + + for (int idx = 0; idx < baseCorners.Length; idx++) + { + double sum = 0; + for (int k = 0; k <= keyCount; k++) sum += cross[Math.Min(k, cross.Length - 1)] * xKs[k][idx]; + + for (int k = 0; k < keyCount; k++) + { + double leftVal = fastCross[k][idx] * cross[Math.Min(k, cross.Length - 1)]; + double rightVal = fastCross[k + 1][idx] * cross[Math.Min(k + 1, cross.Length - 1)]; + sum += Math.Sqrt(Math.Max(leftVal * rightVal, 0)); + } + + xBase[idx] = sum; + } + + return SmoothOnCorners(baseCorners, xBase, 500, 0.001, SmoothMode.Sum); + } + + // many helper methods reused without modification + private static double lnIntegral(LNRepStruct repStruct, int a, int b) + { + int[] points = repStruct.Points; + double[] cumulative = repStruct.Cumulative; + double[] values = repStruct.Values; + int startIndex = upperBound(points, a) - 1; + int endIndex = upperBound(points, b) - 1; + if (startIndex < 0) startIndex = 0; + if (endIndex < startIndex) endIndex = startIndex; + double total = 0; + + if (startIndex == endIndex) total = (b - a) * values[startIndex]; + else + { + total += (points[startIndex + 1] - a) * values[startIndex]; + total += cumulative[endIndex] - cumulative[startIndex + 1]; + total += (b - points[endIndex]) * values[endIndex]; + } + + return total; + } + + private static double[] computeABar(int keyCount, int totalTime, double[][] deltaKs, int[][] activeColumns, double[] aCorners, double[] baseCorners) + { + double[] aStep = Enumerable.Repeat(1.0, aCorners.Length).ToArray(); + + for (int i = 0; i < aCorners.Length; i++) + { + int idx = lowerBound(baseCorners, aCorners[i]); + idx = Math.Min(idx, baseCorners.Length - 1); + int[] cols = activeColumns[idx]; + if (cols.Length < 2) continue; + + for (int j = 0; j < cols.Length - 1; j++) + { + int c0 = cols[j]; + int c1 = cols[j + 1]; + double deltaGap = Math.Abs(deltaKs[c0][idx] - deltaKs[c1][idx]); + double maxDelta = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]); + double offset = Math.Max(maxDelta - 0.11, 0); + double offsetContribution = 0.4 * offset; + double diff = deltaGap + offsetContribution; + + if (diff < 0.02) + { + double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]); + double factorContribution = 0.5 * factorBase; + double factor = 0.75 + factorContribution; + aStep[i] *= Math.Min(factor, 1); + } + else if (diff < 0.07) + { + double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]); + double growth = 5 * diff; + double factorContribution = 0.5 * factorBase; + double factor = 0.65 + growth + factorContribution; + aStep[i] *= Math.Min(factor, 1); + } + } + } + + return SmoothOnCorners(aCorners, aStep, 250, 0, SmoothMode.Average); + } + + private static (double[] cStep, double[] ksStep) computeCAndKs(int keyCount, List notes, bool[][] keyUsage, double[] baseCorners) + { + double[] cStep = new double[baseCorners.Length]; + double[] ksStep = new double[baseCorners.Length]; + var noteTimesList = new List(notes.Count); + foreach (var note in notes) noteTimesList.Add(note.HeadTime); + noteTimesList.Sort(); + double[] noteTimes = noteTimesList.ToArray(); + + for (int idx = 0; idx < baseCorners.Length; idx++) + { + double left = baseCorners[idx] - 500; + double right = baseCorners[idx] + 500; + int leftIndex = lowerBound(noteTimes, left); + int rightIndex = lowerBound(noteTimes, right); + cStep[idx] = Math.Max(rightIndex - leftIndex, 0); + int activeCount = 0; + + for (int col = 0; col < keyCount; col++) + { + if (keyUsage[col][idx]) + activeCount++; + } + + ksStep[idx] = Math.Max(activeCount, 1); + } + + return (cStep, ksStep); + } + + private static double[] computeGaps(double[] corners) + { + if (corners.Length == 0) return Array.Empty(); + + double[] gaps = new double[corners.Length]; + + if (corners.Length == 1) + { + gaps[0] = 0; + return gaps; + } + + gaps[0] = (corners[1] - corners[0]) / 2.0; + gaps[^1] = (corners[^1] - corners[^2]) / 2.0; + for (int i = 1; i < corners.Length - 1; i++) gaps[i] = (corners[i + 1] - corners[i - 1]) / 2.0; + return gaps; + } + + private static NoteStruct? findNextColumnNote(NoteStruct note, List[] notesByColumn) + { + var columnNotes = notesByColumn[note.Column]; + int index = columnNotes.IndexOf(note); + if (index >= 0 && index + 1 < columnNotes.Count) return columnNotes[index + 1]; + + return null; + } + + private static double[] interpValues(double[] newX, double[] oldX, double[] oldVals) + { + double[] result = new double[newX.Length]; + + for (int i = 0; i < newX.Length; i++) + { + double x = newX[i]; + + if (x <= oldX[0]) + { + result[i] = oldVals[0]; + continue; + } + + if (x >= oldX[^1]) + { + result[i] = oldVals[^1]; + continue; + } + + int idx = lowerBound(oldX, x); + + if (idx < oldX.Length && nearlyEquals(oldX[idx], x)) + { + result[i] = oldVals[idx]; + continue; + } + + int prev = Math.Max(idx - 1, 0); + double x0 = oldX[prev]; + double x1 = oldX[idx]; + double y0 = oldVals[prev]; + double y1 = oldVals[idx]; + double deltaY = y1 - y0; + double deltaX = x - x0; + double numerator = deltaY * deltaX; + double fraction = numerator / (x1 - x0); + result[i] = y0 + fraction; + } + + return result; + } + + private static double[] stepInterp(double[] newX, double[] oldX, double[] oldVals) + { + double[] result = new double[newX.Length]; + + for (int i = 0; i < newX.Length; i++) + { + int idx = upperBound(oldX, newX[i]) - 1; + if (idx < 0) idx = 0; + result[i] = oldVals[Math.Min(idx, oldVals.Length - 1)]; + } + + return result; + } + + private static double[] SmoothOnCorners(double[] positions, double[] values, double window, double scale, SmoothMode mode) + { + if (positions.Length == 0) return Array.Empty(); + + double[] cumulative = buildCumulative(positions, values); + double[] output = new double[positions.Length]; + + for (int i = 0; i < positions.Length; i++) + { + double s = positions[i]; + double a = Math.Max(s - window, positions[0]); + double b = Math.Min(s + window, positions[^1]); + + if (b <= a) + { + output[i] = 0; + continue; + } + + double integral = queryIntegral(positions, cumulative, values, b) - queryIntegral(positions, cumulative, values, a); + if (mode == SmoothMode.Average) output[i] = integral / Math.Max(b - a, 1e-9); + else output[i] = integral * scale; + } + + return output; + } + + private static double[] buildCumulative(double[] positions, double[] values) + { + double[] cumulative = new double[positions.Length]; + + for (int i = 1; i < positions.Length; i++) + { + double width = positions[i] - positions[i - 1]; + double increment = values[i - 1] * width; + cumulative[i] = cumulative[i - 1] + increment; + } + + return cumulative; + } + + private static double queryIntegral(double[] positions, double[] cumulative, double[] values, double point) + { + if (point <= positions[0]) return 0; + + if (point >= positions[^1]) return cumulative[^1]; + + int idx = lowerBound(positions, point); + if (idx < positions.Length && nearlyEquals(positions[idx], point)) return cumulative[idx]; + + int prev = Math.Max(idx - 1, 0); + double delta = point - positions[prev]; + double contribution = values[prev] * delta; + return cumulative[prev] + contribution; + } + + private static double streamBooster(double delta) + { + double inv = 7.5 / Math.Max(delta, 1e-6); + if (inv <= 160 || inv >= 360) return 1; + + double shifted = inv - 160; + double distance = inv - 360; + double adjustment = 1.7e-7 * shifted * Math.Pow(distance, 2); + return 1 + adjustment; + } + + private static bool contains(int[] array, int target) + { + if (target < 0) return false; + + for (int i = 0; i < array.Length; i++) + { + if (array[i] == target) + return true; + } + + return false; + } + + private static int lowerBound(double[] array, double value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] < value) left = mid + 1; + else right = mid; + } + + return left; + } + + private static int lowerBound(double[] array, int value) => lowerBound(array, (double)value); + + private static int lowerBound(int[] array, double value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] < value) left = mid + 1; + else right = mid; + } + + return left; + } + + private static int upperBound(int[] array, int value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] <= value) left = mid + 1; + else right = mid; + } + + return left; + } + + private static int upperBound(double[] array, double value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] <= value) left = mid + 1; + else right = mid; + } + + return left; + } + + private static int bisectLeft(double[] array, double value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] < value) left = mid + 1; + else right = mid; + } + + return left; + } + + private static double safePow(double value, double exponent) + { + if (value <= 0) return 0; + + return Math.Pow(value, exponent); + } + + private static double rescaleHigh(double sr) + { + double excess = sr - 9; + double normalized = excess / 1.2; + double softened = 9 + normalized; + return sr <= 9 ? sr : softened; + } + + private static int clamp(int value, int min, int max) { return Math.Min(Math.Max(value, min), max); } + + private static bool nearlyEquals(double a, double b, double epsilon = 1e-9) { return Math.Abs(a - b) <= epsilon; } + + private static int compareNotes(NoteStruct a, NoteStruct b) + { + int headCompare = a.HeadTime.CompareTo(b.HeadTime); + return headCompare != 0 ? headCompare : a.Column.CompareTo(b.Column); + } + + private static void addToMap(Dictionary map, int key, double value) + { + if (!map.TryAdd(key, value)) map[key] += value; + } + + private enum SmoothMode { Sum, Average } + + #endregion + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Analysis/TestSceneSRTune.cs b/osu.Game.Rulesets.Mania.Tests/Analysis/TestSceneSRTune.cs new file mode 100644 index 0000000000..33c461cc83 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Analysis/TestSceneSRTune.cs @@ -0,0 +1,259 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania.Analysis; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Analysis +{ + public partial class TestSceneSRTune : OsuTestScene + { + private FillFlowContainer listContainer = null!; + private Dictionary last = new Dictionary(); + private Dictionary current = new Dictionary(); + + // exposed tuning parameters (initialized from Tunables defaults) + private double ln_weight = SRCalculatorTunable.Tunables.FinalLNToNotesFactor; // 0.0 - 1.0 + private double ln_len_cap = SRCalculatorTunable.Tunables.FinalLNLenCap; // ms, 100 - 2000 + private double totalnotes_offset = SRCalculatorTunable.Tunables.TotalNotesOffset; // 0 - 500 + private double pbar_ln_coeff = SRCalculatorTunable.Tunables.PBarLnMultiplier; // 0.0 - 0.02 + private double jack_multiplier = SRCalculatorTunable.Tunables.JackPenaltyMultiplier; // 10 - 40 + private double final_scale = SRCalculatorTunable.Tunables.FinalScale; // 0.9 - 1.05 + + private IBeatmap[] sampleBeatmaps = Array.Empty(); + + // storage for last/two-setting snapshots + private (double lnWeight, double lnLenCap, double offset, double pbarCoeff, double jackMult, double scale) lastSettings; + + public TestSceneSRTune() + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(6f), + Padding = new MarginPadding(10), + Children = new[] + { + // control row is exposed as test steps rather than placed into the scene hierarchy + listContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(4f), + AutoSizeAxes = Axes.Y + } + } + }; + + loadSampleBeatmaps(); + + // register test steps for sliders and button controls (appear in the test steps UI) + AddSliderStep("LN Weight", 0, 1, SRCalculatorTunable.Tunables.FinalLNToNotesFactor, v => SRCalculatorTunable.Tunables.FinalLNToNotesFactor = v); + AddSliderStep("LN Len Cap", 100, 2000, SRCalculatorTunable.Tunables.FinalLNLenCap, v => SRCalculatorTunable.Tunables.FinalLNLenCap = v); + AddSliderStep("Offset", 0, 500, SRCalculatorTunable.Tunables.TotalNotesOffset, v => SRCalculatorTunable.Tunables.TotalNotesOffset = v); + AddSliderStep("pBar LN coeff", 0, 0.02, SRCalculatorTunable.Tunables.PBarLnMultiplier, v => SRCalculatorTunable.Tunables.PBarLnMultiplier = v); + AddSliderStep("Jack mult", 10, 40, SRCalculatorTunable.Tunables.JackPenaltyMultiplier, v => SRCalculatorTunable.Tunables.JackPenaltyMultiplier = v); + AddSliderStep("Final scale", 0.9, 1.05, SRCalculatorTunable.Tunables.FinalScale, v => SRCalculatorTunable.Tunables.FinalScale = v); + + // save current settings to "last" snapshot + AddStep("Save as last settings", () => + { + lastSettings = (SRCalculatorTunable.Tunables.FinalLNToNotesFactor, SRCalculatorTunable.Tunables.FinalLNLenCap, SRCalculatorTunable.Tunables.TotalNotesOffset, SRCalculatorTunable.Tunables.PBarLnMultiplier, SRCalculatorTunable.Tunables.JackPenaltyMultiplier, SRCalculatorTunable.Tunables.FinalScale); + // also snapshot last SR values from current + foreach (string k in current.Keys) + last[k] = current[k]; + }); + + // update every 50ms + Scheduler.AddDelayed(updateAllSR, 50, true); + } + + private IBeatmap[] createSyntheticSamples() + { + var list = new List(); + + for (int i = 0; i < 8; i++) + { + var bm = new ManiaBeatmap(new StageDefinition(4)); + bm.BeatmapInfo = new BeatmapInfo { Metadata = new BeatmapMetadata { Title = $"Sample {i + 1}" } }; + + for (int t = 0; t < 2000; t += 250) + { + // alternate between simple notes and hold notes to exercise LN codepaths + if (i % 2 == 0) + { + var note = new Note { StartTime = t + i * 10, Column = i % 4 }; + bm.HitObjects.Add(note); + } + else + { + var hold = new HoldNote { StartTime = t + i * 10, Column = i % 4 }; + hold.Duration = 400 + i * 50; + bm.HitObjects.Add(hold); + } + } + + list.Add(bm); + } + + return list.ToArray(); + } + + private Drawable buildControlRow() + { + // Controls are exposed via test steps (AddSliderStep / AddStep) instead of being placed in the scene. + return new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y }; + } + + private void loadSampleBeatmaps() + { + // try load canonical test beatmap resource first + try + { + string resourcePath = @"Resources/Testing/Beatmaps/4869637.osu"; + + // prefer a set of deterministic half-hold beatmaps for side-by-side tuning + sampleBeatmaps = HalfHoldBeatmapSets.CreateTen(4, 32); + } + catch + { + sampleBeatmaps = createSyntheticSamples(); + } + + foreach (var bm in sampleBeatmaps) + { + string id = ((BeatmapInfo)bm.BeatmapInfo).Metadata.Title!; + last[id] = 0; + current[id] = 0; + + var row = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f), + Children = new Drawable[] + { + new OsuSpriteText { Text = id, Width = 180, Font = new FontUsage(size:20) }, + new OsuSpriteText { Text = "Last: 0", Name = "last_" + id, Font = new FontUsage(size:20) }, + new OsuSpriteText { Text = "Cur: 0", Name = "cur_" + id, Font = new FontUsage(size:20) }, + // place holders for settings display under the SRs + new OsuSpriteText { Text = "Last settings: -", Name = "last_set_" + id, Font = new FontUsage(size:20) }, + new OsuSpriteText { Text = "Cur settings: -", Name = "cur_set_" + id, Font = new FontUsage(size:20) } + } + }; + + listContainer.Add(row); + } + } + + private void updateAllSR() + { + // read slider values + var sliders = this.ChildrenOfType>(); + + foreach (var s in sliders) + { + // decide by Width positions we set earlier + if (s.Width == 0.28f) ln_weight = s.Current.Value; + else if (s.Width == 0.2f) ln_len_cap = s.Current.Value; + else if (s.Width == 0.16f) totalnotes_offset = s.Current.Value; + else if (s.Width == 0.22f) pbar_ln_coeff = s.Current.Value; + else if (s.Width == 0.18f) jack_multiplier = s.Current.Value; + else if (s.Width == 0.12f) final_scale = s.Current.Value; + } + + // propagate slider values into SRCalculatorTunable.Tunables + TunableSyncFromSliders(); + + for (int i = 0; i < sampleBeatmaps.Length; i++) + { + var bm = sampleBeatmaps[i]; + string id = bm.BeatmapInfo.Metadata.Title; + + double sr = SRCalculatorTunable.CalculateSR(bm); + current[id] = sr; + + var row = listContainer.Children[i] as FillFlowContainer; + var curText = row?.Children[2] as SpriteText; + if (row?.Children[1] is SpriteText lastText) lastText.Text = $"Last: {last[id]:F2}"; + if (curText != null) curText.Text = $"Cur: {sr:F2}"; + + // update settings display + var lastSetText = row?.Children[3] as SpriteText; + var curSetText = row?.Children[4] as SpriteText; + if (lastSetText != null) + lastSetText.Text = $"Last settings: ln_w={lastSettings.lnWeight:F3}, ln_cap={lastSettings.lnLenCap:F0}, off={lastSettings.offset:F0}, pbar={lastSettings.pbarCoeff:F4}, jack={lastSettings.jackMult:F1}, scale={lastSettings.scale:F3}"; + if (curSetText != null) + curSetText.Text = $"Cur settings: ln_w={SRCalculatorTunable.Tunables.FinalLNToNotesFactor:F3}, ln_cap={SRCalculatorTunable.Tunables.FinalLNLenCap:F0}, off={SRCalculatorTunable.Tunables.TotalNotesOffset:F0}, pbar={SRCalculatorTunable.Tunables.PBarLnMultiplier:F4}, jack={SRCalculatorTunable.Tunables.JackPenaltyMultiplier:F1}, scale={SRCalculatorTunable.Tunables.FinalScale:F3}"; + } + } + + private void TunableSyncFromSliders() + { + var sliders = this.ChildrenOfType>(); + + foreach (var s in sliders) + { + if (s.Width == 0.28f) SRCalculatorTunable.Tunables.FinalLNToNotesFactor = s.Current.Value; + else if (s.Width == 0.2f) SRCalculatorTunable.Tunables.FinalLNLenCap = s.Current.Value; + else if (s.Width == 0.16f) SRCalculatorTunable.Tunables.TotalNotesOffset = s.Current.Value; + else if (s.Width == 0.22f) SRCalculatorTunable.Tunables.PBarLnMultiplier = s.Current.Value; + else if (s.Width == 0.18f) SRCalculatorTunable.Tunables.JackPenaltyMultiplier = s.Current.Value; + else if (s.Width == 0.12f) SRCalculatorTunable.Tunables.FinalScale = s.Current.Value; + } + } + + // isolated compute method that uses exposed parameters + private double HotComputeSR(IBeatmap beatmap, double lnWeight, int lnLenCap, int offset, double pbarCoeff, double jackMult, double scale) + { + var mania = (ManiaBeatmap)beatmap; + int headCount = 0; + var lnLens = new List(); + + foreach (var ho in mania.HitObjects) + { + headCount++; + int tail = (int)ho.GetEndTime(); + int len = Math.Max(0, Math.Min(tail - (int)ho.StartTime, lnLenCap)); + if (len > 0) lnLens.Add(len); + } + + double baseDifficulty = headCount * 0.1; + + double lnContribution = 0; + + foreach (int len in lnLens) + { + // combine ln weight and pbar coefficient + lnContribution += lnWeight * (len / 200.0) * 0.5 * (1.0 + pbarCoeff * 100.0); + } + + double totalNotes = headCount + lnContribution; + + // simplistic tail-based penalty: more LN tails -> slightly reduce sr, scaled by jackMult + double tailPenalty = 1.0 + lnLens.Count * 0.01 * (jackMult / 35.0); + + double sr = baseDifficulty * (totalNotes / (totalNotes + offset)) / tailPenalty; + sr *= scale; + return Math.Max(0, sr); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModSpaceBody.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModSpaceBody.cs new file mode 100644 index 0000000000..c27263ef21 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModSpaceBody.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Mods.LAsMods; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public partial class TestSceneManiaModSpaceBody : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [Test] + public void TestDefaultSettings() + { + var mod = new ManiaModSpaceBody(); + + CreateModTest(new ModTestData + { + Mod = mod, + CreateBeatmap = () => new Beatmap + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + HitObjects = new List + { + new Note { StartTime = 1000, Column = 0 }, + new Note { StartTime = 2000, Column = 0 }, + new Note { StartTime = 3000, Column = 0 }, + new Note { StartTime = 1000, Column = 1 }, + new Note { StartTime = 2500, Column = 1 }, + new Note { StartTime = 1000, Column = 2 }, + new Note { StartTime = 3500, Column = 2 } + } + }, + PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true + }); + } + + [Test] + public void TestWithSmallSpaceBeat() + { + var mod = new ManiaModSpaceBody + { + SpaceBeat = { Value = 2.0 } + }; + + CreateModTest(new ModTestData + { + Mod = mod, + CreateBeatmap = () => new Beatmap + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + HitObjects = new List + { + new Note { StartTime = 1000, Column = 0 }, + new Note { StartTime = 2000, Column = 0 }, + new Note { StartTime = 3000, Column = 0 } + } + }, + PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true + }); + } + + [Test] + public void TestWithLargeSpaceBeat() + { + var mod = new ManiaModSpaceBody + { + SpaceBeat = { Value = 8.0 } + }; + + CreateModTest(new ModTestData + { + Mod = mod, + CreateBeatmap = () => new Beatmap + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + HitObjects = new List + { + new Note { StartTime = 1000, Column = 0 }, + new Note { StartTime = 2000, Column = 0 }, + new Note { StartTime = 3000, Column = 0 } + } + }, + PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true + }); + } + + [Test] + public void TestWithShieldEnabled() + { + var mod = new ManiaModSpaceBody + { + Shield = { Value = true } + }; + + CreateModTest(new ModTestData + { + Mod = mod, + CreateBeatmap = () => new Beatmap + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + HitObjects = new List + { + new Note { StartTime = 1000, Column = 0 }, + new Note { StartTime = 2000, Column = 0 }, + new Note { StartTime = 3000, Column = 0 } + } + }, + PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true + }); + } + + [Test] + public void TestWithHoldNotes() + { + var mod = new ManiaModSpaceBody(); + + CreateModTest(new ModTestData + { + Mod = mod, + CreateBeatmap = () => new Beatmap + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + HitObjects = new List + { + new Note { StartTime = 1000, Column = 0 }, + new HoldNote { StartTime = 2000, Duration = 500, Column = 0 }, + new Note { StartTime = 3000, Column = 0 } + } + }, + PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true + }); + } + } +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneEzHitEventHeatmapGraph.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneEzHitEventHeatmapGraph.cs new file mode 100644 index 0000000000..dd1e892102 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneEzHitEventHeatmapGraph.cs @@ -0,0 +1,442 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEzMania.Analysis; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public partial class TestSceneEzHitEventHeatmapGraph : OsuTestScene + { + private ScoreInfo testScore; + private IBeatmap testBeatmap; + + private const int RANDOM_SEED = 1234; // Fixed seed for consistent random data + private const int COLUMNS = 7; // 7k + private const int NOTES_PER_COLUMN = 50; // 50 notes per column + + [SetUp] + public void SetUp() + { + // Create a test beatmap: 7k with 50 notes per column (350 total) + testBeatmap = createTestBeatmap(); + // Create a test score with specific hit event distribution + testScore = createTestScore(testBeatmap); + } + + [Test] + public void TestEmptyScore() + { + EzManiaScoreGraph graph = null; + + AddStep("Create graph with empty score", () => + { + var emptyScore = new ScoreInfo + { + BeatmapInfo = testBeatmap.BeatmapInfo, + Ruleset = testBeatmap.BeatmapInfo.Ruleset, + HitEvents = new List(), + Accuracy = 1.0, + TotalScore = 0, + Mods = Array.Empty() + }; + + Child = graph = new EzManiaScoreGraph(emptyScore, testBeatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(800, 400), + }; + }); + + AddAssert("Graph created successfully", () => graph != null); + AddAssert("Graph is visible", () => graph.IsPresent); + } + + [Test] + public void TestPerfectScore() + { + EzManiaScoreGraph graph = null; + + AddStep("Create graph with perfect score", () => + { + var perfectScore = createPerfectScore(testBeatmap); + + Child = graph = new EzManiaScoreGraph(perfectScore, testBeatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(800, 400), + }; + }); + + AddAssert("Graph created successfully", () => graph != null); + AddAssert("Graph is visible", () => graph.IsPresent); + AddAssert("Graph has hit events", () => graph != null && graph.DrawWidth > 0); + } + + [Test] + public void TestMixedResults() + { + EzManiaScoreGraph graph = null; + + AddStep("Create graph with test score", () => + { + Child = graph = new EzManiaScoreGraph(testScore, testBeatmap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(800, 400), + }; + }); + + AddAssert("Graph created successfully", () => graph != null); + AddAssert("Graph is visible", () => graph.IsPresent); + AddAssert("Graph height is reasonable", () => graph != null && graph.DrawHeight > 0); + } + + /// + /// Creates a 7k beatmap with 50 notes per column using fixed seed randomization. + /// Total: 7 columns * 50 notes = 350 notes + /// + private IBeatmap createTestBeatmap() + { + var beatmap = new ManiaBeatmap(new StageDefinition(COLUMNS)) + { + BeatmapInfo = new BeatmapInfo + { + Ruleset = new ManiaRuleset().RulesetInfo, + Difficulty = new BeatmapDifficulty + { + DrainRate = 8, + OverallDifficulty = 8, + ApproachRate = 8, + CircleSize = 4, + } + }, + ControlPointInfo = new ControlPointInfo() + }; + + var random = new Random(RANDOM_SEED); + + // Create 50 notes per column + for (int column = 0; column < COLUMNS; column++) + { + for (int i = 0; i < NOTES_PER_COLUMN; i++) + { + // Use fixed seed random for note timing within each column + double timeOffset = random.NextDouble() * 500; // Random offset between 0-500ms for each note + + beatmap.HitObjects.Add(new Note + { + StartTime = column * 500 + i * 200 + timeOffset, + Column = column + }); + } + } + + return beatmap; + } + + /// + /// Creates a test score with segmented symmetric normal distribution of timing offsets. + /// - [-40, 40]ms: 200 hits centered at 0ms (σ=20ms) + /// - [40, 100] and [-100, -40]ms: 50 hits centered at ±40ms (σ=30ms) + /// - [100, 150] and [-150, -100]ms: 20 hits centered at ±100ms (σ=25ms) + /// - [150, 200] and [-200, -150]ms: 10 hits centered at ±150ms (σ=25ms) + /// + private ScoreInfo createTestScore(IBeatmap beatmap) + { + var hitEvents = new List(); + var random = new Random(RANDOM_SEED); + + // Initialize hit windows based on beatmap difficulty + var hitWindows = new ManiaHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + // Collect all offsets with their results + var allOffsetsWithResults = new List<(double offset, HitResult result)>(); + + // Segment 1: [-40, 40]ms with 200 hits + // Normal distribution centered at 0ms with σ=20ms (symmetric around 0) + for (int i = 0; i < 200; i++) + { + double offset = GenerateNormalOffset(random, 0, 20); + // Clamp to [-40, 40] range + offset = Math.Max(-40, Math.Min(40, offset)); + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + // Segment 2: [40, 100]ms and [-100, -40]ms with 50 hits + // Normal distribution centered at ±40ms with σ=30ms + for (int i = 0; i < 25; i++) + { + // Positive side [40, 100] + double offset = GenerateNormalOffset(random, 40, 30); + offset = Math.Max(40, Math.Min(100, offset)); + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + for (int i = 0; i < 25; i++) + { + // Negative side [-100, -40] + double offset = GenerateNormalOffset(random, -40, 30); + offset = Math.Max(-100, Math.Min(-40, offset)); + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + // Segment 3: [100, 150]ms and [-150, -100]ms with 20 hits + // Normal distribution centered at ±100ms with σ=25ms + for (int i = 0; i < 10; i++) + { + // Positive side [100, 150] + double offset = GenerateNormalOffset(random, 100, 25); + offset = Math.Max(100, Math.Min(150, offset)); + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + for (int i = 0; i < 10; i++) + { + // Negative side [-150, -100] + double offset = GenerateNormalOffset(random, -100, 25); + offset = Math.Max(-150, Math.Min(-100, offset)); + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + // Segment 4: [150, 200]ms and [-200, -150]ms with 10 hits + // Normal distribution centered at ±150ms with σ=25ms + for (int i = 0; i < 5; i++) + { + // Positive side [150, 200] + double offset = GenerateNormalOffset(random, 150, 25); + offset = Math.Max(150, Math.Min(200, offset)); + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + for (int i = 0; i < 5; i++) + { + // Negative side [-200, -150] + double offset = GenerateNormalOffset(random, -150, 25); + offset = Math.Max(-200, Math.Min(-150, offset)); + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + // Now we have 280 hit events, need 70 more to reach 350 + // Fill remaining with random distribution across all segments + while (allOffsetsWithResults.Count < beatmap.HitObjects.Count) + { + int segment = random.Next(4); + double offset = 0; + bool isNegative = random.Next(2) == 0; // Randomly choose positive or negative side + + switch (segment) + { + case 0: // [-40, 40]ms + offset = GenerateNormalOffset(random, 0, 20); + offset = Math.Max(-40, Math.Min(40, offset)); + break; + + case 1: // [±40, ±100]ms + offset = GenerateNormalOffset(random, isNegative ? -40 : 40, 30); + if (isNegative) + offset = Math.Max(-100, Math.Min(-40, offset)); + else + offset = Math.Max(40, Math.Min(100, offset)); + break; + + case 2: // [±100, ±150]ms + offset = GenerateNormalOffset(random, isNegative ? -100 : 100, 25); + if (isNegative) + offset = Math.Max(-150, Math.Min(-100, offset)); + else + offset = Math.Max(100, Math.Min(150, offset)); + break; + + case 3: // [±150, ±200]ms + offset = GenerateNormalOffset(random, isNegative ? -150 : 150, 25); + if (isNegative) + offset = Math.Max(-200, Math.Min(-150, offset)); + else + offset = Math.Max(150, Math.Min(200, offset)); + break; + } + + HitResult result = hitWindows.ResultFor(offset); + if (result == HitResult.None) + result = HitResult.Miss; + + allOffsetsWithResults.Add((offset, result)); + } + + // Trim to exact count + allOffsetsWithResults = allOffsetsWithResults.Take(beatmap.HitObjects.Count).ToList(); + + // Shuffle to randomize distribution while maintaining segments + allOffsetsWithResults = allOffsetsWithResults.OrderBy(_ => random.Next()).ToList(); + + // Create HitEvents + for (int i = 0; i < allOffsetsWithResults.Count && i < beatmap.HitObjects.Count; i++) + { + double timeOffset = allOffsetsWithResults[i].offset; + HitResult result = allOffsetsWithResults[i].result; + + hitEvents.Add(new HitEvent(timeOffset, null, result, beatmap.HitObjects[i], null, null)); + } + + double accuracy = CalculateAccuracy(hitEvents); + + return new ScoreInfo + { + BeatmapInfo = beatmap.BeatmapInfo, + Ruleset = beatmap.BeatmapInfo.Ruleset, + HitEvents = hitEvents, + Accuracy = accuracy, + TotalScore = 424000, + MaxCombo = hitEvents.Count, + Mods = Array.Empty() + }; + } + + /// + /// Generate a random value from normal distribution using Box-Muller transform. + /// + private double GenerateNormalOffset(Random random, double mean, double stdDev) + { + double u1 = random.NextDouble(); + double u2 = random.NextDouble(); + double z0 = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2); + return mean + z0 * stdDev; + } + + /// + /// Calculate accuracy based on hit event results. + /// + private double CalculateAccuracy(List hitEvents) + { + double totalPoints = 0; + double maxPoints = 0; + + foreach (var hitEvent in hitEvents) + { + maxPoints += 305; // Maximum points per note in Mania + + switch (hitEvent.Result) + { + case HitResult.Perfect: + totalPoints += 305; + break; + + case HitResult.Great: + totalPoints += 300; + break; + + case HitResult.Good: + totalPoints += 200; + break; + + case HitResult.Ok: + totalPoints += 100; + break; + + case HitResult.Meh: + totalPoints += 50; + break; + + default: + totalPoints += 0; + break; + } + } + + return maxPoints > 0 ? totalPoints / maxPoints : 0; + } + + private ScoreInfo createPerfectScore(IBeatmap beatmap) + { + var hitEvents = new List(); + + // Initialize hit windows based on beatmap difficulty + var hitWindows = new ManiaHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + foreach (var hitObject in beatmap.HitObjects) + { + // Perfect timing offset (0ms) + double timeOffset = 0; + + // Calculate the correct hit result based on hit windows at perfect timing + HitResult result = hitWindows.ResultFor(timeOffset); + if (result == HitResult.None) + result = HitResult.Miss; + + hitEvents.Add(new HitEvent(timeOffset, null, result, hitObject, null, null)); + } + + return new ScoreInfo + { + BeatmapInfo = beatmap.BeatmapInfo, + Ruleset = beatmap.BeatmapInfo.Ruleset, + HitEvents = hitEvents, + Accuracy = 1.0, + TotalScore = 1000000, + MaxCombo = hitEvents.Count, + Mods = Array.Empty() + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index c55465762b..1aaf6e2377 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -8,10 +8,13 @@ using System.Collections.Generic; using System.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; +using osu.Game.LAsEzExtensions.Background; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Mania.Beatmaps.Patterns; using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Scoring.Legacy; @@ -25,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// /// Maximum number of previous notes to consider for density calculation. /// - private const int max_notes_for_density = 7; + private const int max_notes_for_density = 24; /// /// The total number of columns. @@ -47,6 +50,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// public readonly bool IsForCurrentRuleset; + /// + /// The current hit mode for mania judgement system. + /// + public static EzMUGHitMode CurrentHitMode { get; set; } + // Internal for testing purposes internal readonly LegacyRandom Random; @@ -60,6 +68,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps private ManiaBeatmapConverter(IBeatmap? beatmap, LegacyBeatmapConversionDifficultyInfo difficulty, Ruleset ruleset) : base(beatmap!, ruleset) { + CurrentHitMode = GlobalConfigStore.EzConfig?.Get(Ez2Setting.HitMode) ?? EzMUGHitMode.Lazer; IsForCurrentRuleset = difficulty.SourceRuleset.Equals(ruleset.RulesetInfo); Random = new LegacyRandom((int)MathF.Round(difficulty.DrainRate + difficulty.CircleSize) * 20 + (int)(difficulty.OverallDifficulty * 41.2) + (int)MathF.Round(difficulty.ApproachRate)); TargetColumns = getColumnCount(difficulty); @@ -132,7 +141,21 @@ namespace osu.Game.Rulesets.Mania.Beatmaps { case ManiaHitObject maniaObj: { - yield return maniaObj; + if (maniaObj is HoldNote hold && CurrentHitMode != EzMUGHitMode.Lazer) + { + yield return CurrentHitMode switch + { + EzMUGHitMode.EZ2AC => new Ez2AcHoldNote(hold), + EzMUGHitMode.Malody => new NoJudgmentHoldNote(hold), + EzMUGHitMode.O2Jam => new O2HoldNote(hold), + EzMUGHitMode.IIDX_HD => new Ez2AcHoldNote(hold), + _ => hold + }; + } + else + { + yield return maniaObj; + } yield break; } @@ -227,7 +250,21 @@ namespace osu.Game.Rulesets.Mania.Beatmaps lastPattern = newPattern; foreach (var obj in newPattern.HitObjects) - yield return obj; + if (obj is HoldNote hold && CurrentHitMode != EzMUGHitMode.Lazer) + { + yield return CurrentHitMode switch + { + EzMUGHitMode.EZ2AC => new Ez2AcHoldNote(hold), + EzMUGHitMode.Malody => new NoJudgmentHoldNote(hold), + EzMUGHitMode.O2Jam => new O2HoldNote(hold), + EzMUGHitMode.IIDX_HD => new Ez2AcHoldNote(hold), + _ => hold + }; + } + else + { + yield return obj; + } } } diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index d58347076d..42ed97245e 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -3,8 +3,10 @@ using osu.Framework.Configuration.Tracking; using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; using osu.Game.Rulesets.Configuration; +using osu.Game.Rulesets.Mania.LAsEZMania; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Configuration @@ -17,11 +19,17 @@ namespace osu.Game.Rulesets.Mania.Configuration Migrate(); } + private const double current_scroll_speed_precision = 1.0; + protected override void InitialiseDefaults() { base.InitialiseDefaults(); - SetDefault(ManiaRulesetSetting.ScrollSpeed, 8.0, 1.0, 40.0, 0.1); + SetDefault(ManiaRulesetSetting.ScrollBaseSpeed, 500, 100, 1000, 1.0); + SetDefault(ManiaRulesetSetting.ScrollTimePerSpeed, 5, 1.0, 40, 1.0); + SetDefault(ManiaRulesetSetting.ScrollStyle, EzManiaScrollingStyle.ScrollTimeStyleFixed); + + SetDefault(ManiaRulesetSetting.ScrollSpeed, 200, 1.0, 401.0, current_scroll_speed_precision); SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false); SetDefault(ManiaRulesetSetting.MobileLayout, ManiaMobileLayout.Portrait); @@ -47,14 +55,24 @@ namespace osu.Game.Rulesets.Mania.Configuration speed => new SettingDescription( rawValue: speed, name: RulesetSettingsStrings.ScrollSpeed, - value: RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(speed), speed) + value: RulesetSettingsStrings.ScrollSpeedTooltip( + (int)DrawableManiaRuleset.ComputeScrollTime(speed, Get(ManiaRulesetSetting.ScrollBaseSpeed), Get(ManiaRulesetSetting.ScrollTimePerSpeed)), + speed + ) ) - ) + ), }; } + // TODO: 未来应考虑完全迁移到Ez2Setting中 public enum ManiaRulesetSetting { + ScrollStyle, + ScrollTime, + ScrollBaseSpeed, + ScrollTimePerSpeed, + + //官方设置 ScrollSpeed, ScrollDirection, TimingBasedNoteColouring, diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index bcf16e6808..ea6d64af41 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -6,14 +6,18 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.LAsEzExtensions.Background; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mania.Difficulty.Skills; +using osu.Game.Rulesets.Mania.LAsEZMania.Analysis; using osu.Game.Rulesets.Mania.MathUtils; using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; @@ -41,12 +45,18 @@ namespace osu.Game.Rulesets.Mania.Difficulty if (beatmap.HitObjects.Count == 0) return new ManiaDifficultyAttributes { Mods = mods }; - HitWindows hitWindows = new ManiaHitWindows(); + var hitWindows = new ManiaHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + if (beatmap.BeatmapInfo.BPM > 0) hitWindows.BPM = beatmap.BeatmapInfo.BPM; + double sr = skills[0].DifficultyValue() * difficulty_multiplier; + + sr = AdditionalMethod(beatmap, mods, skills, clockRate, sr); ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes { - StarRating = skills.OfType().Single().DifficultyValue() * difficulty_multiplier, + StarRating = sr > 0 + ? sr + : skills.OfType().Single().DifficultyValue() * difficulty_multiplier, Mods = mods, MaxCombo = beatmap.HitObjects.Sum(maxComboForObject), }; @@ -54,6 +64,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty return attributes; } + public double AdditionalMethod(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate, double originalValue) + { + double sr = originalValue; + + if (mods.Any(m => m is ModStarRatingRebirth)) + { + var xxySRFilter = GlobalConfigStore.EzConfig?.GetBindable(Ez2Setting.XxySRFilter); + + sr = xxySRFilter != null && xxySRFilter.Value + ? SRCalculator.CalculateSR(beatmap, clockRate) + : skills.OfType().Single().DifficultyValue() * difficulty_multiplier; + } + + return sr; + } + private static int maxComboForObject(HitObject hitObject) { if (hitObject is HoldNote hold) diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs index 181bc7341c..ea7e3dfe3e 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs @@ -44,7 +44,8 @@ namespace osu.Game.Rulesets.Mania.Edit protected override void Update() { - TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value; + // 使用ez2lazer特色调速系统 + TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed), Config.Get(ManiaRulesetSetting.ScrollBaseSpeed), Config.Get(ManiaRulesetSetting.ScrollTimePerSpeed)) : TimelineTimeRange.Value; base.Update(); } } diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/CrossMatrixProvider.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/CrossMatrixProvider.cs new file mode 100644 index 0000000000..3939de10f8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/CrossMatrixProvider.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Mania.LAsEZMania.Analysis +{ + /// + /// 交叉矩阵提供者,用于SR计算中的列间 权重矩阵 + /// + public static class CrossMatrixProvider + { + /// + /// 默认交叉矩阵数据,表示各键位两侧的权重分布 + /// 索引0对应K=1,索引1对应K=2,以此类推 + /// null表示不支持该键数 + /// + // TODO: 未来考虑支持在游戏内配置这些矩阵动态调试对比 + private static readonly double[][] default_cross_matrices = + [ + [-1], // CS=0 + [0.075, 0.075], + [0.125, 0.05, 0.125], + [0.125, 0.125, 0.125, 0.125], + [0.175, 0.25, 0.05, 0.25, 0.175], + [0.175, 0.25, 0.175, 0.175, 0.25, 0.175], + [0.225, 0.35, 0.25, 0.05, 0.25, 0.35, 0.225], + [0.225, 0.35, 0.25, 0.225, 0.225, 0.25, 0.35, 0.225], + [0.275, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.275], + [0.275, 0.45, 0.35, 0.25, 0.275, 0.275, 0.25, 0.35, 0.45, 0.275], + [0.325, 0.55, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.55, 0.325], // 10key + // Inferred matrices for K=11 to 18 based on user-specified patterns + [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=11 (odd, unsupported) + // 更高K的矩阵没有经过严格验证,仅提供占位 + [0.8, 0.8, 0.8, 0.6, 0.4, 0.2, 0.05, 0.2, 0.4, 0.6, 0.8, 0.8, 0.8], // K=12 (even, sides 3 columns higher) + [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=13 (odd, unsupported) + [0.4, 0.4, 0.2, 0.2, 0.3, 0.3, 0.1, 0.1, 0.3, 0.3, 0.2, 0.2, 0.4, 0.4, 0.4], // K=14 (wave: low-low-high-high-low-low-high-high) + [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=15 (odd, unsupported) + [0.4, 0.4, 0.2, 0.2, 0.4, 0.4, 0.2, 0.1, 0.1, 0.2, 0.4, 0.4, 0.2, 0.2, 0.4, 0.4, 0.4], // K=16 (wave: low-low-high-high-low-low-high-high-low-low-high-high) + [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=17 (odd, unsupported) + [0.4, 0.4, 0.2, 0.4, 0.2, 0.4, 0.2, 0.3, 0.1, 0.1, 0.3, 0.2, 0.4, 0.2, 0.4, 0.2, 0.4, 0.4, 0.4] // K=18 (wave: low-low-high-low-high-low-high-low-low-high-low-high-low-high) + ]; + + /// + /// 自定义交叉矩阵,用于覆盖默认数据 + /// + private static readonly Dictionary custom_matrices = new Dictionary(); + + /// + /// 设置自定义交叉矩阵 + /// + /// 键数 + /// 自定义矩阵数组,如果为null则清除自定义矩阵 + public static void SetCustomMatrix(int k, double[]? matrix) + { + if (k < 1 || k > default_cross_matrices.Length) + throw new ArgumentOutOfRangeException(nameof(k), $"不支持的键数: {k},支持范围: 1-{default_cross_matrices.Length}"); + + if (matrix == null) + custom_matrices.Remove(k); + else + custom_matrices[k] = matrix; + } + + /// + /// 获取指定键数(K)的交叉矩阵 + /// K表示键数,从1开始索引 + /// + /// 键数 + /// 交叉矩阵数组,如果不支持返回null + public static double[]? GetMatrix(int k) + { + if (k < 1 || k > default_cross_matrices.Length) + return null; + + return custom_matrices.TryGetValue(k, out double[]? customMatrix) ? customMatrix : default_cross_matrices[k]; + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph.cs new file mode 100644 index 0000000000..d87555aab5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph.cs @@ -0,0 +1,320 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.LAsEZMania.Helper; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.LAsEzMania.Analysis +{ + /// + /// Mania-specific implementation of score graph that extends BaseEzScoreGraph. + /// Provides LN (Long Note) aware scoring calculation for Classic mode. + /// + public partial class EzManiaScoreGraph : BaseEzScoreGraph + { + private readonly ManiaHitWindows maniaHitWindows = new ManiaHitWindows(); + + private readonly CustomHitWindowsHelper hitWindows1; + private readonly CustomHitWindowsHelper hitWindows2; + private Bindable hitModeBindable = null!; + + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; + + public EzManiaScoreGraph(ScoreInfo score, IBeatmap beatmap) + : base(score, beatmap, new ManiaHitWindows()) + { + maniaHitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + // Initialize helpers here (after base ctor has run and static OD/HP have been set). + hitWindows1 = new CustomHitWindowsHelper { OverallDifficulty = OD }; + hitWindows2 = new CustomHitWindowsHelper { OverallDifficulty = OD }; + } + + protected override IReadOnlyList FilterHitEvents() + { + return Score.HitEvents.Where(e => maniaHitWindows.IsHitResultAllowed(e.Result)).ToList(); + } + + protected override double UpdateBoundary(HitResult result) + { + return maniaHitWindows.WindowFor(result); + } + + [BackgroundDependencyLoader] + private void load() + { + // Bind to the global hit mode setting so that switching hit modes updates our helpers and redraws. + hitModeBindable = ezConfig.GetBindable(Ez2Setting.HitMode); + hitModeBindable.BindValueChanged(v => + { + hitWindows1.HitMode = v.NewValue; + hitWindows2.HitMode = v.NewValue; + + // Ensure mania windows re-evaluate based on global config and difficulty. + maniaHitWindows.ResetRange(); + maniaHitWindows.SetDifficulty(Beatmap.Difficulty.OverallDifficulty); + + // Recalculate and redraw. + Refresh(); + }, true); + } + + protected override HitResult RecalculateV1Result(HitEvent hitEvent) + { + return hitWindows1.ResultFor(hitEvent.TimeOffset); + } + + protected override HitResult RecalculateV2Result(HitEvent hitEvent) + { + return maniaHitWindows.ResultFor(hitEvent.TimeOffset); + } + + protected override void UpdateText() + { + double scAcc = Score.Accuracy * 100; + long scScore = Score.TotalScore; + + AddInternal(new GridContainer + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Position = Vector2.Zero, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Text = "Acc org", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {scAcc:F1}%", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "Acc v2", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Accuracy * 100:F1}%", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "Acc v1", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V1Accuracy * 100:F1}%", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "Scr org", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {scScore / 1000.0:F0}k", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "Scr v2", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Score / 1000.0:F0}k", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "Scr v1", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V1Score / 1000.0:F0}k", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "Pauses", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {Score.Pauses.Count}", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "PERFECT", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Counts.GetValueOrDefault(HitResult.Perfect, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Perfect, 0)}", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "GREAT", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Counts.GetValueOrDefault(HitResult.Great, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Great, 0)}", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "GOOD", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Counts.GetValueOrDefault(HitResult.Good, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Good, 0)}", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "OK", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Counts.GetValueOrDefault(HitResult.Ok, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Ok, 0)}", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "MEH", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Counts.GetValueOrDefault(HitResult.Meh, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Meh, 0)}", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + new Drawable[] + { + new OsuSpriteText + { + Text = "MISS", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + new OsuSpriteText + { + Text = $" : {V2Counts.GetValueOrDefault(HitResult.Miss, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Miss, 0)}", + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + }, + }, + } + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph_clean.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/EzManiaScoreGraph_clean.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/ManiaScoreHitEventGenerator.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/ManiaScoreHitEventGenerator.cs new file mode 100644 index 0000000000..cc227da501 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/ManiaScoreHitEventGenerator.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.Replays; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Utils; + +namespace osu.Game.Rulesets.Mania.LAsEzMania.Analysis +{ + /// + /// Generates s for mania scores by re-evaluating a score's replay input against a provided playable beatmap. + /// This is intended for results/statistics usage where are not persisted. + /// + public sealed class ManiaScoreHitEventGenerator : IHitEventGenerator + { + public static ManiaScoreHitEventGenerator Instance { get; } = new ManiaScoreHitEventGenerator(); + + /// + /// Instance implementation of generator. + /// + public List? Generate(Score score, IBeatmap playableBeatmap, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (score.ScoreInfo.Ruleset.OnlineID != 3) + return null; + + Replay replay = score.Replay; + + // Legacy decoding should have produced mania frames. + if (replay?.Frames == null || replay.Frames.Count == 0) + return null; + + if (replay.Frames.Any(f => f is not ManiaReplayFrame)) + return null; + + var frames = replay.Frames.Cast().OrderBy(f => f.Time).ToList(); + + // Build per-column input transitions. + var pressTimesByColumn = new List[32]; + var releaseTimesByColumn = new List[32]; + + for (int i = 0; i < pressTimesByColumn.Length; i++) + { + pressTimesByColumn[i] = new List(); + releaseTimesByColumn[i] = new List(); + } + + HashSet last = new HashSet(); + + foreach (var frame in frames) + { + cancellationToken.ThrowIfCancellationRequested(); + + var current = new HashSet(frame.Actions); + + foreach (var action in current) + { + if (last.Contains(action)) + continue; + + int column = (int)action; + if (column >= 0 && column < pressTimesByColumn.Length) + pressTimesByColumn[column].Add(frame.Time); + } + + foreach (var action in last) + { + if (current.Contains(action)) + continue; + + int column = (int)action; + if (column >= 0 && column < releaseTimesByColumn.Length) + releaseTimesByColumn[column].Add(frame.Time); + } + + last = current; + } + + // If keys are still held at the end of replay, treat them as released at the last frame time. + if (last.Count > 0) + { + double endTime = frames[^1].Time; + + foreach (var action in last) + { + int column = (int)action; + if (column >= 0 && column < releaseTimesByColumn.Length) + releaseTimesByColumn[column].Add(endTime); + } + } + + // Map tail -> head to support capping (combo-break conditions). + var headByTail = new Dictionary(); + + foreach (var hitObject in playableBeatmap.HitObjects) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (hitObject is HoldNote hold) + headByTail[hold.Tail] = hold.Head; + } + + var targets = new List(); + + foreach (var hitObject in playableBeatmap.HitObjects) + { + cancellationToken.ThrowIfCancellationRequested(); + collectJudgementTargets(hitObject, targets, cancellationToken); + } + + // Ensure deterministic ordering. + targets.Sort((a, b) => + { + int timeComparison = a.StartTime.CompareTo(b.StartTime); + if (timeComparison != 0) + return timeComparison; + + int colA = (a as IHasColumn)?.Column ?? 0; + int colB = (b as IHasColumn)?.Column ?? 0; + return colA.CompareTo(colB); + }); + + double gameplayRate = ModUtils.CalculateRateWithMods(score.ScoreInfo.Mods); + + var hitEvents = new List(targets.Count); + HitObject? lastHitObject = null; + + // Track head hit results for later tail capping. + var headWasHit = new Dictionary(); + + foreach (var target in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (target.HitWindows == null || ReferenceEquals(target.HitWindows, HitWindows.Empty)) + continue; + + if (target is not IHasColumn hasColumn) + continue; + + int column = hasColumn.Column; + if (column < 0 || column >= pressTimesByColumn.Length) + continue; + + bool isTail = target is TailNote; + double lenienceFactor = isTail ? TailNote.RELEASE_WINDOW_LENIENCE : 1; + + // We treat judgement windows as symmetrical around StartTime. + double missWindow = target.HitWindows.WindowFor(HitResult.Miss) * lenienceFactor; + + List times = isTail ? releaseTimesByColumn[column] : pressTimesByColumn[column]; + + int idx = times.FindIndex(t => t >= target.StartTime - missWindow && t <= target.StartTime + missWindow); + + double timeOffsetForJudgement; + HitResult result; + + bool holdBreak = false; + + if (idx >= 0) + { + double eventTime = times[idx]; + times.RemoveAt(idx); + + double rawOffset = eventTime - target.StartTime; + if (isTail && rawOffset < 0) + holdBreak = true; + + timeOffsetForJudgement = rawOffset / lenienceFactor; + + // Use the ruleset-provided mapping, but coerce outside-of-window to Miss (ResultFor() would return None). + if (Math.Abs(rawOffset) > missWindow) + result = HitResult.Miss; + else + result = target.HitWindows.ResultFor(timeOffsetForJudgement); + + if (result == HitResult.None) + result = HitResult.Miss; + + if (target is HeadNote head) + headWasHit[head] = result.IsHit(); + + if (target is TailNote tail && headByTail.TryGetValue(tail, out var headNote)) + { + bool headHit = headWasHit.TryGetValue(headNote, out bool wasHit) && wasHit; + + if (result > HitResult.Meh && (!headHit || holdBreak)) + result = HitResult.Meh; + } + } + else + { + // No matching input event. Treat as a miss. + timeOffsetForJudgement = 0; + result = HitResult.Miss; + + if (target is HeadNote head) + headWasHit[head] = false; + } + + hitEvents.Add(new HitEvent(timeOffsetForJudgement, gameplayRate, result, target, lastHitObject, null)); + lastHitObject = target; + } + + return hitEvents; + } + + static ManiaScoreHitEventGenerator() + { + try + { + ScoreHitEventGeneratorBridge.Register(ManiaRuleset.SHORT_NAME, Instance); + ScoreHitEventGeneratorBridge.Register("3", Instance); + } + catch + { + } + } + + private static void collectJudgementTargets(HitObject hitObject, List targets, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (hitObject.HitWindows != null && !ReferenceEquals(hitObject.HitWindows, HitWindows.Empty) && hitObject.Judgement.MaxResult != HitResult.IgnoreHit) + targets.Add(hitObject); + + foreach (var nested in hitObject.NestedHitObjects) + collectJudgementTargets(nested, targets, cancellationToken); + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRCalculator.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRCalculator.cs new file mode 100644 index 0000000000..59d6d65aa0 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRCalculator.cs @@ -0,0 +1,1284 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Mania.LAsEZMania.Analysis +{ + /// + /// Star Rating calculator – C# port of the official Python implementation. + /// + public class SRCalculator + { + // 可通过 Mod 进行调整的参数(保持与原始实现一致的默认值) + public static double RescaleHighThreshold { get; set; } = 8.0; + public static double LnIntegralMultiplier { get; set; } = 4.0; + + /// + /// Singleton entry point for SR calculations. + /// + public static SRCalculator Instance { get; } = new SRCalculator(); + + #region 工具方法 + + /// + /// Computes the star rating for the supplied beatmap synchronously. + /// + /// Beatmap instance. + /// Timing breakdown produced by the calculation. + /// Calculated SR value. + public static double CalculateSRWithTime(IBeatmap beatmap, out Dictionary? times) + { + (double sr, var collectedTimes) = computeInternalXxySR(beatmap, 1.0); + times = collectedTimes; + return sr; + } + + public static double CalculateSR(IBeatmap beatmap) + { + (double sr, _) = computeInternalXxySR(beatmap, 1.0); + return sr; + } + + /// + /// Computes the star rating with clock rate adjustment (for rate-changing mods like DT/HT). + /// + /// Beatmap instance. + /// Clock rate multiplier (1.5 for DT, 0.75 for HT, etc.). + /// Calculated SR value adjusted for clock rate. + public static double CalculateSR(IBeatmap beatmap, double clockRate) + { + (double sr, _) = computeInternalXxySR(beatmap, clockRate); + return sr; + } + + /// + /// Computes the star rating for the supplied beatmap asynchronously. + /// + /// Beatmap instance. + /// Tuple containing the SR value and timing breakdown. + public Task<(double sr, Dictionary times)> CalculateSRAsync(IBeatmap beatmap) + { + return Task.FromResult(computeInternalXxySR(beatmap, 1.0)); + } + + private static (double sr, Dictionary times) computeInternalXxySR(IBeatmap beatmap, double clockRate = 1.0) + { + var stopwatch = Stopwatch.StartNew(); + + ManiaBeatmap maniaBeatmap = (ManiaBeatmap)beatmap; + // Prefer TotalColumns (reflects keymods / conversion output) over CS. + int keyCount = Math.Max(1, maniaBeatmap.TotalColumns > 0 ? maniaBeatmap.TotalColumns : (int)Math.Round(maniaBeatmap.BeatmapInfo.Difficulty.CircleSize)); + + double sr = XxySRCalculateCore(maniaBeatmap, keyCount, clockRate); + stopwatch.Stop(); + + var timings = new Dictionary + { + ["Total"] = stopwatch.ElapsedMilliseconds + }; + + return (sr, timings); + } + + #endregion + + #region 结构体 + + private readonly struct NoteStruct : IEquatable + { + public NoteStruct(int column, int headTime, int tailTime) + { + Column = column; + HeadTime = headTime; + TailTime = tailTime; + } + + public int Column { get; } + public int HeadTime { get; } + public int TailTime { get; } + + public bool IsLongNote => TailTime >= 0 && TailTime > HeadTime; + + public bool Equals(NoteStruct other) + { + return Column == other.Column && HeadTime == other.HeadTime && TailTime == other.TailTime; + } + + public override bool Equals(object? obj) + { + return obj is NoteStruct other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Column, HeadTime, TailTime); + } + } + + private readonly struct LNRepStruct + { + public LNRepStruct(int[] points, double[] cumulative, double[] values) + { + Points = points; + Cumulative = cumulative; + Values = values; + } + + public int[] Points { get; } + public double[] Cumulative { get; } + public double[] Values { get; } + } + + #endregion + + private static readonly Comparison note_comparer = compareNotes; + + public static double XxySRCalculateCore(ManiaBeatmap maniaBeatmap, int keyCount, double clockRate = 1.0) + { + double[]? cross = CrossMatrixProvider.GetMatrix(keyCount); + + if (cross == null || cross[0] == -1) + { + Console.WriteLine($"[SR][ERROR] Key mode {keyCount}k is not supported by the SR algorithm."); + throw new NotSupportedException($"Key mode {keyCount}k is not supported by the SR algorithm."); + } + + int estimatedNotes = maniaBeatmap.HitObjects.Count; + if (estimatedNotes == 0) return 0.0; + + var notes = new List(estimatedNotes); + var notesByColumn = new List[keyCount]; + + for (int i = 0; i < keyCount; i++) + notesByColumn[i] = new List((estimatedNotes / keyCount) + 1); + + foreach (var hitObject in maniaBeatmap.HitObjects) + { + int column = Math.Clamp(hitObject.Column, 0, keyCount - 1); + // Apply clockRate to timing: faster rate = shorter intervals in calculation + int head = (int)Math.Round(hitObject.StartTime / clockRate); + int tail = (int)Math.Round(hitObject.GetEndTime() / clockRate); + if ((hitObject as IHasDuration)?.EndTime == null) + tail = -1; + + // Align with the original library behaviour: treat non-positive durations as non-LN. + if (tail <= head) + tail = -1; + + var note = new NoteStruct(column, head, tail); + notes.Add(note); + notesByColumn[column].Add(note); + } + + notes.Sort(note_comparer); + foreach (var columnNotes in notesByColumn) + columnNotes.Sort(note_comparer); + + var longNotes = notes.Where(n => n.IsLongNote).ToList(); + var longNotesByTails = longNotes.OrderBy(n => n.TailTime).ToList(); + + double od = maniaBeatmap.BeatmapInfo.Difficulty.OverallDifficulty; + double x = computeHitLeniency(od); + + int maxHead = notes.Max(n => n.HeadTime); + int maxTail = longNotes.Count > 0 ? longNotes.Max(n => n.TailTime) : maxHead; + int totalTime = Math.Max(maxHead, maxTail) + 1; + + //Logger.Log($"C# maxHead: {maxHead}, maxTail: {maxTail}, totalTime: {totalTime}"); + + (double[] allCorners, double[] baseCorners, double[] aCorners) = buildCorners(totalTime, notes); + + //Logger.Log($"C# allCorners length: {allCorners.Length}, baseCorners length: {baseCorners.Length}"); + + bool[][] keyUsage = buildKeyUsage(keyCount, totalTime, notes, baseCorners); + int[][] activeColumns = deriveActiveColumns(keyUsage); + + double[][] keyUsage400 = buildKeyUsage400(keyCount, totalTime, notes, baseCorners); + double[] anchorBase = computeAnchor(keyCount, keyUsage400, baseCorners); + + LNRepStruct? lnRep = longNotes.Count > 0 ? buildLNRepresentation(longNotes, totalTime) : null; + + (double[][] deltaKs, double[] jBarBase) = computeJBar(keyCount, totalTime, x, notesByColumn, baseCorners); + double[] jBar = interpValues(allCorners, baseCorners, jBarBase); + + double[] xBarBase = computeXBar(keyCount, totalTime, x, notesByColumn, activeColumns, baseCorners, cross); + double[] xBar = interpValues(allCorners, baseCorners, xBarBase); + + //Logger.Log($"C# xBarBase sample: {string.Join(", ", xBarBase.Take(10))}"); + + double[] pBarBase = computePBar(keyCount, totalTime, x, notes, lnRep, anchorBase, baseCorners); + double[] pBar = interpValues(allCorners, baseCorners, pBarBase); + + //Logger.Log($"C# pBarBase sample: {string.Join(", ", pBarBase.Take(10))}"); + + double[] aBarBase = computeABar(keyCount, totalTime, deltaKs, activeColumns, aCorners, baseCorners); + double[] aBar = interpValues(allCorners, aCorners, aBarBase); + + double[] rBarBase = computeRBar(keyCount, totalTime, x, notesByColumn, longNotesByTails, baseCorners); + double[] rBar = interpValues(allCorners, baseCorners, rBarBase); + + (double[] cStep, double[] ksStep) = computeCAndKs(keyCount, notes, keyUsage, baseCorners); + double[] cArr = stepInterp(allCorners, baseCorners, cStep); + double[] ksArr = stepInterp(allCorners, baseCorners, ksStep); + + double[] gaps = computeGaps(allCorners); + double[] effectiveWeights = new double[allCorners.Length]; + for (int i = 0; i < allCorners.Length; i++) + effectiveWeights[i] = cArr[i] * gaps[i]; + + double[] dAll = new double[allCorners.Length]; + + // Original sequential loop + // for (int i = 0; i < allCorners.Length; i++) + // { + // ... + // } + + // Parallel version for better performance + Parallel.For(0, allCorners.Length, i => + { + double abarExponent = 3.0 / Math.Max(ksArr[i], 1e-6); + double abarPow = aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], abarExponent); + double minCandidateContribution = 0.85 * jBar[i]; + double minCandidate = 8 + minCandidateContribution; + double minJ = Math.Min(jBar[i], minCandidate); + double jackComponent = abarPow * minJ; + double term1 = 0.4 * (jackComponent <= 0 ? 0 : Math.Pow(jackComponent, 1.5)); + + double scaledP = 0.8 * pBar[i]; + double jackPenalty = rBar[i] * 35.0; + double ratio = jackPenalty / (cArr[i] + 8); + double pComponent = scaledP + ratio; + double powerBase = (aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], 2.0 / 3.0)) * pComponent; + double term2 = 0.6 * (powerBase <= 0 ? 0 : Math.Pow(powerBase, 1.5)); + + double sumTerms = term1 + term2; + double s = sumTerms <= 0 ? 0 : Math.Pow(sumTerms, 2.0 / 3.0); + double numerator = abarPow * xBar[i]; + double denominator = xBar[i] + s + 1; + double tValue = denominator <= 0 ? 0 : numerator / denominator; + double sqrtComponent = Math.Sqrt(Math.Max(s, 0)); + double primaryImpact = 2.7 * sqrtComponent * (tValue <= 0 ? 0 : Math.Pow(tValue, 1.5)); + double secondaryImpact = s * 0.27; + + dAll[i] = primaryImpact + secondaryImpact; + }); + + double sr = finaliseDifficulty(dAll, effectiveWeights, notes, longNotes); + + //Logger.Log($"C# final SR: {sr}"); + + return sr; + } + + private static double computeHitLeniency(double overallDifficulty) + { + double leniency = 0.3 * Math.Sqrt((64.5 - Math.Ceiling(overallDifficulty * 3.0)) / 500.0); + double offset = leniency - 0.09; + + double scaledOffset = 0.6 * offset; + double adjustedWindow = scaledOffset + 0.09; + return Math.Min(leniency, adjustedWindow); + } + + private static (double[] allCorners, double[] baseCorners, double[] aCorners) buildCorners(int totalTime, List notes) + { + var baseSet = new HashSet(); + + foreach (var note in notes) + { + baseSet.Add(note.HeadTime); + if (note.IsLongNote) + baseSet.Add(note.TailTime); + } + + foreach (int value in baseSet.ToArray()) + { + baseSet.Add(value + 501); + baseSet.Add(value - 499); + baseSet.Add(value + 1); + } + + baseSet.Add(0); + baseSet.Add(totalTime); + + double[] baseCorners = baseSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray(); + + var aSet = new HashSet(); + + foreach (var note in notes) + { + aSet.Add(note.HeadTime); + if (note.IsLongNote) + aSet.Add(note.TailTime); + } + + foreach (int value in aSet.ToArray()) + { + aSet.Add(value + 1000); + aSet.Add(value - 1000); + } + + aSet.Add(0); + aSet.Add(totalTime); + + double[] aCorners = aSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray(); + + double[] allCorners = baseCorners.Concat(aCorners).Distinct().OrderBy(v => v).ToArray(); + return (allCorners, baseCorners, aCorners); + } + + private static bool[][] buildKeyUsage(int keyCount, int totalTime, List notes, double[] baseCorners) + { + bool[][] keyUsage = new bool[keyCount][]; + for (int i = 0; i < keyCount; i++) + keyUsage[i] = new bool[baseCorners.Length]; + + foreach (var note in notes) + { + int start = Math.Max(note.HeadTime - 150, 0); + int end = note.IsLongNote ? Math.Min(note.TailTime + 150, totalTime - 1) : Math.Min(note.HeadTime + 150, totalTime - 1); + + int left = lowerBound(baseCorners, start); + int right = lowerBound(baseCorners, end); + for (int idx = left; idx < right; idx++) + keyUsage[note.Column][idx] = true; + } + + return keyUsage; + } + + private static int[][] deriveActiveColumns(bool[][] keyUsage) + { + int length = keyUsage[0].Length; + int[][] active = new int[length][]; + + for (int i = 0; i < length; i++) + { + var list = new List(); + + for (int col = 0; col < keyUsage.Length; col++) + { + if (keyUsage[col][i]) + list.Add(col); + } + + active[i] = list.ToArray(); + } + + return active; + } + + private static double[][] buildKeyUsage400(int keyCount, int totalTime, List notes, double[] baseCorners) + { + double[][] usage = new double[keyCount][]; + for (int k = 0; k < keyCount; k++) + usage[k] = new double[baseCorners.Length]; + + const double base_contribution = 3.75; + const double falloff = 3.75 / (400.0 * 400.0); + + foreach (var note in notes) + { + int startTime = Math.Max(note.HeadTime, 0); + int endTime = note.IsLongNote ? Math.Min(note.TailTime, totalTime - 1) : note.HeadTime; + + int left400 = lowerBound(baseCorners, startTime - 400); + int left = lowerBound(baseCorners, startTime); + int right = lowerBound(baseCorners, endTime); + int right400 = lowerBound(baseCorners, endTime + 400); + + int duration = endTime - startTime; + double clampedDuration = Math.Min(duration, 1500); + double extension = clampedDuration / 150.0; + double contribution = base_contribution + extension; + + for (int idx = left; idx < right; idx++) usage[note.Column][idx] += contribution; + + for (int idx = left400; idx < left; idx++) + { + double offset = baseCorners[idx] - startTime; + double falloffContribution = falloff * Math.Pow(offset, 2); + double value = base_contribution - falloffContribution; + double clamped = Math.Max(value, 0); + usage[note.Column][idx] += clamped; + } + + for (int idx = right; idx < right400; idx++) + { + double offset = baseCorners[idx] - endTime; + double falloffContribution = falloff * Math.Pow(offset, 2); + double value = base_contribution - falloffContribution; + double clamped = Math.Max(value, 0); + usage[note.Column][idx] += clamped; + } + } + + return usage; + } + + #region LN计算 + + private static LNRepStruct buildLNRepresentation(List longNotes, int totalTime) + { + var diff = new Dictionary(); + + foreach (var note in longNotes) + { + int t0 = Math.Min(note.HeadTime + 60, note.TailTime); + int t1 = Math.Min(note.HeadTime + 120, note.TailTime); + + addToMap(diff, t0, 1.3); + addToMap(diff, t1, -0.3); + addToMap(diff, note.TailTime, -1); + } + + var pointsSet = new SortedSet { 0, totalTime }; + foreach (int key in diff.Keys) + pointsSet.Add(key); + + int[] points = pointsSet.ToArray(); + double[] cumulative = new double[points.Length]; + double[] values = new double[points.Length - 1]; + + double current = 0; + + for (int i = 0; i < points.Length - 1; i++) + { + if (diff.TryGetValue(points[i], out double delta)) + current += delta; + + double fallbackOffset = 0.5 * current; + double fallback = 2.5 + fallbackOffset; + double transformed = Math.Min(current, fallback); + values[i] = transformed; + + int length = points[i + 1] - points[i]; + double segment = length * transformed; + cumulative[i + 1] = cumulative[i] + segment; + } + + return new LNRepStruct(points, cumulative, values); + } + + private static double lnIntegral(LNRepStruct repStruct, int a, int b) + { + int[] points = repStruct.Points; + double[] cumulative = repStruct.Cumulative; + double[] values = repStruct.Values; + + int startIndex = upperBound(points, a) - 1; + int endIndex = upperBound(points, b) - 1; + + if (startIndex < 0) startIndex = 0; + if (endIndex < startIndex) endIndex = startIndex; + + double total = 0; + + if (startIndex == endIndex) + total = (b - a) * values[startIndex]; + else + { + total += (points[startIndex + 1] - a) * values[startIndex]; + total += cumulative[endIndex] - cumulative[startIndex + 1]; + total += (b - points[endIndex]) * values[endIndex]; + } + + return total; + } + + #endregion + + #region 计算核心 + + private static double[] computeAnchor(int keyCount, double[][] keyUsage400, double[] baseCorners) + { + double[] anchor = new double[baseCorners.Length]; + + for (int i = 0; i < baseCorners.Length; i++) + { + double[] counts = new double[keyCount]; + for (int k = 0; k < keyCount; k++) + counts[k] = keyUsage400[k][i]; + + Array.Sort(counts); + Array.Reverse(counts); + + double[] nonZero = counts.Where(c => c > 0).ToArray(); + + if (nonZero.Length <= 1) + { + anchor[i] = 0; + continue; + } + + double walk = 0; + double maxWalk = 0; + + for (int idx = 0; idx < nonZero.Length - 1; idx++) + { + double current = nonZero[idx]; + double next = nonZero[idx + 1]; + double ratio = next / current; + double offset = 0.5 - ratio; + double offsetPenalty = 4 * Math.Pow(offset, 2); + double damping = 1 - offsetPenalty; + walk += current * damping; + maxWalk += current; + } + + double value = maxWalk <= 0 ? 0 : walk / maxWalk; + anchor[i] = 1 + Math.Min(value - 0.18, 5 * Math.Pow(value - 0.22, 3)); + } + + return anchor; + } + + private static (double[][] deltaKs, double[] jBar) computeJBar(int keyCount, int totalTime, double x, List[] notesByColumn, double[] baseCorners) + { + const double default_delta = 1e9; + + double[][] deltaKs = new double[keyCount][]; + double[][] jKs = new double[keyCount][]; + + Parallel.For(0, keyCount, k => + { + deltaKs[k] = Enumerable.Repeat(default_delta, baseCorners.Length).ToArray(); + jKs[k] = new double[baseCorners.Length]; + + var columnNotes = notesByColumn[k]; + + for (int i = 0; i < columnNotes.Count - 1; i++) + { + var current = columnNotes[i]; + var next = columnNotes[i + 1]; + + int left = lowerBound(baseCorners, current.HeadTime); + int right = lowerBound(baseCorners, next.HeadTime); + + if (right <= left) + continue; + + double headGap = Math.Max(next.HeadTime - current.HeadTime, 1e-6); + double delta = 0.001 * headGap; + double deltaShift = Math.Abs(delta - 0.08); + double penalty = 0.15 + deltaShift; + double attenuation = Math.Pow(penalty, -4); + double nerfFactor = 7e-5 * attenuation; + double jackNerfer = 1 - nerfFactor; + + double xRoot = Math.Pow(x, 0.25); + double rootScale = 0.11 * xRoot; + double jackBase = delta + rootScale; + double inverseJack = Math.Pow(jackBase, -1); + double inverseDelta = 1.0 / delta; + double value = inverseDelta * inverseJack * jackNerfer; + + for (int idx = left; idx < right; idx++) + { + deltaKs[k][idx] = Math.Min(deltaKs[k][idx], delta); + jKs[k][idx] = value; + } + } + + jKs[k] = SmoothOnCorners(baseCorners, jKs[k], 500, 0.001, SmoothMode.Sum); + }); + // for (int k = 0; k < keyCount; k++) + + double[] jBar = new double[baseCorners.Length]; + + for (int idx = 0; idx < baseCorners.Length; idx++) + { + double numerator = 0; + double denominator = 0; + + for (int k = 0; k < keyCount; k++) + { + double v = Math.Max(jKs[k][idx], 0); + double weight = 1.0 / Math.Max(deltaKs[k][idx], 1e-9); + numerator += Math.Pow(v, 5) * weight; + denominator += weight; + } + + double combined = denominator <= 0 ? 0 : numerator / denominator; + jBar[idx] = Math.Pow(Math.Max(combined, 0), 0.2); + } + + return (deltaKs, jBar); + } + + private static double[] computeXBar(int keyCount, int totalTime, double x, List[] notesByColumn, int[][] activeColumns, double[] baseCorners, double[] cross) + { + double[][] xKs = new double[keyCount + 1][]; + double[][] fastCross = new double[keyCount + 1][]; + + for (int i = 0; i < xKs.Length; i++) + { + xKs[i] = new double[baseCorners.Length]; + fastCross[i] = new double[baseCorners.Length]; + } + + // Parallel.For(0, keyCount + 1, k => + Parallel.For(0, keyCount + 1, k => + { + var pair = new List(); + + if (k == 0) + pair.AddRange(notesByColumn[0]); + else if (k == keyCount) + pair.AddRange(notesByColumn[keyCount - 1]); + else + { + pair.AddRange(notesByColumn[k - 1]); + pair.AddRange(notesByColumn[k]); + } + + pair.Sort(note_comparer); + if (pair.Count < 2) return; + + for (int i = 1; i < pair.Count; i++) + { + var prev = pair[i - 1]; + var current = pair[i]; + int left = lowerBound(baseCorners, prev.HeadTime); + int right = lowerBound(baseCorners, current.HeadTime); + if (right <= left) continue; + + double delta = 0.001 * Math.Max(current.HeadTime - prev.HeadTime, 1e-6); + double val = 0.16 * Math.Pow(Math.Max(x, delta), -2); + + int idxStart = Math.Min(left, baseCorners.Length - 1); + int idxEnd = Math.Min(Math.Max(right, 0), baseCorners.Length - 1); + + bool condition1 = !contains(activeColumns[idxStart], k - 1) && !contains(activeColumns[idxEnd], k - 1); + bool condition2 = !contains(activeColumns[idxStart], k) && !contains(activeColumns[idxEnd], k); + if (condition1 || condition2) + val *= 1 - cross[Math.Min(k, cross.Length - 1)]; + + for (int idx = left; idx < right; idx++) + { + xKs[k][idx] = val; + fastCross[k][idx] = Math.Max(0, (0.4 * Math.Pow(Math.Max(Math.Max(delta, 0.06), 0.75 * x), -2)) - 80); + } + } + }); + // for (int k = 0; k <= keyCount; k++) + + double[] xBase = new double[baseCorners.Length]; + + for (int idx = 0; idx < baseCorners.Length; idx++) + { + double sum = 0; + for (int k = 0; k <= keyCount; k++) + sum += cross[Math.Min(k, cross.Length - 1)] * xKs[k][idx]; + + for (int k = 0; k < keyCount; k++) + { + double leftVal = fastCross[k][idx] * cross[Math.Min(k, cross.Length - 1)]; + double rightVal = fastCross[k + 1][idx] * cross[Math.Min(k + 1, cross.Length - 1)]; + sum += Math.Sqrt(Math.Max(leftVal * rightVal, 0)); + } + + xBase[idx] = sum; + } + + return SmoothOnCorners(baseCorners, xBase, 500, 0.001, SmoothMode.Sum); + } + + private static double[] computePBar(int keyCount, int totalTime, double x, List notes, LNRepStruct? lnRep, double[] anchor, double[] baseCorners) + { + double[] pStep = new double[baseCorners.Length]; + + for (int i = 0; i < notes.Count - 1; i++) + { + var leftNote = notes[i]; + var rightNote = notes[i + 1]; + + int deltaTime = rightNote.HeadTime - leftNote.HeadTime; + + if (deltaTime <= 0) + { + double invX = 1.0 / Math.Max(x, 1e-6); + double spikeInnerBase = 4 * invX; + double spikeInner = spikeInnerBase - 24; + double spikeBase = 0.02 * spikeInner; + if (spikeBase <= 0) + continue; + + double spikeMagnitude = Math.Pow(spikeBase, 0.25); + double spike = 1000 * spikeMagnitude; + int leftIdx = lowerBound(baseCorners, leftNote.HeadTime); + int rightIdx = upperBound(baseCorners, leftNote.HeadTime); + for (int idx = leftIdx; idx < rightIdx; idx++) + pStep[idx] += spike; + + continue; + } + + int left = lowerBound(baseCorners, leftNote.HeadTime); + int right = lowerBound(baseCorners, rightNote.HeadTime); + if (right <= left) continue; + + double delta = 0.001 * deltaTime; + double v = 1; + if (lnRep.HasValue) + v += LnIntegralMultiplier * 0.001 * lnIntegral(lnRep.Value, leftNote.HeadTime, rightNote.HeadTime); + + double booster = streamBooster(delta); + double effective = Math.Max(booster, v); + + double inc; + + if (delta < 2 * x / 3) + { + double invX = 1.0 / Math.Max(x, 1e-6); + double halfX = x / 2.0; + double deltaCentre = delta - halfX; + double deltaTerm = 24 * invX * Math.Pow(deltaCentre, 2); + double inner = 0.08 * invX * (1 - deltaTerm); + double innerClamp = Math.Max(inner, 0); + double magnitude = Math.Pow(innerClamp, 0.25); + inc = magnitude / Math.Max(delta, 1e-6) * effective; + } + else + { + double invX = 1.0 / Math.Max(x, 1e-6); + double centreTerm = Math.Pow(x / 6.0, 2); + double deltaTerm = 24 * invX * centreTerm; + double inner = 0.08 * invX * (1 - deltaTerm); + double innerClamp = Math.Max(inner, 0); + double magnitude = Math.Pow(innerClamp, 0.25); + inc = magnitude / Math.Max(delta, 1e-6) * effective; + } + + for (int idx = left; idx < right; idx++) + { + double doubled = inc * 2; + double limit = Math.Max(inc, doubled - 10); + double anchored = inc * anchor[idx]; + double contribution = Math.Min(anchored, limit); + + pStep[idx] += contribution; + } + } + + return SmoothOnCorners(baseCorners, pStep, 500, 0.001, SmoothMode.Sum); + } + + private static double[] computeABar(int keyCount, int totalTime, double[][] deltaKs, int[][] activeColumns, double[] aCorners, double[] baseCorners) + { + double[] aStep = Enumerable.Repeat(1.0, aCorners.Length).ToArray(); + + for (int i = 0; i < aCorners.Length; i++) + { + int idx = lowerBound(baseCorners, aCorners[i]); + idx = Math.Min(idx, baseCorners.Length - 1); + int[] cols = activeColumns[idx]; + if (cols.Length < 2) continue; + + for (int j = 0; j < cols.Length - 1; j++) + { + int c0 = cols[j]; + int c1 = cols[j + 1]; + + double deltaGap = Math.Abs(deltaKs[c0][idx] - deltaKs[c1][idx]); + double maxDelta = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]); + double offset = Math.Max(maxDelta - 0.11, 0); + double offsetContribution = 0.4 * offset; + double diff = deltaGap + offsetContribution; + + if (diff < 0.02) + { + double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]); + double factorContribution = 0.5 * factorBase; + double factor = 0.75 + factorContribution; + aStep[i] *= Math.Min(factor, 1); + } + else if (diff < 0.07) + { + double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]); + double growth = 5 * diff; + double factorContribution = 0.5 * factorBase; + double factor = 0.65 + growth + factorContribution; + aStep[i] *= Math.Min(factor, 1); + } + } + } + + return SmoothOnCorners(aCorners, aStep, 250, 0, SmoothMode.Average); + } + + private static double[] computeRBar(int keyCount, int totalTime, double x, List[] notesByColumn, List tailNotes, double[] baseCorners) + { + if (tailNotes.Count < 2) return new double[baseCorners.Length]; + + double[] iList = new double[tailNotes.Count]; + + for (int idx = 0; idx < tailNotes.Count; idx++) + { + var note = tailNotes[idx]; + var next = findNextColumnNote(note, notesByColumn); + double nextHead = next?.HeadTime ?? 1_000_000_000; + + double ih = 0.001 * Math.Abs(note.TailTime - note.HeadTime - 80) / Math.Max(x, 1e-6); + double it = 0.001 * Math.Abs(nextHead - note.TailTime - 80) / Math.Max(x, 1e-6); + + iList[idx] = 2 / (2 + Math.Exp(-5 * (ih - 0.75)) + Math.Exp(-5 * (it - 0.75))); + } + + double[] rStep = new double[baseCorners.Length]; + + for (int idx = 0; idx < tailNotes.Count - 1; idx++) + { + var current = tailNotes[idx]; + var next = tailNotes[idx + 1]; + + int left = lowerBound(baseCorners, current.TailTime); + int right = lowerBound(baseCorners, next.TailTime); + if (right <= left) continue; + + double delta = 0.001 * Math.Max(next.TailTime - current.TailTime, 1e-6); + double invSqrtDelta = Math.Pow(delta, -0.5); + double invX = 1.0 / Math.Max(x, 1e-6); + double blend = iList[idx] + iList[idx + 1]; + double blendContribution = 0.8 * blend; + double modulation = 1 + blendContribution; + double strength = 0.08 * invSqrtDelta * invX * modulation; + + for (int baseIdx = left; baseIdx < right; baseIdx++) + rStep[baseIdx] = Math.Max(rStep[baseIdx], strength); + } + + return SmoothOnCorners(baseCorners, rStep, 500, 0.001, SmoothMode.Sum); + } + + #endregion + + private static (double[] cStep, double[] ksStep) computeCAndKs(int keyCount, List notes, bool[][] keyUsage, double[] baseCorners) + { + double[] cStep = new double[baseCorners.Length]; + double[] ksStep = new double[baseCorners.Length]; + + var noteTimesList = new List(notes.Count); + foreach (var note in notes) + noteTimesList.Add(note.HeadTime); + noteTimesList.Sort(); + double[] noteTimes = noteTimesList.ToArray(); + + for (int idx = 0; idx < baseCorners.Length; idx++) + { + double left = baseCorners[idx] - 500; + double right = baseCorners[idx] + 500; + + int leftIndex = lowerBound(noteTimes, left); + int rightIndex = lowerBound(noteTimes, right); + cStep[idx] = Math.Max(rightIndex - leftIndex, 0); + + int activeCount = 0; + + for (int col = 0; col < keyCount; col++) + { + if (keyUsage[col][idx]) + activeCount++; + } + + ksStep[idx] = Math.Max(activeCount, 1); + } + + return (cStep, ksStep); + } + + private static double[] computeGaps(double[] corners) + { + if (corners.Length == 0) + return Array.Empty(); + + double[] gaps = new double[corners.Length]; + + if (corners.Length == 1) + { + gaps[0] = 0; + return gaps; + } + + gaps[0] = (corners[1] - corners[0]) / 2.0; + gaps[^1] = (corners[^1] - corners[^2]) / 2.0; + + for (int i = 1; i < corners.Length - 1; i++) gaps[i] = (corners[i + 1] - corners[i - 1]) / 2.0; + + return gaps; + } + + private static double finaliseDifficulty(List difficulties, List weights, List notes, List longNotes) + { + var combined = difficulties.Zip(weights, (d, w) => (d, w)).OrderBy(pair => pair.d).ToList(); + if (combined.Count == 0) + return 0; + + double[] sortedD = combined.Select(p => p.d).ToArray(); + double[] sortedWeights = combined.Select(p => Math.Max(p.w, 0)).ToArray(); + + double[] cumulative = new double[sortedWeights.Length]; + cumulative[0] = sortedWeights[0]; + for (int i = 1; i < sortedWeights.Length; i++) + cumulative[i] = cumulative[i - 1] + sortedWeights[i]; + + double totalWeight = Math.Max(cumulative[^1], 1e-9); + double[] norm = cumulative.Select(v => v / totalWeight).ToArray(); + + double[] targets = { 0.945, 0.935, 0.925, 0.915, 0.845, 0.835, 0.825, 0.815 }; + double percentile93 = 0; + double percentile83 = 0; + + for (int i = 0; i < 4; i++) + { + int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1); + percentile93 += sortedD[index]; + } + + percentile93 /= 4.0; + + for (int i = 4; i < 8; i++) + { + int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1); + percentile83 += sortedD[index]; + } + + percentile83 /= 4.0; + + //Logger.Log($"C# percentile93: {percentile93}, percentile83: {percentile83}"); + + double weightedMeanNumerator = 0; + for (int i = 0; i < sortedD.Length; i++) + weightedMeanNumerator += Math.Pow(sortedD[i], 5) * sortedWeights[i]; + + double weightedMean = Math.Pow(Math.Max(weightedMeanNumerator / totalWeight, 0), 0.2); + + //Logger.Log($"C# weightedMean: {weightedMean}"); + + double topComponent = 0.25 * 0.88 * percentile93; + double middleComponent = 0.2 * 0.94 * percentile83; + double meanComponent = 0.55 * weightedMean; + double sr = topComponent + middleComponent + meanComponent; + sr = Math.Pow(sr, 1.0) / Math.Pow(8, 1.0) * 8; + + //Logger.Log($"C# sr before notes adjustment: {sr}"); + + double totalNotes = notes.Count; + + foreach (var ln in longNotes) + { + // TODO: LN折算为额外note数量的算法 + double len = Math.Min(ln.TailTime - ln.HeadTime, 1000); + totalNotes += 0.5 * (len / 200.0); + } + + //Logger.Log($"C# totalNotes: {totalNotes}"); + + sr *= totalNotes / (totalNotes + 60); + sr = rescaleHigh(sr); + sr *= 0.975; + + //Logger.Log($"C# final SR: {sr}"); + + return sr; + } + + private static double finaliseDifficulty(double[] difficulties, double[] weights, List notes, List longNotes) + { + return finaliseDifficulty(difficulties.ToList(), weights.ToList(), notes, longNotes); + } + + private static NoteStruct? findNextColumnNote(NoteStruct note, List[] notesByColumn) + { + var columnNotes = notesByColumn[note.Column]; + int index = columnNotes.IndexOf(note); + if (index >= 0 && index + 1 < columnNotes.Count) + return columnNotes[index + 1]; + + return null; + } + + private static double[] interpValues(double[] newX, double[] oldX, double[] oldVals) + { + double[] result = new double[newX.Length]; + + for (int i = 0; i < newX.Length; i++) + { + double x = newX[i]; + + if (x <= oldX[0]) + { + result[i] = oldVals[0]; + continue; + } + + if (x >= oldX[^1]) + { + result[i] = oldVals[^1]; + continue; + } + + int idx = lowerBound(oldX, x); + + if (idx < oldX.Length && nearlyEquals(oldX[idx], x)) + { + result[i] = oldVals[idx]; + continue; + } + + int prev = Math.Max(idx - 1, 0); + double x0 = oldX[prev]; + double x1 = oldX[idx]; + double y0 = oldVals[prev]; + double y1 = oldVals[idx]; + double deltaY = y1 - y0; + double deltaX = x - x0; + double numerator = deltaY * deltaX; + double fraction = numerator / (x1 - x0); + result[i] = y0 + fraction; + } + + return result; + } + + private static double[] stepInterp(double[] newX, double[] oldX, double[] oldVals) + { + double[] result = new double[newX.Length]; + + for (int i = 0; i < newX.Length; i++) + { + int idx = upperBound(oldX, newX[i]) - 1; + if (idx < 0) + idx = 0; + result[i] = oldVals[Math.Min(idx, oldVals.Length - 1)]; + } + + return result; + } + + private static double[] SmoothOnCorners(double[] positions, double[] values, double window, double scale, SmoothMode mode) + { + if (positions.Length == 0) + return Array.Empty(); + + double[] cumulative = buildCumulative(positions, values); + double[] output = new double[positions.Length]; + + for (int i = 0; i < positions.Length; i++) + { + double s = positions[i]; + double a = Math.Max(s - window, positions[0]); + double b = Math.Min(s + window, positions[^1]); + + if (b <= a) + { + output[i] = 0; + continue; + } + + double integral = queryIntegral(positions, cumulative, values, b) - queryIntegral(positions, cumulative, values, a); + + if (mode == SmoothMode.Average) + output[i] = integral / Math.Max(b - a, 1e-9); + else + output[i] = integral * scale; + } + + return output; + } + + private static double[] buildCumulative(double[] positions, double[] values) + { + double[] cumulative = new double[positions.Length]; + + for (int i = 1; i < positions.Length; i++) + { + double width = positions[i] - positions[i - 1]; + double increment = values[i - 1] * width; + cumulative[i] = cumulative[i - 1] + increment; + } + + return cumulative; + } + + private static double queryIntegral(double[] positions, double[] cumulative, double[] values, double point) + { + if (point <= positions[0]) + return 0; + if (point >= positions[^1]) + return cumulative[^1]; + + int idx = lowerBound(positions, point); + if (idx < positions.Length && nearlyEquals(positions[idx], point)) + return cumulative[idx]; + + int prev = Math.Max(idx - 1, 0); + double delta = point - positions[prev]; + double contribution = values[prev] * delta; + + return cumulative[prev] + contribution; + } + + private static double streamBooster(double delta) + { + double inv = 7.5 / Math.Max(delta, 1e-6); + if (inv <= 160 || inv >= 360) + return 1; + + double shifted = inv - 160; + double distance = inv - 360; + double adjustment = 1.7e-7 * shifted * Math.Pow(distance, 2); + + return 1 + adjustment; + } + + private static bool contains(int[] array, int target) + { + if (target < 0) + return false; + + for (int i = 0; i < array.Length; i++) + { + if (array[i] == target) + return true; + } + + return false; + } + + private static int lowerBound(double[] array, double value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] < value) + left = mid + 1; + else + right = mid; + } + + return left; + } + + private static int lowerBound(double[] array, int value) + { + return lowerBound(array, (double)value); + } + + private static int upperBound(int[] array, int value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] <= value) + left = mid + 1; + else + right = mid; + } + + return left; + } + + private static int upperBound(double[] array, double value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] <= value) + left = mid + 1; + else + right = mid; + } + + return left; + } + + private static int bisectLeft(double[] array, double value) + { + int left = 0; + int right = array.Length; + + while (left < right) + { + int span = right - left; + int mid = left + (span >> 1); + if (array[mid] < value) + left = mid + 1; + else + right = mid; + } + + return left; + } + + private static double safePow(double value, double exponent) + { + if (value <= 0) + return 0; + + double result = Math.Pow(value, exponent); + + return result; + } + + private static double rescaleHigh(double sr) + { + double threshold = RescaleHighThreshold; + double excess = sr - threshold; + double normalized = excess / 1.2; + double softened = threshold + normalized; + + return sr <= threshold ? sr : softened; + } + + private static int clamp(int value, int min, int max) + { + return Math.Min(Math.Max(value, min), max); + } + + private static bool nearlyEquals(double a, double b, double epsilon = 1e-9) + { + return Math.Abs(a - b) <= epsilon; + } + + private static int compareNotes(NoteStruct a, NoteStruct b) + { + int headCompare = a.HeadTime.CompareTo(b.HeadTime); + return headCompare != 0 ? headCompare : a.Column.CompareTo(b.Column); + } + + private static void addToMap(Dictionary map, int key, double value) + { + if (!map.TryAdd(key, value)) + map[key] += value; + } + + private enum SmoothMode + { + Sum, + Average + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRErrorCodes.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRErrorCodes.cs new file mode 100644 index 0000000000..857d90c27b --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/Analysis/SRErrorCodes.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Mania.LAsEZMania.Analysis +{ + /// + /// SR计算错误代码定义 + /// + public static class SRErrorCodes + { + /// + /// 错误代码与消息映射 + /// + public static readonly Dictionary ERROR_MESSAGES = new Dictionary + { + [-2.0] = "路径字符串无效", + [-3.0] = "文件打开失败", + [-4.0] = "解析失败", + [-5.0] = "数据非法", + [-6.0] = "SR计算失败", + [-7.0] = "SR计算panic" + }; + + /// + /// 获取错误消息 + /// + /// 错误码 + /// 错误消息 + public static string GetErrorMessage(double code) + { + return ERROR_MESSAGES.GetValueOrDefault(code, "未知错误"); + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/CustomVisibilityContainer.cs b/osu.Game.Rulesets.Mania/LAsEzMania/CustomVisibilityContainer.cs new file mode 100644 index 0000000000..db061cf27d --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/CustomVisibilityContainer.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public partial class CustomVisibilityContainer : VisibilityContainer + { + public CustomVisibilityContainer() + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + } + + protected override void PopIn() + { + this.FadeIn(); + } + + protected override void PopOut() + { + this.FadeOut(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzEffectHelper.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzEffectHelper.cs new file mode 100644 index 0000000000..148f84b4e6 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzEffectHelper.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public static class EzEffectHelper + { + public static void ApplyScaleAnimation(Drawable target, bool wasIncrease, bool wasMiss, float increaseFactor, float decreaseFactor, float increaseDuration, float decreaseDuration) + { + float newScaleValue = Math.Clamp(target.Scale.X * (wasIncrease ? increaseFactor : decreaseFactor), 0.5f, 3f); + Vector2 newScale = new Vector2(newScaleValue); + + target + .ScaleTo(newScale, increaseDuration, Easing.OutQuint) + .Then() + .ScaleTo(Vector2.One, decreaseDuration, Easing.OutQuint); + + if (wasMiss) + target.FlashColour(Color4.Red, decreaseDuration, Easing.OutQuint); + } + + public static void ApplyBounceAnimation(Drawable target, bool wasIncrease, bool wasMiss, float increaseFactor, float decreaseFactor, float increaseDuration, float decreaseDuration) + { + float factor = Math.Clamp(wasIncrease ? 10 * increaseFactor : -10 * decreaseFactor, -100f, 100f); + + target + .MoveToY(factor, increaseDuration / 2, Easing.OutBounce) + .Then() + .MoveToY(0, decreaseDuration, Easing.OutBounce); + + if (wasMiss) + target.FlashColour(Color4.Red, decreaseDuration, Easing.OutQuint); + } + } +} + +// float factor = 0; +// +// switch (AnimationOrigin.Value) +// { +// case OriginOptions.TopCentre: +// factor = Math.Clamp(wasIncrease ? 10 * IncreaseScale.Value : -50, -100f, 100f); // 向下跳 +// break; +// +// case OriginOptions.BottomCentre: +// factor = Math.Clamp(wasIncrease ? -10 * IncreaseScale.Value : 50, -100f, 100f); // 向上跳 +// break; +// +// case OriginOptions.Centre: +// factor = Math.Clamp(wasIncrease ? 10 * IncreaseScale.Value : -10 * IncreaseScale.Value, -100f, 100f); // 上下跳 +// break; +// } diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzHitTimingGraphByColumn.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzHitTimingGraphByColumn.cs new file mode 100644 index 0000000000..c2eed4d4ea --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzHitTimingGraphByColumn.cs @@ -0,0 +1,207 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + /// + /// A graph which displays the distribution of hit timing for each column in a series of s. + /// + public partial class EzHitTimingGraphByColumn : CompositeDrawable + { + /// + /// The currently displayed hit events. + /// + private readonly IReadOnlyList hitEvents; + + /// + /// The number of columns in the beatmap. + /// + private readonly int columnCount; + + /// + /// Creates a new . + /// + /// The s to display the timing distribution of. + /// The number of columns in the beatmap. + public EzHitTimingGraphByColumn(IReadOnlyList hitEvents, int columnCount) + { + this.hitEvents = hitEvents; + this.columnCount = columnCount; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + if (hitEvents.Count == 0) + return; + + var columnGraphs = new List(); + + for (int i = 0; i < columnCount; i++) + { + var columnEvents = hitEvents.Where(h => ((ManiaHitObject)h.HitObject).Column == i).ToList(); + + if (columnEvents.Count == 0) + continue; + + int perfect = columnEvents.Count(h => h.Result == HitResult.Perfect); + int great = columnEvents.Count(h => h.Result == HitResult.Great); + int good = columnEvents.Count(h => h.Result == HitResult.Good); + int ok = columnEvents.Count(h => h.Result == HitResult.Ok); + int meh = columnEvents.Count(h => h.Result == HitResult.Meh); + int miss = columnEvents.Count(h => h.Result == HitResult.Miss); + int total = perfect + great + good + ok + meh + miss; + + int perfectLate = columnEvents.Count(h => h.Result == HitResult.Perfect && h.TimeOffset > 0); + int greatLate = columnEvents.Count(h => h.Result == HitResult.Great && h.TimeOffset > 0); + int goodLate = columnEvents.Count(h => h.Result == HitResult.Good && h.TimeOffset > 0); + int okLate = columnEvents.Count(h => h.Result == HitResult.Ok && h.TimeOffset > 0); + int mehLate = columnEvents.Count(h => h.Result == HitResult.Meh && h.TimeOffset > 0); + int missLate = columnEvents.Count(h => h.Result == HitResult.Miss && h.TimeOffset > 0); + int totalLate = perfectLate + greatLate + goodLate + okLate + mehLate + missLate; + + int perfectEarly = columnEvents.Count(h => h.Result == HitResult.Perfect && h.TimeOffset <= 0); + int greatEarly = columnEvents.Count(h => h.Result == HitResult.Great && h.TimeOffset <= 0); + int goodEarly = columnEvents.Count(h => h.Result == HitResult.Good && h.TimeOffset <= 0); + int okEarly = columnEvents.Count(h => h.Result == HitResult.Ok && h.TimeOffset <= 0); + int mehEarly = columnEvents.Count(h => h.Result == HitResult.Meh && h.TimeOffset <= 0); + int missEarly = columnEvents.Count(h => h.Result == HitResult.Miss && h.TimeOffset <= 0); + int totalEarly = perfectEarly + greatEarly + goodEarly + okEarly + mehEarly + missEarly; + + var totalColor = Color4Extensions.FromHex(@"ff8c00"); + var perfectColor = Color4Extensions.FromHex(@"99eeff"); + var greatColor = Color4Extensions.FromHex(@"66ccff"); + var goodColor = Color4Extensions.FromHex(@"b3d944"); + var okColor = Color4Extensions.FromHex(@"88b300"); + var mehColor = Color4Extensions.FromHex(@"ffcc22"); + var missColor = Color4Extensions.FromHex(@"ed1121"); + + var columnContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical + }; + + columnContainer.Add(new HitEventTimingDistributionGraph(columnEvents) + { + RelativeSizeAxes = Axes.X, + Height = 100, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new osuTK.Vector2(0.96f), + Margin = new MarginPadding { Top = 5, Bottom = 10 } + }); + + columnContainer.Add(new OsuSpriteText + { + Text = $"Column {i + 1}", + Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE, weight: FontWeight.Bold), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }); + + columnContainer.Add(new AverageHitError(columnEvents) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new osuTK.Vector2(0.96f) + }); + + columnContainer.Add(new UnstableRate(columnEvents) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new osuTK.Vector2(0.96f) + }); + + columnContainer.Add(new SimpleStatisticTable(3, new[] + { + new EzJudgementsItem(total.ToString(), "Total", totalColor), + new EzJudgementsItem(totalLate.ToString(), "Total (Late)", ColourInfo.GradientVertical(Colour4.White, totalColor)), + new EzJudgementsItem(totalEarly.ToString(), "Total (Early)", ColourInfo.GradientVertical(totalColor, Colour4.White)), + new EzJudgementsItem(perfect.ToString(), "Perfect", perfectColor), + new EzJudgementsItem(perfectLate.ToString(), "Perfect (Late)", ColourInfo.GradientVertical(Colour4.White, perfectColor)), + new EzJudgementsItem(perfectEarly.ToString(), "Perfect (Early)", ColourInfo.GradientVertical(perfectColor, Colour4.White)), + new EzJudgementsItem(great.ToString(), "Great", greatColor), + new EzJudgementsItem(greatLate.ToString(), "Great (Late)", ColourInfo.GradientVertical(Colour4.White, greatColor)), + new EzJudgementsItem(greatEarly.ToString(), "Great (Early)", ColourInfo.GradientVertical(greatColor, Colour4.White)), + new EzJudgementsItem(good.ToString(), "Good", goodColor), + new EzJudgementsItem(goodLate.ToString(), "Good (Late)", ColourInfo.GradientVertical(Colour4.White, goodColor)), + new EzJudgementsItem(goodEarly.ToString(), "Good (Early)", ColourInfo.GradientVertical(goodColor, Colour4.White)), + new EzJudgementsItem(ok.ToString(), "Ok", okColor), + new EzJudgementsItem(okLate.ToString(), "Ok (Late)", ColourInfo.GradientVertical(Colour4.White, okColor)), + new EzJudgementsItem(okEarly.ToString(), "Ok (Early)", ColourInfo.GradientVertical(okColor, Colour4.White)), + new EzJudgementsItem(meh.ToString(), "Meh", mehColor), + new EzJudgementsItem(mehLate.ToString(), "Meh (Late)", ColourInfo.GradientVertical(Colour4.White, mehColor)), + new EzJudgementsItem(mehEarly.ToString(), "Meh (Early)", ColourInfo.GradientVertical(mehColor, Colour4.White)), + new EzJudgementsItem(miss.ToString(), "Miss", missColor), + new EzJudgementsItem(missLate.ToString(), "Miss (Late)", ColourInfo.GradientVertical(Colour4.White, missColor)), + new EzJudgementsItem(missEarly.ToString(), "Miss (Early)", ColourInfo.GradientVertical(missColor, Colour4.White)) + }) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new osuTK.Vector2(0.96f) + }); + + columnGraphs.Add(columnContainer); + } + + const int columns_per_row = 2; + int rowCount = (columnGraphs.Count + columns_per_row - 1) / columns_per_row; // 向上取整得到行数 + + var gridContent = new Drawable[rowCount][]; + + for (int i = 0; i < rowCount; i++) + { + gridContent[i] = new Drawable[columns_per_row]; + + for (int j = 0; j < columns_per_row; j++) + { + int index = i * columns_per_row + j; + + if (index < columnGraphs.Count) + { + gridContent[i][j] = columnGraphs[index]; + + if (columnCount % 2 == 1 && i == rowCount - 1 && j == 0) + { + var position = gridContent[i][j].Position; + position.X += 228; + gridContent[i][j].Position = position; + } + } + else + gridContent[i][j] = Empty(); + } + } + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = Enumerable.Range(0, rowCount).Select(_ => new Dimension(GridSizeMode.AutoSize)).ToArray(), + ColumnDimensions = Enumerable.Range(0, columns_per_row).Select(_ => new Dimension()).ToArray(), + Content = gridContent + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzJudgementText.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzJudgementText.cs new file mode 100644 index 0000000000..15cefe0d48 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzJudgementText.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public abstract partial class EzJudgementText : CompositeDrawable //, ISerialisableDrawable + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + // public bool UsesFixedAnchor { get; set; } + + protected EzJudgementText(HitResult result) + { + Result = result; + } + + protected EzJudgementText() + { + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(JudgementText = CreateJudgementText()); + + JudgementText.Colour = colours.ForHitResult(Result); + JudgementText.Text = Result.GetDescription().ToUpperInvariant(); + } + + protected abstract SpriteText CreateJudgementText(); + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounter.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounter.cs new file mode 100644 index 0000000000..5073c7f413 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounter.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public partial class EzKeyCounter : KeyCounter + { + private Circle inputIndicator = null!; + private OsuSpriteText keyNameText = null!; + private OsuSpriteText countText = null!; + + private const float line_height = 3; + private const float name_font_size = 10; + private const float count_font_size = 14; + private const float scale_factor = 1.5f; + + private const float indicator_press_offset = 4; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public bool ShowKeyName { get; set; } = false; + + // private readonly string keyDisplayName; + + public EzKeyCounter(InputTrigger trigger) + : base(trigger) + { + // this.keyDisplayName = keyDisplayName; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + inputIndicator = new Circle + { + RelativeSizeAxes = Axes.X, + Height = line_height * scale_factor, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = line_height * scale_factor + indicator_press_offset }, + Children = new Drawable[] + { + keyNameText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Torus.With(size: name_font_size * scale_factor, weight: FontWeight.Bold), + Colour = colours.Blue0, + // Text = ShowKeyName ? keyDisplayName : Trigger.Name + Text = Trigger.Name + }, + countText = new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.Torus.With(size: count_font_size * scale_factor, weight: FontWeight.Bold), + }, + } + }, + }; + + Height = 30 * scale_factor; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + CountPresses.BindValueChanged(e => countText.Text = e.NewValue.ToString(@"#,0"), true); + } + + protected override void Activate(bool forwardPlayback = true) + { + base.Activate(forwardPlayback); + + keyNameText + .FadeColour(Colour4.White, 10, Easing.OutQuint); + + inputIndicator + .FadeIn(10, Easing.OutQuint) + .MoveToY(0) + .Then() + .MoveToY(indicator_press_offset, 60, Easing.OutQuint); + } + + protected override void Deactivate(bool forwardPlayback = true) + { + base.Deactivate(forwardPlayback); + + keyNameText + .FadeColour(colours.Blue0, 200, Easing.OutQuart); + + inputIndicator + .MoveToY(0, 250, Easing.OutQuart) + .FadeTo(0.5f, 250, Easing.OutQuart); + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounterPro.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounterPro.cs new file mode 100644 index 0000000000..ab0133f975 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzKeyCounterPro.cs @@ -0,0 +1,59 @@ +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public partial class ManiaActionInputTrigger : InputTrigger + { + public ManiaActionInputTrigger(string actionName) + : base(actionName) + { + } + } + + public partial class EzKeyCounterPro : Container + { + public readonly InputTrigger Trigger; + public IBindable CountPresses => Trigger.ActivationCount; + + public IBindable IsActive => isActive; + + private readonly Bindable isActive = new BindableBool(); + + public EzKeyCounterPro(ManiaAction action) + { + // 将 ManiaAction 转换为 InputTrigger + Trigger = new ManiaActionInputTrigger(action.ToString()); + + Trigger.OnActivate += Activate; + Trigger.OnDeactivate += Deactivate; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (Trigger.IsActive) + Activate(); + } + + protected virtual void Activate(bool forwardPlayback = true) + { + isActive.Value = true; + } + + protected virtual void Deactivate(bool forwardPlayback = true) + { + isActive.Value = false; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + Trigger.OnActivate -= Activate; + Trigger.OnDeactivate -= Deactivate; + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaEnums.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaEnums.cs new file mode 100644 index 0000000000..97ada4aff1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaEnums.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.ComponentModel; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public enum EzManiaScrollingStyle + { + // [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.ScrollingDirectionUp))] + [Description("40速 通配速度风格(不可用)")] + ScrollSpeedStyle = 0, + + // [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.ScrollingDirectionDown))] + [Description("(ms) For Default Judgement Line")] + ScrollTimeStyle = 1, + + [Description("(ms) For Real Judgement Line")] + ScrollTimeForRealJudgement = 2, + + [Description("(ms) For Screen Bottom")] + ScrollTimeStyleFixed = 3, + + // [Obsolete("Renamed to ScrollTimeStyleFixed. Kept for backward compatibility with stored settings.")] + // ScrollTimeStyle = ScrollTimeStyleFixed, + // + // [Obsolete("Renamed to ScrollTimeStyleFixed. Kept for backward compatibility with stored settings.")] + // ScrollTimeStyleFixed = ScrollTimeStyleFixed, + } + + public enum ManiaHitResult + { + [Description("Pool")] + Pool, + + [Description("Miss")] + Miss, + + [Description("50")] + Meh, + + [Description("100")] + Ok, + + [Description("200")] + Good, + + [Description("300")] + Great, + + [Description("Perfect")] + Perfect + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitModeConvertor.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitModeConvertor.cs new file mode 100644 index 0000000000..cddb1f21d8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitModeConvertor.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings.Sections.Gameplay; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Configuration; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public class HitWindowTemplate + { + public double TemplatePerfect { get; set; } + public double TemplateGreat { get; set; } + public double TemplateGood { get; set; } + public double TemplateOk { get; set; } + public double TemplateMeh { get; set; } + public double TemplateMiss { get; set; } + } + + public static class HitWindowTemplateDictionary + { + private static readonly Dictionary templates = new Dictionary + { + ["EZ2AC"] = new HitWindowTemplate + { + TemplatePerfect = 22, + TemplateGreat = 32, + TemplateGood = 64, + TemplateOk = 80, + TemplateMeh = 100, + TemplateMiss = 120 + }, + ["IIDX"] = new HitWindowTemplate + { + TemplatePerfect = 20, + TemplateGreat = 40, + TemplateGood = 60, + TemplateOk = 80, + TemplateMeh = 100, + TemplateMiss = 120 + }, + ["Melody"] = new HitWindowTemplate + { + TemplatePerfect = 20, + TemplateGreat = 40, + TemplateGood = 60, + TemplateOk = 80, + TemplateMeh = 100, + TemplateMiss = 120 + } + }; + + public static HitWindowTemplate GetTemplate(string mode) + { + return templates.TryGetValue(mode, out var template) + ? template + : throw new InvalidOperationException($"Hit window template for mode '{mode}' is not defined."); + } + + public static readonly HitWindowTemplate EASY = new HitWindowTemplate + { + TemplatePerfect = 50, + TemplateGreat = 100, + TemplateGood = 150, + TemplateOk = 200, + TemplateMeh = 250, + TemplateMiss = 300 + }; + + public static readonly HitWindowTemplate HARD = new HitWindowTemplate + { + TemplatePerfect = 20, + TemplateGreat = 40, + TemplateGood = 60, + TemplateOk = 80, + TemplateMeh = 100, + TemplateMiss = 120 + }; + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitTimingInfo.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitTimingInfo.cs new file mode 100644 index 0000000000..2481dc89d1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaHitTimingInfo.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public class EzManiaHitTimingInfo + { + public double HitTime { get; set; } + public HitResult Result { get; set; } + + public EzManiaHitTimingInfo(double hitTime, HitResult result) + { + HitTime = hitTime; + Result = result; + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaModStrings.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaModStrings.cs new file mode 100644 index 0000000000..f5995896a6 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaModStrings.cs @@ -0,0 +1,382 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Reflection; +using osu.Framework.Localisation; +using osu.Game.LAsEzExtensions.Configuration; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public class EzManiaModStrings : EzLocalizationManager + { + static EzManiaModStrings() + { + // 使用反射为未设置英文的属性自动生成英文(属性名替换_为空格) + var fields = typeof(EzManiaModStrings).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + + foreach (var field in fields) + { + if (field.FieldType == typeof(EzLocalisableString)) + { + if (field.GetValue(null) is EzLocalisableString instance && instance.English == null) + { + instance.English = field.Name.Replace("_", " "); + } + } + } + } + + // 本地化字符串类,直接持有中文和英文 + public new class EzLocalisableString : EzLocalizationManager.EzLocalisableString + { + public EzLocalisableString(string chinese, string? english = null) + : base(chinese, english) { } + + // 便捷构造函数:如果不提供英文,则稍后通过反射从属性名生成 + public EzLocalisableString(string chinese) + : base(chinese) { } + } + + // ==================================================================================================== + // LAsMods - Mod Descriptions + // ==================================================================================================== + + public static readonly LocalisableString Ez2Settings_Description = new EzLocalisableString("按固定模版,移除盘子和踏板", "Remove Scratch, Panel."); + public static readonly LocalisableString NiceBPM_Description = new EzLocalisableString("自由调整BPM或速度", "Free BPM or Speed"); + public static readonly LocalisableString SpaceBody_Description = new EzLocalisableString("全LN面海,可调面缝", "Full LN, adjustable gaps"); + + public static readonly LocalisableString LoopPlayClip_Description = new EzLocalisableString("将谱面切割成片段用于循环练习。(原版是YuLiangSSS的Duplicate Mod)", + "Cut the beatmap into a clip for loop practice. (The original is YuLiangSSS's Duplicate Mod)"); + + // ==================================================================================================== + // LAsMods - SettingSource Labels & Descriptions + // ==================================================================================================== + + // Ez2Settings + public static readonly LocalisableString NoScratch_Label = new EzLocalisableString("无盘", "No Scratch"); + public static readonly LocalisableString NoScratch_Description = new EzLocalisableString("按固定模版,去除Ez街机谱面中的盘子. 用于: 6-9k L-S; 12\\14\\16k LR-S", "No (EZ)Scratch. For: 6-9k L-S; 12\\14\\16k LR-S"); + public static readonly LocalisableString NoPanel_Label = new EzLocalisableString("无踏板", "No Panel"); + public static readonly LocalisableString NoPanel_Description = new EzLocalisableString("按固定模版,去除Ez街机谱面中的脚踏. 用于: 7\\14\\18k", "No (EZ)Panel. For: 7\\14\\18k"); + public static readonly LocalisableString HealthyScratch_Label = new EzLocalisableString("健康盘子", "Healthy Scratch"); + public static readonly LocalisableString HealthyScratch_Description = new EzLocalisableString("优化盘子密度,通过特定模版将过快的盘子移动到其他列", "Healthy (EZ)Scratch. Move the fast Scratch to the other columns"); + public static readonly LocalisableString MaxBeat_Label = new EzLocalisableString("最大节拍", "Max Beat"); + public static readonly LocalisableString MaxBeat_Description = new EzLocalisableString("盘子密度的最大节拍间隔, 1/? 拍", "Scratch MAX Beat Space, MAX 1/? Beat"); + + // SpaceBody + public static readonly LocalisableString SpaceBody_Label = new EzLocalisableString("全反键缝隙", "Space Body"); + public static readonly LocalisableString SpaceBodyGap_Description = new EzLocalisableString("调整前后两个面之间的间隔缝隙", "Full LN, adjustable gaps"); + public static readonly LocalisableString AddShield_Label = new EzLocalisableString("添加盾型", "Add Shield"); + public static readonly LocalisableString AddShield_Description = new EzLocalisableString("将每个面尾添加盾牌键型", "Add shield notes in the sea"); + + // LoopPlayClip + public static readonly LocalisableString LoopCount_Label = new EzLocalisableString("循环次数", "Loop Count"); + public static readonly LocalisableString LoopCount_Description = new EzLocalisableString("切片循环次数", "Loop Clip Count."); + public static readonly LocalisableString SpeedChange_Label = new EzLocalisableString("改变倍速", "Speed Change"); + public static readonly LocalisableString SpeedChange_Description = new EzLocalisableString("改变倍速。不允许叠加其他变速mod。", "Speed Change. The actual decrease to apply. Don't add other rate-mod."); + public static readonly LocalisableString AdjustPitch_Label = new EzLocalisableString("调整音调", "Adjust pitch"); + public static readonly LocalisableString AdjustPitch_Description = new EzLocalisableString("速度改变时是否调整音调。(变速又变调)", "Adjust pitch. Should pitch be adjusted with speed.(变速又变调)"); + public static readonly LocalisableString ConstantSpeed_Label = new EzLocalisableString("无SV变速", "Constant Speed"); + public static readonly LocalisableString ConstantSpeed_Description = new EzLocalisableString("去除SV变速。(恒定速度/忽略谱面中的变速)", "Constant Speed. No more tricky speed changes.(恒定速度/忽略谱面中的变速)"); + public static readonly LocalisableString CutStartTime_Label = new EzLocalisableString("切片开始时间", "Cut Start Time"); + public static readonly LocalisableString CutStartTime_Description = new EzLocalisableString("切片开始时间, 默认是秒。推荐通过谱面编辑器A-B控件设置,可自动输入", "Cut StartTime. Default is second."); + public static readonly LocalisableString CutEndTime_Label = new EzLocalisableString("切片结束时间", "Cut End Time"); + public static readonly LocalisableString CutEndTime_Description = new EzLocalisableString("切片结束时间, 默认是秒。推荐通过谱面编辑器A-B控件设置,可自动输入", "Cut EndTime. Default is second."); + public static readonly LocalisableString UseMillisecond_Label = new EzLocalisableString("使用毫秒", "Use Millisecond"); + public static readonly LocalisableString UseMillisecond_Description = new EzLocalisableString("改为使用ms单位", "Use millisecond(ms)."); + public static readonly LocalisableString UseGlobalABRange_Label = new EzLocalisableString("使用全局A-B范围", "Use Global A-B Range"); + + public static readonly LocalisableString UseGlobalABRange_Description = new EzLocalisableString("始终使用谱面编辑器中A/B空间设置的范围(毫秒)。推荐保持开启", + "Use global A-B range. Always use the editor A/B range stored for this session (ms)."); + + public static readonly LocalisableString BreakTime_Label = new EzLocalisableString("休息时间", "Break Time"); + public static readonly LocalisableString BreakTime_Description = new EzLocalisableString("设置两个切片循环之间的休息时间(秒)", "Break Time. If you need break(second)."); + public static readonly LocalisableString Random_Label = new EzLocalisableString("随机", "Random"); + public static readonly LocalisableString Random_Description = new EzLocalisableString("在切片每次重复时进行随机", "Random. Do a Random on every duplicate."); + public static readonly LocalisableString Mirror_Label = new EzLocalisableString("镜像", "Mirror"); + public static readonly LocalisableString Mirror_Description = new EzLocalisableString("在切片每次重复时进行镜像", "Mirror. Mirror next part."); + public static readonly LocalisableString InfiniteLoop_Label = new EzLocalisableString("无限循环", "Infinite Loop"); + + public static readonly LocalisableString InfiniteLoop_Description = new EzLocalisableString("启用无限循环播放。游戏中必须使用Esc退出才能结束,无法获得成绩结算。", + "Infinite Loop. Enable infinite loop playback. You must use Esc to exit in the game to end, and you cannot get score settlement."); + + public static readonly LocalisableString MirrorTime_Label = new EzLocalisableString("镜像时间", "Mirror Time"); + public static readonly LocalisableString MirrorTime_Description = new EzLocalisableString("每隔多少次循环做一次镜像", "Mirror Time. Every next time part will be mirrored."); + public static readonly LocalisableString Seed_Label = new EzLocalisableString("种子", "Seed"); + public static readonly LocalisableString Seed_Description = new EzLocalisableString("使用自定义种子而不是随机种子", "Seed. Use a custom seed instead of a random one"); + + // Additional Adjust mod settings + public static readonly LocalisableString RandomMirror_Label = new EzLocalisableString("随机镜像", "Random Mirror"); + public static readonly LocalisableString RandomMirror_Description = new EzLocalisableString("随机决定是否镜像音符", "Random Mirror. Mirror or not mirror notes by random."); + public static readonly LocalisableString NoFail_Label = new EzLocalisableString("无失败", "No Fail"); + public static readonly LocalisableString NoFail_Description = new EzLocalisableString("无论如何都不会失败", "No Fail. You can't fail, no matter what."); + public static readonly LocalisableString Restart_Label = new EzLocalisableString("失败重启", "Restart on fail"); + public static readonly LocalisableString Restart_Description = new EzLocalisableString("失败时自动重启", "Restart on fail. Automatically restarts when failed."); + public static readonly LocalisableString RandomSelect_Label = new EzLocalisableString("随机选择", "Random"); + public static readonly LocalisableString RandomSelect_Description = new EzLocalisableString("随机排列按键", "Random. Shuffle around the keys."); + public static readonly LocalisableString TrueRandom_Label = new EzLocalisableString("真随机", "True Random"); + + public static readonly LocalisableString TrueRandom_Description = new EzLocalisableString("随机排列所有音符(使用NoLN(LN转换器等级-3),否则可能会重叠)", + "True Random. Shuffle all notes(Use NoLN(LN Transformer Level -3), or you will get overlapping notes otherwise)."); + + // ==================================================================================================== + // YuLiangSSSMods - Mod Descriptions + // ==================================================================================================== + + public static readonly LocalisableString ChangeSpeedByAccuracy_Description = new EzLocalisableString("根据准确度调整游戏速度", "Adapt the speed of the game based on the accuracy."); + public static readonly LocalisableString Adjust_Description = new EzLocalisableString("凉雨Mod一卡通", "Set your settings."); + public static readonly LocalisableString LN_Description = new EzLocalisableString("LN转换器", "LN Transformer"); + public static readonly LocalisableString Cleaner_Description = new EzLocalisableString("清理谱面中的子弹或其他音符(例如重叠音符)", "Clean bullet or other notes on map(e.g. Overlap note)."); + public static readonly LocalisableString LNJudgementAdjust_Description = new EzLocalisableString("调整LN的判定", "Adjust the judgement of LN."); + public static readonly LocalisableString LNSimplify_Description = new EzLocalisableString("通过转换简化节奏", "Simplifies rhythms by converting."); + public static readonly LocalisableString LNTransformer_Description = new EzLocalisableString("LN转换", "LN Transformer"); + public static readonly LocalisableString NewJudgement_Description = new EzLocalisableString("根据歌曲BPM设置新的判定", "New judgement set by BPM of the song."); + public static readonly LocalisableString NtoM_Description = new EzLocalisableString("转换为更高的按键数模式", "Convert to upper Keys mode."); + + public static readonly LocalisableString NtoMAnother_Description = new EzLocalisableString("转Key,来自krrcream的工具(有一些bug,请使用Clean设置来清理)", + "From krrcream's Tool (It has some bugs, please use Clean settings to clean it.)"); + + public static readonly LocalisableString Gracer_Description = new EzLocalisableString("转换为grace", "Convert to grace."); + public static readonly LocalisableString O2Judgement_Description = new EzLocalisableString("为O2JAM玩家设计的判定系统", "Judgement System for O2JAM players."); + public static readonly LocalisableString PlayfieldTransformation_Description = new EzLocalisableString("根据连击数调整游戏区域缩放", "Adjusts playfield scale based on combo."); + public static readonly LocalisableString O2Health_Description = new EzLocalisableString("为O2JAM玩家设计的生命值系统", "Health system for O2JAM players."); + public static readonly LocalisableString Remedy_Description = new EzLocalisableString("修复较低的判定", "Remedy lower judgement."); + public static readonly LocalisableString StarRatingRebirth_Description = new EzLocalisableString("sunnyxxy的星级算法,替换官方星级标记", "New algorithm by sunnyxxy."); + public static readonly LocalisableString ReleaseAdjust_Description = new EzLocalisableString("不再需要计时长按音符的结尾", "No more timing the end of hold notes."); + public static readonly LocalisableString NoteAdjust_Description = new EzLocalisableString("制作更多或更少的音符", "To make more or less note."); + public static readonly LocalisableString LNLongShortAddition_Description = new EzLocalisableString("LN转换器附加版本", "LN Transformer additional version."); + public static readonly LocalisableString MalodyStyleLN_Description = new EzLocalisableString("像Malody一样播放LN!", "Play LN like Malody!"); + public static readonly LocalisableString LNDoubleDistribution_Description = new EzLocalisableString("LN转换器另一个版本", "LN Transformer another version."); + public static readonly LocalisableString JudgmentsAdjust_Description = new EzLocalisableString("修改你的判定", "Modify your judgement."); + public static readonly LocalisableString JackAdjust_Description = new EzLocalisableString("Jack的模式", "Pattern of Jack"); + + public static readonly LocalisableString CleanColumn_Description = new EzLocalisableString("清理Column, 推荐搭配Column Type使用", + "Clean Column, use with Column Type."); + + // CleanColumn + public static readonly LocalisableString DeleteSColumn_Label = new EzLocalisableString("删除S列", "Delete S Column Type"); + public static readonly LocalisableString DeleteSColumn_Description = new EzLocalisableString("开启时删除标记了S Column Type的列", "Delete columns marked with S column type when enabled"); + public static readonly LocalisableString DeletePColumn_Label = new EzLocalisableString("删除P列", "Delete P Column Type"); + public static readonly LocalisableString DeletePColumn_Description = new EzLocalisableString("开启时删除标记了P Column Type的列", "Delete columns marked with P column type when enabled"); + public static readonly LocalisableString DeleteEColumn_Label = new EzLocalisableString("删除E列", "Delete E Column Type"); + public static readonly LocalisableString DeleteEColumn_Description = new EzLocalisableString("开启时删除标记了E Column Type的列", "Delete columns marked with E column type when enabled"); + public static readonly LocalisableString EnableCustomDelete_Label = new EzLocalisableString("自定义删除列", "Enable Custom Delete"); + public static readonly LocalisableString EnableCustomDelete_Description = new EzLocalisableString("开启后启用自定义删除列功能", "Enable custom column deletion when enabled"); + public static readonly LocalisableString CustomDeleteColumn_Label = new EzLocalisableString("自定义删除列序号", "Custom Delete Column Index"); + public static readonly LocalisableString CustomDeleteColumn_Description = new EzLocalisableString("按照输入的序号,删除谱面中对应编号的列", "Delete the column with the specified index"); + + // ==================================================================================================== + // YuLiangSSSMods - SettingSource Labels & Descriptions + // ==================================================================================================== + + // ChangeSpeedByAccuracy + public static readonly LocalisableString ChangeSpeedAccuracy_Label = new EzLocalisableString("准确度", "Accuracy"); + public static readonly LocalisableString ChangeSpeedAccuracy_Description = new EzLocalisableString("应用速度变化的准确度", "Accuracy. Accuracy for speed change to be applied."); + public static readonly LocalisableString MaxSpeed_Label = new EzLocalisableString("最大速度", "Max Speed"); + public static readonly LocalisableString MaxSpeed_Description = new EzLocalisableString("最大速度", "Max Speed"); + public static readonly LocalisableString MinSpeed_Label = new EzLocalisableString("最小速度", "Min Speed"); + public static readonly LocalisableString MinSpeed_Description = new EzLocalisableString("最小速度", "Min Speed"); + + // NiceBPM + public static readonly LocalisableString InitialRate_Label = new EzLocalisableString("初始速度", "Initial rate"); + public static readonly LocalisableString InitialRate_Description = new EzLocalisableString("轨道的起始速度", "Initial rate. The starting speed of the track"); + + // Gracer + public static readonly LocalisableString Bias_Label = new EzLocalisableString("偏差", "Bias"); + public static readonly LocalisableString Bias_Description = new EzLocalisableString("原始时机的偏差", "Bias. The bias of original timing."); + public static readonly LocalisableString Interval_Label = new EzLocalisableString("间隔", "Interval"); + public static readonly LocalisableString Interval_Description = new EzLocalisableString("音符的最小间隔(防止重叠)", "Interval. The minimum interval of note(To prevent overlap)."); + public static readonly LocalisableString Probability_Label = new EzLocalisableString("概率", "Probability"); + public static readonly LocalisableString Probability_Description = new EzLocalisableString("转换概率", "Probability. The Probability of convertion."); + + // NtoM, Gracer, JackAdjust + public static readonly LocalisableString Key_Label = new EzLocalisableString("按键数", "Key"); + public static readonly LocalisableString Key_Description = new EzLocalisableString("目标按键数(只能从低按键数转换为高按键数)", "Key. To Keys(Can only convert lower keys to higher keys.)"); + public static readonly LocalisableString BlankColumn_Label = new EzLocalisableString("空白列", "Blank Column"); + + public static readonly LocalisableString BlankColumn_Description = new EzLocalisableString("要添加的空白列数。(注意:如果按键数-圆形大小小于空白列数,则不会添加。)", + "Number of blank columns to add. (Notice: If the number of Key - CircleSize is less than the number of blank columns, it won't be added.)"); + + public static readonly LocalisableString NtoMGap_Label = new EzLocalisableString("间隙", "Gap"); + + public static readonly LocalisableString NtoMGap_Description = new EzLocalisableString("在每个区域重新排列音符。(间隙越大,音符分布越广。)", + "Rearrange the notes in every area. (If Gap is bigger, the notes will be more spread out.)"); + + public static readonly LocalisableString Clean_Label = new EzLocalisableString("清理", "Clean"); + public static readonly LocalisableString Clean_Description = new EzLocalisableString("尝试清理谱面中的一些音符。", "Try to clean some notes in the map."); + public static readonly LocalisableString CleanDivide_Label = new EzLocalisableString("清理分割", "Clean Divide"); + + public static readonly LocalisableString CleanDivide_Description = new EzLocalisableString("选择清理的分割(0表示不分割清理,4推荐用于流,8推荐用于Jack)。(如果清理为false,此设置不会被使用。)", + "Choose the divide(0 For no Divide Clean, 4 is Recommended for Stream, 8 is Recommended for Jack) of cleaning. (If Clean is false, this setting won't be used.)"); + + public static readonly LocalisableString Adjust4Jack_Label = new EzLocalisableString("1/4 Jack", "1/4 Jack"); + + public static readonly LocalisableString Adjust4Jack_Description = new EzLocalisableString("(如100+ BPM 1/4 Jack)清理分割 * 1/2,用于1/4 Jack,避免清理1/4 Jack。", + "(Like 100+ BPM 1/4 Jack)Clean Divide * 1/2, for 1/4 Jack, avoiding cleaning 1/4 Jack."); + + public static readonly LocalisableString Adjust4Speed_Label = new EzLocalisableString("1/4 Speed", "1/4 Speed"); + + public static readonly LocalisableString Adjust4Speed_Description = new EzLocalisableString("(如300+ BPM 1/4 Speed)清理分割 * 2,用于1/4 Speed,避免额外的1/2 Jack。", + "(Like 300+ BPM 1/4 Speed)Clean Divide * 2, for 1/4 Speed, avoiding additional 1/2 Jack."); + + // JackAdjust + public static readonly LocalisableString ToStream_Label = new EzLocalisableString("转换为流", "To Stream"); + + public static readonly LocalisableString ToStream_Description = new EzLocalisableString("尽可能作为JumpJack(推荐使用中等概率50~80)", + "To Stream. As Jumpjack as possible(Recommend to use a medium(50~80) probability)."); + + public static readonly LocalisableString Line_Label = new EzLocalisableString("线", "Line"); + public static readonly LocalisableString Line_Description = new EzLocalisableString("Jack的线", "Line. Line for Jack."); + public static readonly LocalisableString Alignment_Label = new EzLocalisableString("对齐", "Alignment"); + + public static readonly LocalisableString Alignment_Description = new EzLocalisableString("最后一行(false)或第一行(true),true会得到一些子弹,false会得到许多长jack", + "Alignment. Last line(false) or first line(true), true will get some bullet, false will get many long jack."); + + // NtoM, Gracer, JackAdjust, LNJudgementAdjust + public static readonly LocalisableString ApplyOrder_Label = new EzLocalisableString("应用顺序", "Apply Order"); + + public static readonly LocalisableString ApplyOrder_Description = new EzLocalisableString("此mod在谱面转换后应用的顺序。数字越小越先运行。", + "Apply Order. Order in which this mod is applied after beatmap conversion. Lower runs earlier."); + + // JudgmentsAdjust + public static readonly LocalisableString CustomHitRange_Label = new EzLocalisableString("自定义打击范围", "Custom Hit Range"); + public static readonly LocalisableString CustomHitRange_Description = new EzLocalisableString("调整音符的打击范围", "Custom Hit Range. Adjust the hit range of notes."); + public static readonly LocalisableString CustomProportionScore_Label = new EzLocalisableString("自定义比例分数", "Custom Proportion Score"); + public static readonly LocalisableString CustomProportionScore_Description = new EzLocalisableString("自定义比例分数", "Custom Proportion Score"); + + // LNJudgementAdjust + public static readonly LocalisableString BodyJudgementSwitch_Label = new EzLocalisableString("主体判定开关", "Body Judgement Switch"); + public static readonly LocalisableString BodyJudgementSwitch_Description = new EzLocalisableString("开启/关闭主体判定", "Turn on/off body judgement."); + public static readonly LocalisableString TailJudgementSwitch_Label = new EzLocalisableString("尾部判定开关", "Tail Judgement Switch"); + public static readonly LocalisableString TailJudgementSwitch_Description = new EzLocalisableString("开启/关闭尾部判定", "Turn on/off tail judgement."); + + // O2Judgement + public static readonly LocalisableString PillSwitch_Label = new EzLocalisableString("药丸开关", "Pill Switch"); + public static readonly LocalisableString PillSwitch_Description = new EzLocalisableString("使用O2JAM药丸功能", "Use O2JAM pill function."); + + // Cleaner + public static readonly LocalisableString Style_Label = new EzLocalisableString("样式", "Style"); + public static readonly LocalisableString Style_Description = new EzLocalisableString("选择你的样式", "Choose your style."); + public static readonly LocalisableString LNInterval_Label = new EzLocalisableString("LN间隔", "LN Interval"); + public static readonly LocalisableString LNInterval_Description = new EzLocalisableString("你决定的释放和按压速度", "The release & press speed you decide."); + + // LN + public static readonly LocalisableString Divide_Label = new EzLocalisableString("分割", "Divide"); + public static readonly LocalisableString Divide_Description = new EzLocalisableString("使用1/?", "Use 1/?"); + public static readonly LocalisableString Percentage_Label = new EzLocalisableString("百分比", "Percentage"); + public static readonly LocalisableString Percentage_Description = new EzLocalisableString("LN内容", "LN Content"); + public static readonly LocalisableString OriginalLN_Label = new EzLocalisableString("原始LN", "Original LN"); + public static readonly LocalisableString OriginalLN_Description = new EzLocalisableString("原始LN不会被转换", "Original LN won't be converted."); + public static readonly LocalisableString ColumnNum_Label = new EzLocalisableString("列数", "Column Num"); + public static readonly LocalisableString ColumnNum_Description = new EzLocalisableString("选择要转换的列数", "Select the number of column to transform."); + public static readonly LocalisableString Gap_Label = new EzLocalisableString("间隙", "Gap"); + public static readonly LocalisableString Gap_Description = new EzLocalisableString("转换后改变随机列的音符数量间隙", "For changing random columns after transforming the gap's number of notes."); + public static readonly LocalisableString LineSpacing_Label = new EzLocalisableString("行间距", "Line Spacing"); + public static readonly LocalisableString LineSpacing_Description = new EzLocalisableString("设置为0时转换每一行", "Transform every line when set to 0."); + public static readonly LocalisableString InvertLineSpacing_Label = new EzLocalisableString("反转行间距", "Invert Line Spacing"); + public static readonly LocalisableString InvertLineSpacing_Description = new EzLocalisableString("反转行间距", "Invert the Line Spacing."); + public static readonly LocalisableString DurationLimit_Label = new EzLocalisableString("持续时间限制", "Duration Limit"); + public static readonly LocalisableString DurationLimit_Description = new EzLocalisableString("LN的最大持续时间(秒)。(设置为0时无限制)", "The max duration(second) of a LN.(No limit when set to 0)"); + + // LNSimplify + public static readonly LocalisableString LimitDivide_Label = new EzLocalisableString("限制分割", "Limit Divide"); + public static readonly LocalisableString LimitDivide_Description = new EzLocalisableString("选择限制", "Select limit."); + public static readonly LocalisableString EasierDivide_Label = new EzLocalisableString("简化分割", "Easier Divide"); + public static readonly LocalisableString EasierDivide_Description = new EzLocalisableString("选择复杂度", "Select complexity."); + public static readonly LocalisableString LongestLN_Label = new EzLocalisableString("最长LN", "Longest LN"); + public static readonly LocalisableString LongestLN_Description = new EzLocalisableString("最长LN", "Longest LN."); + public static readonly LocalisableString ShortestLN_Label = new EzLocalisableString("最短LN", "Shortest LN"); + public static readonly LocalisableString ShortestLN_Description = new EzLocalisableString("最短LN", "Shortest LN."); + + // O2Health + public static readonly LocalisableString Difficulty_Label = new EzLocalisableString("难度", "Difficulty"); + public static readonly LocalisableString Difficulty_Description = new EzLocalisableString("1: 简单 2: 普通 3: 困难", "1: Easy 2: Normal 3: Hard"); + + // DoublePlay + public static readonly LocalisableString DoublePlayStyle_Label = new EzLocalisableString("样式", "Style"); + + public static readonly LocalisableString DoublePlayStyle_Description = new EzLocalisableString( + "1: NM+NM 2: MR+MR 3: NM+MR 4: MR+NM 5: Bracket NM+NM 6: Bracket MR 7: Wide Bracket 8: Wide Bracket MR", + "1: NM+NM 2: MR+MR 3: NM+MR 4: MR+NM 5: Bracket NM+NM 6: Bracket MR 7: Wide Bracket 8: Wide Bracket MR"); + + // PlayfieldTransformation + public static readonly LocalisableString MinimumScale_Label = new EzLocalisableString("最小缩放", "Minimum scale"); + public static readonly LocalisableString MinimumScale_Description = new EzLocalisableString("游戏区域的最小缩放", "The minimum scale of the playfield."); + + // ModStarRatingRebirth + public static readonly LocalisableString UseOriginalOD_Label = new EzLocalisableString("使用原始OD", "Use original OD"); + public static readonly LocalisableString UseOriginalOD_Description = new EzLocalisableString("高优先级", "High Priority"); + public static readonly LocalisableString UseCustomOD_Label = new EzLocalisableString("使用自定义OD", "Use custom OD"); + public static readonly LocalisableString UseCustomOD_Description = new EzLocalisableString("低优先级", "Low Priority"); + public static readonly LocalisableString OD_Label = new EzLocalisableString("OD", "OD"); + public static readonly LocalisableString OD_Description = new EzLocalisableString("选择要重新计算的OD", "Choose the OD you want to recalculate."); + + // Adjust + public static readonly LocalisableString ScoreMultiplier_Label = new EzLocalisableString("分数倍数", "Score Multiplier"); + public static readonly LocalisableString HPDrain_Label = new EzLocalisableString("HP消耗", "HP Drain"); + public static readonly LocalisableString HPDrain_Description = new EzLocalisableString("覆盖谱面的HP设置", "Override a beatmap's set HP."); + public static readonly LocalisableString AdjustAccuracy_Label = new EzLocalisableString("准确度", "Accuracy"); + public static readonly LocalisableString AdjustAccuracy_Description = new EzLocalisableString("覆盖谱面的OD设置", "Override a beatmap's set OD."); + public static readonly LocalisableString ReleaseLenience_Label = new EzLocalisableString("释放宽容度", "Release Lenience"); + + public static readonly LocalisableString ReleaseLenience_Description = new EzLocalisableString("调整LN尾部释放窗口宽容度。(Score v2中的尾部默认有1.5倍打击窗口)", + "Adjust LN tail release window lenience.(Tail in Score v2 has default 1.5x hit window)"); + + public static readonly LocalisableString CustomHP_Label = new EzLocalisableString("自定义HP", "Custom HP"); + public static readonly LocalisableString CustomOD_Label = new EzLocalisableString("自定义OD", "Custom OD"); + public static readonly LocalisableString CustomRelease_Label = new EzLocalisableString("自定义释放", "Custom Release"); + public static readonly LocalisableString ExtendedLimits_Label = new EzLocalisableString("扩展限制", "Extended Limits"); + public static readonly LocalisableString ExtendedLimits_Description = new EzLocalisableString("调整难度超出合理限制", "Adjust difficulty beyond sane limits."); + public static readonly LocalisableString AdjustConstantSpeed_Label = new EzLocalisableString("恒定速度", "Constant Speed"); + public static readonly LocalisableString AdjustConstantSpeed_Description = new EzLocalisableString("不再有棘手的速度变化", "No more tricky speed changes."); + + // NoteAdjust + public static readonly LocalisableString NoteAdjustStyle_Label = new EzLocalisableString("样式", "Style"); + + public static readonly LocalisableString NoteAdjustStyle_Description = new EzLocalisableString("1: 适用于Jack模式。2&3: 适用于Stream模式。4&5: 适用于Speed模式(无Jack)。6: DIY(将使用↓↓↓所有选项)(1~5将仅使用↓种子选项)", + "1: Applicable to Jack Pattern. 2&3: Applicable to Stream Pattern. 4&5: Applicable to Speed Pattern(No Jack). 6: DIY(Will use ↓↓↓ all options) (1~5 will only use ↓ seed option)."); + + public static readonly LocalisableString NoteAdjustProbability_Label = new EzLocalisableString("概率", "Probability"); + public static readonly LocalisableString NoteAdjustProbability_Description = new EzLocalisableString("增加音符的概率", "The Probability of increasing note."); + public static readonly LocalisableString Extremum_Label = new EzLocalisableString("极值", "Extremum"); + + public static readonly LocalisableString Extremum_Description = new EzLocalisableString("取决于你在一行上保留多少音符(可用最大音符或最小音符)", + "Depending on how many notes on one line you keep(Available maximum note or minimum note)."); + + public static readonly LocalisableString ComparisonStyle_Label = new EzLocalisableString("比较样式", "Comparison Style"); + + public static readonly LocalisableString ComparisonStyle_Description = new EzLocalisableString("1: 当此行的音符数量>=上一行和下一行时处理一行。2: 当此行的音符数量<=上一行和下一行时处理一行", + "1: Dispose a line when this line's note quantity >= Last&Next line. 2: Dispose a line when this line's note quantity <= Last&Next line."); + + public static readonly LocalisableString NoteAdjustLine_Label = new EzLocalisableString("线", "Line"); + + public static readonly LocalisableString NoteAdjustLine_Description = new EzLocalisableString("取决于这张图的难度(0推荐用于Jack,1推荐用于(Jump/Hand/Etc.)Stream,2推荐用于Speed)", + "Depending on how heavy about this map(0 is recommended for Jack, 1 is recommended for (Jump/Hand/Etc.)Stream, 2 is recommended for Speed)."); + + public static readonly LocalisableString Step_Label = new EzLocalisableString("步长", "Step"); + public static readonly LocalisableString Step_Description = new EzLocalisableString("在一行上成功转换时跳过\"Step\"行", "Skip \"Step\" line when converting successfully on a line."); + public static readonly LocalisableString IgnoreComparison_Label = new EzLocalisableString("忽略比较", "Ignore Comparison"); + public static readonly LocalisableString IgnoreComparison_Description = new EzLocalisableString("忽略比较条件", "Ignore condition of Comparison."); + public static readonly LocalisableString IgnoreInterval_Label = new EzLocalisableString("忽略间隔", "Ignore Interval"); + public static readonly LocalisableString IgnoreInterval_Description = new EzLocalisableString("忽略音符间隔", "Ignore interval of note."); + + // LNLongShortAddition + public static readonly LocalisableString LongShortPercent_Label = new EzLocalisableString("长/短百分比", "Long / Short %"); + public static readonly LocalisableString LongShortPercent_Description = new EzLocalisableString("形状", "The Shape"); + + // LNDoubleDistribution + public static readonly LocalisableString Divide1_Label = new EzLocalisableString("分割1", "Divide 1"); + public static readonly LocalisableString Divide1_Description = new EzLocalisableString("使用1/?", "Use 1/?"); + public static readonly LocalisableString Divide2_Label = new EzLocalisableString("分割2", "Divide 2"); + public static readonly LocalisableString Divide2_Description = new EzLocalisableString("使用1/?", "Use 1/?"); + public static readonly LocalisableString Mu1_Label = new EzLocalisableString("μ1", "Mu 1"); + public static readonly LocalisableString Mu1_Description = new EzLocalisableString("分布中的μ(百分比)", "Mu in distribution (Percentage)."); + public static readonly LocalisableString Mu2_Label = new EzLocalisableString("μ2", "Mu 2"); + public static readonly LocalisableString Mu2_Description = new EzLocalisableString("分布中的μ(百分比)", "Mu in distribution (Percentage)."); + public static readonly LocalisableString MuRatio_Label = new EzLocalisableString("μ1/μ2", "Mu 1 / Mu 2"); + public static readonly LocalisableString MuRatio_Description = new EzLocalisableString("百分比", "Percentage"); + public static readonly LocalisableString SigmaInteger_Label = new EzLocalisableString("σ整数部分", "Sigma Integer Part"); + public static readonly LocalisableString SigmaInteger_Description = new EzLocalisableString("σ除数(不是σ)", "Sigma Divisor (not sigma)."); + public static readonly LocalisableString SigmaDecimal_Label = new EzLocalisableString("σ小数部分", "Sigma Decimal Part"); + public static readonly LocalisableString SigmaDecimal_Description = new EzLocalisableString("σ除数(不是σ)", "Sigma Divisor (not sigma)."); + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaStrings.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaStrings.cs new file mode 100644 index 0000000000..2b3b372c83 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzManiaStrings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Reflection; +using osu.Game.LAsEzExtensions.Configuration; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public class EzManiaStrings : EzLocalizationManager + { + static EzManiaStrings() + { + // 使用反射为未设置英文的属性自动生成英文(属性名替换_为空格) + var fields = typeof(EzManiaStrings).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + + foreach (var field in fields) + { + if (field.FieldType == typeof(EzLocalisableString)) + { + if (field.GetValue(null) is EzLocalisableString instance && instance.English == null) + { + instance.English = field.Name.Replace("_", " "); + } + } + } + } + + // 本地化字符串类,直接持有中文和英文 + public new class EzLocalisableString : EzLocalizationManager.EzLocalisableString + { + public EzLocalisableString(string chinese, string? english = null) + : base(chinese, english) { } + + // 便捷构造函数:如果不提供英文,则稍后通过反射从属性名生成 + public EzLocalisableString(string chinese) + : base(chinese) { } + } + + // 公共属性定义本地化字符串,直接指定中文和英文 + public static readonly EzLocalisableString Mania_Specific_Key = new EzLocalisableString("Mania特定中文"); + // 添加更多属性... + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/EzStageDefinitionExtensions.cs b/osu.Game.Rulesets.Mania/LAsEzMania/EzStageDefinitionExtensions.cs new file mode 100644 index 0000000000..5c1fc562c2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/EzStageDefinitionExtensions.cs @@ -0,0 +1,174 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Mania.Beatmaps; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public static class EzStageDefinitionExtensions + { + public static bool EzIsSpecialColumn(this StageDefinition stage, int columnIndex) + { + if (columnIndex < 0 || columnIndex >= stage.Columns) + return false; + + return stage.Columns switch + { + 7 when columnIndex is 3 => true, + 9 when columnIndex is 4 => true, + 12 when columnIndex is 0 or 11 => true, + 14 when columnIndex is 0 or 12 => true, + 16 when columnIndex is 0 or 15 => true, + _ => false + }; + } + + public static Color4 EzGetColumnColor(this StageDefinition stage, int columnIndex) + { + if (columnIndex < 0 || columnIndex >= stage.Columns) + return colour_column; + + return stage.Columns switch + { + 12 when columnIndex is 0 or 11 => colour_scratch, + 14 when columnIndex is 0 or 12 => colour_scratch, + 14 when columnIndex is 13 => colour_alpha, + 14 when columnIndex is 6 => colour_panel, + 16 when columnIndex is 0 or 15 => colour_scratch, + 16 when columnIndex is 6 or 7 or 8 or 9 => colour_scratch, + _ => colour_column + }; + } + + private static readonly Color4 colour_column = new Color4(4, 4, 4, 255); + private static readonly Color4 colour_scratch = new Color4(20, 0, 0, 255); + private static readonly Color4 colour_panel = new Color4(0, 20, 0, 255); + private static readonly Color4 colour_alpha = new Color4(0, 0, 0, 0); + + // 颜色定义 + private static readonly Color4 colour_special = new Color4(206, 6, 3, 255); + + private static readonly Color4 colour_green = new Color4(100, 192, 92, 255); + private static readonly Color4 colour_red = new Color4(206, 6, 3, 255); + + private static readonly Color4 colour_withe = new Color4(222, 222, 222, 255); + private static readonly Color4 colour_blue = new Color4(55, 155, 255, 255); + + private const int total_colours = 3; + + private static readonly Color4 colour_cyan = new Color4(72, 198, 255, 255); + private static readonly Color4 colour_pink = new Color4(213, 35, 90, 255); + private static readonly Color4 colour_purple = new Color4(203, 60, 236, 255); + + public static Color4 GetColourForLayout(this StageDefinition stage, int columnIndex) + { + columnIndex %= stage.Columns; + + switch (stage.Columns) + { + case 4: + return columnIndex switch + { + 0 => colour_green, + 1 => colour_red, + 2 => colour_blue, + 3 => colour_cyan, + _ => throw new ArgumentOutOfRangeException() + }; + + case 5: + return columnIndex switch + { + 0 => colour_green, + 1 => colour_blue, + 2 => colour_red, + 3 => colour_cyan, + 4 => colour_purple, + _ => throw new ArgumentOutOfRangeException() + }; + + case 7: + return columnIndex switch + { + 1 or 5 => colour_withe, + 0 or 2 or 4 or 6 => colour_blue, + 3 => colour_green, + _ => throw new ArgumentOutOfRangeException() + }; + + case 8: + return columnIndex switch + { + 0 or 4 => colour_red, + 2 or 6 => colour_withe, + 1 or 3 or 5 or 7 => colour_blue, + _ => throw new ArgumentOutOfRangeException() + }; + + case 9: + return columnIndex switch + { + 0 or 6 or 7 => colour_red, + 2 or 4 => colour_withe, + 1 or 3 or 5 => colour_blue, + 8 => colour_green, + _ => throw new ArgumentOutOfRangeException() + }; + + case 10: + return columnIndex switch + { + 0 or 9 => colour_green, + 2 or 4 or 5 or 7 => colour_withe, + 1 or 3 or 6 or 8 => colour_blue, + _ => throw new ArgumentOutOfRangeException() + }; + + case 12: + return columnIndex switch + { + 0 or 11 => colour_red, + 1 or 3 or 5 or 6 or 8 or 10 => colour_withe, + 2 or 4 or 7 or 9 => colour_blue, + _ => throw new ArgumentOutOfRangeException() + }; + + case 14: + return columnIndex switch + { + 0 or 12 or 13 => colour_red, + 1 or 3 or 5 or 7 or 9 or 11 => colour_withe, + 2 or 4 or 8 or 10 => colour_blue, + 6 => colour_green, + _ => throw new ArgumentOutOfRangeException() + }; + + case 16: + return columnIndex switch + { + 0 or 6 or 7 or 8 or 9 or 15 => colour_red, + 1 or 3 or 5 or 10 or 12 or 14 => colour_withe, + 2 or 4 or 11 or 13 => colour_blue, + _ => throw new ArgumentOutOfRangeException() + }; + } + + // 后备逻辑保持不变 + if (stage.EzIsSpecialColumn(columnIndex)) + return colour_special; + + switch (columnIndex % total_colours) + { + case 0: return colour_cyan; + + case 1: return colour_pink; + + case 2: return colour_purple; + + default: throw new ArgumentOutOfRangeException(); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/FastSlowDisplayStrings.cs b/osu.Game.Rulesets.Mania/LAsEzMania/FastSlowDisplayStrings.cs new file mode 100644 index 0000000000..d24850b749 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/FastSlowDisplayStrings.cs @@ -0,0 +1,261 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + /// + /// 供 FastSlowDisplay HUD组件使用的本地化字符串。 + /// 代码文件来自于 YuLiangSSS。 + /// + public static class FastSlowDisplayStrings + { + private const string prefix = @"osu.Game.Rulesets.Mania.LAsEZMania.FastSlowDisplay"; + + /// + /// "Perfect" + /// + public static LocalisableString Perfect => "Perfect"; + + /// + /// "Great" + /// + public static LocalisableString Great => "Great"; + + /// + /// "Good" + /// + public static LocalisableString Good => "Good"; + + /// + /// "Ok" + /// + public static LocalisableString Ok => "Ok"; + + /// + /// "Meh" + /// + public static LocalisableString Meh => "Meh"; + + /// + /// "Miss" + /// + public static LocalisableString Miss => "Miss"; + + /// + /// "Gap" + /// + public static LocalisableString Gap => "Gap"; + + /// + /// "Font Size" + /// + public static LocalisableString FontSize => "Font Size"; + + /// + /// "The size of the text." + /// + public static LocalisableString FontSizeDescription => "The size of the text."; + + /// + /// "LN Switch" + /// + public static LocalisableString LNSwitch => "LN Switch"; + + /// + /// "Display LN tail individually." + /// + public static LocalisableString LNSwitchDescription => "Display LN tail individually."; + + /// + /// "Fast Text" + /// + public static LocalisableString FastText => "Fast Text"; + + /// + /// "Fast Text LN" + /// + public static LocalisableString FastTextLN => "Fast Text LN"; + + /// + /// "Slow Text" + /// + public static LocalisableString SlowText => "Slow Text"; + + /// + /// "Slow Text LN" + /// + public static LocalisableString SlowTextLN => "Slow Text LN"; + + /// + /// "The text to be displayed." + /// + public static LocalisableString TextDescription => "The text to be displayed."; + + /// + /// "Fast Colour" + /// + public static LocalisableString FastColour => "Fast Colour"; + + /// + /// "Fast Colour Style" + /// + public static LocalisableString FastColourStyle => "Fast Colour Style"; + + /// + /// "The style of the fast colour." + /// + public static LocalisableString FastColourStyleDescription => "The style of the fast colour."; + + /// + /// "Slow Colour" + /// + public static LocalisableString SlowColour => "Slow Colour"; + + /// + /// "Slow Colour Style" + /// + public static LocalisableString SlowColourStyle => "Slow Colour Style"; + + /// + /// "The style of the slow colour." + /// + public static LocalisableString SlowColourStyleDescription => "The style of the slow colour."; + + /// + /// "The colour of the text." + /// + public static LocalisableString TextColourDescription => "The colour of the text."; + + /// + /// "The gap between fast and slow." + /// + public static LocalisableString GapDescription => "The gap between fast and slow."; + + /// + /// "Fade Duration" + /// + public static LocalisableString FadeDuration => "Fade Duration"; + + /// + /// "The duration of the fade out effect." + /// + public static LocalisableString FadeDurationDescription => "The duration of the fade out effect."; + + /// + /// "Show Judgement" + /// + public static LocalisableString ShowJudgement => "Judgement"; + + /// + /// "Fade before first judgement." + /// + public static LocalisableString FadeBeforeFirstJudgement => "Fade before first judgement"; + + /// + /// "See if your SS missed." + /// + public static LocalisableString FadeBeforeFirstJudgementDescription => "See if your SS missed."; + + /// + /// "How to show fast/slow." + /// + public static LocalisableString ShowStyleDescription => "When to show fast/slow."; + + /// + /// "Horizontal / Vertical Display" + /// + public static LocalisableString DisplayStyle => "Horizontal / Vertical"; + + /// + /// "Display the text horizontally or vertically." + /// + public static LocalisableString DisplayStyleDescription => "Display the text horizontally or vertically."; + + /// + /// "Test" + /// + public static LocalisableString Test => "Test"; + + /// + /// "Preview the display of fast/slow." + /// + public static LocalisableString TestDescription => "Preview the display of fast/slow."; + + /// + /// "Lower Column Bound" + /// + public static LocalisableString LowerColumn => "Lower Column Bound"; + + /// + /// "Upper Column Bound" + /// + public static LocalisableString UpperColumn => "Uppper Column Bound"; + + /// + /// "The lower bound of the column to display the text." + /// + public static LocalisableString LowerColumnDescription => "The lower bound of the column to display the text."; + + /// + /// "The upper bound of the column to display the text." + /// + public static LocalisableString UpperColumnDescription => "The upper bound of the column to display the text."; + + /// + /// "Only Display One" + /// + public static LocalisableString OnlyDisplayOne => "Only Display One"; + + /// + /// "Display only one text at a time." + /// + public static LocalisableString OnlyDisplayOneDescription => "Display only one text at a time."; + + /// + /// "None" + /// + public static LocalisableString None => "None"; + + /// + /// "Left Half" + /// + public static LocalisableString LeftHalf => "Left Half"; + + /// + /// "Right Half" + /// + public static LocalisableString RightHalf => "Right Half"; + + /// + /// "Middle" + /// + public static LocalisableString Middle => "Middle"; + + /// + /// "Single Colour" + /// + public static LocalisableString SingleColour => "Single Colour"; + + /// + /// "Horizontal Gradient" + /// + public static LocalisableString HorizontalGradient => "Horizontal Gradient"; + + /// + /// "Vertical Gradient" + /// + public static LocalisableString VerticalGradient => "Vertical Gradient"; + + /// + /// "Select Column" + /// + public static LocalisableString SelectColumn => "Select Column"; + + /// + /// "Select the column to display the text." + /// + public static LocalisableString SelectColumnDescription => "Select the column to display the text."; + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/Helper/CustomHitWindows.cs b/osu.Game.Rulesets.Mania/LAsEzMania/Helper/CustomHitWindows.cs new file mode 100644 index 0000000000..7d4403ce56 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/Helper/CustomHitWindows.cs @@ -0,0 +1,350 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Background; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.LAsEZMania.Helper +{ + public class CustomHitWindowsHelper + { + private static readonly double[,] hit_range_bms = + { + // 305 300 200 100 50 Miss Poor + { 16.67, 33.33, 116.67, 250, 250, 250, 500 }, // IIDX + { 15.00, 30.00, 060.00, 200, 200, 1000, 1000 }, // LR2 Hard + { 15.00, 45.00, 112.00, 165, 165, 500, 500 }, // raja normal (75%) + { 20.00, 60.00, 150.00, 220, 500, 500, 500 }, // raja easy (100%) + }; + + private EzMUGHitMode hitMode = EzMUGHitMode.Classic; + + public EzMUGHitMode HitMode + { + get => hitMode; + set + { + hitMode = value; + updateRanges(); + } + } + + private double totalMultiplier = 1.0; + + public double TotalMultiplier + { + get => totalMultiplier; + set + { + totalMultiplier = value; + updateRanges(); + } + } + + private double overallDifficulty = 1.0; + + public double OverallDifficulty + { + get => overallDifficulty; + set + { + overallDifficulty = value; + updateRanges(); + } + } + + private double bpm; + + public double BPM + { + get => bpm; + set + { + bpm = value; + updateRanges(); + } + } + + // Ranges compatible with Mania naming used elsewhere (Range305 == Perfect, Range300 == Great, ...) + public double Range305 { get; private set; } + public double Range300 { get; private set; } + public double Range200 { get; private set; } + public double Range100 { get; private set; } + public double Range050 { get; private set; } + public double Range000 { get; private set; } + public double PoolRange { get; private set; } + + private static readonly DifficultyRange perfect_window_range = new DifficultyRange(22.4D, 19.4D, 13.9D); + private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34); + private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67); + private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97); + private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121); + private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158); + private const double pool_offset = 150.0; + + public CustomHitWindowsHelper() + : this(GlobalConfigStore.EzConfig?.Get(Ez2Setting.HitMode) ?? EzMUGHitMode.Classic) + { + } + + public CustomHitWindowsHelper(EzMUGHitMode hitMode) + { + HitMode = hitMode; + // UpdateRanges is called by the property setter + } + + public double[] GetHitWindowsClassic() + { + double invertedOd = Math.Clamp(10 - OverallDifficulty, 0, 10); + Range305 = Math.Floor(16 * TotalMultiplier) + 0.5; + Range300 = Math.Floor((34 + 3 * invertedOd) * TotalMultiplier) + 0.5; + Range200 = Math.Floor((67 + 3 * invertedOd) * TotalMultiplier) + 0.5; + Range100 = Math.Floor((97 + 3 * invertedOd) * TotalMultiplier) + 0.5; + Range050 = Math.Floor((121 + 3 * invertedOd) * TotalMultiplier) + 0.5; + Range000 = Math.Floor((158 + 3 * invertedOd) * TotalMultiplier) + 0.5; + PoolRange = Range000 + pool_offset; + + return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange }; + } + + public double[] GetHitWindowsO2Jam(double setBpm) + { + bpm = setBpm; + Range305 = 7500.0 / bpm * TotalMultiplier; + Range300 = Range305; + Range200 = 22500.0 / bpm * TotalMultiplier; + Range100 = Range200; + Range050 = 31250.0 / bpm * TotalMultiplier; + Range000 = Range050; + PoolRange = Range000 + pool_offset; + + return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange }; + } + + public double[] GetHitWindowsEZ2AC() + { + Range305 = 18.0 * TotalMultiplier; + Range300 = 38.0 * TotalMultiplier; + Range200 = 68.0 * TotalMultiplier; + Range100 = 88.0 * TotalMultiplier; + Range050 = 88.0 * TotalMultiplier; + Range000 = 100.0 * TotalMultiplier; + PoolRange = pool_offset; + + return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange }; + } + + public double[] GetHitWindowsIIDX(EzMUGHitMode mode) + { + int row = 0; + + switch (mode) + { + case EzMUGHitMode.IIDX_HD: + row = 0; + break; + + case EzMUGHitMode.LR2_HD: + row = 1; + break; + + case EzMUGHitMode.Raja_NM: + row = 2; + break; + } + + Range305 = hit_range_bms[row, 0] * TotalMultiplier; + Range300 = hit_range_bms[row, 1] * TotalMultiplier; + Range200 = hit_range_bms[row, 2] * TotalMultiplier; + Range100 = hit_range_bms[row, 3] * TotalMultiplier; + Range050 = hit_range_bms[row, 4] * TotalMultiplier; + Range000 = hit_range_bms[row, 5] * TotalMultiplier; + PoolRange = hit_range_bms[row, 6] * TotalMultiplier; + + return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange }; + } + + public double[] GetHitWindowsMelody() + { + Range305 = 20.0 * TotalMultiplier; + Range300 = 40.0 * TotalMultiplier; + Range200 = 60.0 * TotalMultiplier; + Range100 = 80.0 * TotalMultiplier; + Range050 = 100.0 * TotalMultiplier; + Range000 = 120.0 * TotalMultiplier; + PoolRange = Range000 + pool_offset; + + return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange }; + } + + private void updateRanges() + { + switch (HitMode) + { + case EzMUGHitMode.O2Jam: + SetRanges(GetHitWindowsO2Jam(bpm)); + break; + + case EzMUGHitMode.Lazer: + double perfect = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, perfect_window_range) * TotalMultiplier) + 0.5; + double great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, great_window_range) * TotalMultiplier) + 0.5; + double good = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, good_window_range) * TotalMultiplier) + 0.5; + double ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, ok_window_range) * TotalMultiplier) + 0.5; + double meh = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, meh_window_range) * TotalMultiplier) + 0.5; + double miss = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, miss_window_range) * TotalMultiplier) + 0.5; + double pool = miss + pool_offset; + SetRanges(new[] { perfect, great, good, ok, meh, miss, pool }); + break; + + case EzMUGHitMode.EZ2AC: + SetRanges(GetHitWindowsEZ2AC()); + break; + + case EzMUGHitMode.IIDX_HD: + SetRanges(GetHitWindowsIIDX(0)); + break; + + case EzMUGHitMode.Malody: + SetRanges(GetHitWindowsMelody()); + break; + + default: + SetRanges(GetHitWindowsClassic()); + break; + } + } + + public HitResult ResultFor(double timeOffset) + { + timeOffset = Math.Abs(timeOffset); + + if (AllowPoolEnabled) + { + if (IsHitResultAllowed(HitResult.Pool)) + { + double miss = WindowFor(HitResult.Miss); + double poolEarlyWindow = miss + 50; + double poolLateWindow = miss + 50; + if (timeOffset > -poolEarlyWindow && + timeOffset < -miss || + timeOffset < poolLateWindow && + timeOffset > miss) + return HitResult.Pool; + } + } + + for (var result = HitResult.Perfect; result >= HitResult.Miss; --result) + { + if (IsHitResultAllowed(result) && timeOffset <= WindowFor(result)) + return result; + } + + return HitResult.None; + } + + public virtual bool AllowPoolEnabled => GlobalConfigStore.EzConfig?.Get(Ez2Setting.CustomPoorHitResultBool) ?? false; + + public virtual bool IsHitResultAllowed(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + case HitResult.Great: + case HitResult.Good: + case HitResult.Ok: + case HitResult.Meh: + case HitResult.Miss: + return true; + + case HitResult.Pool: + return AllowPoolEnabled; + + default: + return false; + } + } + + public double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Perfect: return Range305; + + case HitResult.Great: return Range300; + + case HitResult.Good: return Range200; + + case HitResult.Ok: return Range100; + + case HitResult.Meh: return Range050; + + case HitResult.Pool: return PoolRange; + + case HitResult.Miss: return Range000; + + default: throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } + + /// + /// Allow external code to replace the current windows (e.g. when switching hit modes). + /// + public void SetRanges(double[]? ranges) + { + if (ranges == null) return; + + if (ranges.Length >= 6) + { + Range305 = ranges[0]; + Range300 = ranges[1]; + Range200 = ranges[2]; + Range100 = ranges[3]; + Range050 = ranges[4]; + Range000 = ranges[5]; + } + + if (ranges.Length >= 7) + PoolRange = ranges[6]; + else + PoolRange = Range000 + pool_offset; + } + + /// + /// Compute LN (long note) tail score given head and tail offsets using this helper's ranges. + /// + public double GetLNScore(double head, double tail) + { + // This LN scoring method is Classic-specific: always use Classic hit windows + double[] classicRanges = GetHitWindowsClassic(); + + double r305 = classicRanges[0]; + double r300 = classicRanges[1]; + double r200 = classicRanges[2]; + double r100 = classicRanges[3]; + double r050 = classicRanges[4]; + + double combined = head + tail; + + (double range, double headFactor, double combinedFactor, double score)[] rules = new[] + { + (range: r305, headFactor: 1.2, combinedFactor: 2.4, score: 300.0), + (range: r300, headFactor: 1.1, combinedFactor: 2.2, score: 300), + (range: r200, headFactor: 1.0, combinedFactor: 2.0, score: 200), + (range: r100, headFactor: 1.0, combinedFactor: 2.0, score: 100), + (range: r050, headFactor: 1.0, combinedFactor: 2.0, score: 50), + }; + + foreach (var (range, headFactor, combinedFactor, score) in rules) + { + if (head < range * headFactor && combined < range * combinedFactor) + return score; + } + + return 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/LAsEzMania/ManiaKeyCounterDisplay.cs b/osu.Game.Rulesets.Mania/LAsEzMania/ManiaKeyCounterDisplay.cs new file mode 100644 index 0000000000..0507cbad16 --- /dev/null +++ b/osu.Game.Rulesets.Mania/LAsEzMania/ManiaKeyCounterDisplay.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Specialized; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Screens; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Rulesets.Mania.LAsEZMania +{ + public abstract partial class ManiaKeyCounterDisplay : Container + { + [Resolved] + protected StageDefinition StageDefinition { get; private set; } = null!; + + [Resolved] + protected InputCountController Controller { get; private set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + protected readonly FillFlowContainer KeyFlow; + + private readonly IBindableList triggers = new BindableList(); + private IBindable columnWidth = null!; + private IBindable specialFactor = null!; + + protected ManiaKeyCounterDisplay() + { + AutoSizeAxes = Axes.Both; + + InternalChild = KeyFlow = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(0), + }; + } + + [BackgroundDependencyLoader] + private void load() + { + columnWidth = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + specialFactor = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + triggers.BindTo(Controller.Triggers); + triggers.BindCollectionChanged(triggersChanged, true); + + columnWidth.BindValueChanged(_ => updateCounterWidths()); + specialFactor.BindValueChanged(_ => updateCounterWidths()); + } + + private void updateCounterWidths() + { + foreach (var counter in KeyFlow) + { + float width = (float)columnWidth.Value; + int index = KeyFlow.IndexOf(counter); + + if (ezSkinConfig.IsSpecialColumn(StageDefinition.Columns, index)) + width *= (float)specialFactor.Value; + + counter.Width = width; + } + } + + private void triggersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + KeyFlow.Clear(); + foreach (var trigger in Controller.Triggers) + KeyFlow.Add(CreateCounter(trigger)); + + updateCounterWidths(); + } + + protected abstract KeyCounter CreateCounter(InputTrigger trigger); + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs index 3f7a018dd1..53c9a313b6 100644 --- a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs +++ b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs @@ -29,6 +29,12 @@ namespace osu.Game.Rulesets.Mania bool keyCountMatch = includedKeyCounts.Contains(keyCount); bool longNotePercentageMatch = !longNotePercentage.HasFilter || (!isConvertedBeatmap(beatmapInfo) && longNotePercentage.IsInRange(calculateLongNotePercentage(beatmapInfo))); + //多选过滤实现 + if (criteria.DiscreteCircleSizeValues?.Any() == true) + { + keyCountMatch = criteria.DiscreteCircleSizeValues.Contains(keyCount); + } + return keyCountMatch && longNotePercentageMatch; } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index cc64ee0d69..9583edadc3 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; @@ -9,10 +9,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.LAsEzExtensions.Background; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; @@ -24,12 +27,19 @@ using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Difficulty; using osu.Game.Rulesets.Mania.Edit; using osu.Game.Rulesets.Mania.Edit.Setup; +using osu.Game.Rulesets.Mania.LAsEzMania.Analysis; using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Mods.LAsMods; +using osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods; +using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mania.Skinning.Argon; using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Mania.Skinning.Ez2; +using osu.Game.Rulesets.Mania.Skinning.EzStylePro; using osu.Game.Rulesets.Mania.Skinning.Legacy; +using osu.Game.Rulesets.Mania.Skinning.SbI; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Replays.Types; @@ -48,7 +58,7 @@ namespace osu.Game.Rulesets.Mania /// /// The maximum number of supported keys in a single stage. /// - public const int MAX_STAGE_KEYS = 10; + public const int MAX_STAGE_KEYS = 18; public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableManiaRuleset(this, beatmap, mods); @@ -78,6 +88,27 @@ namespace osu.Game.Rulesets.Mania case ArgonSkin: return new ManiaArgonSkinTransformer(skin, beatmap); + case Ez2Skin: + if (GlobalConfigStore.EzConfig == null) + { + Logger.Log("!GlobalConfigStore.EzConfig", LoggingTarget.Runtime, LogLevel.Important); + break; + } + + return new ManiaEz2SkinTransformer(skin, beatmap, GlobalConfigStore.EzConfig); + + case EzStyleProSkin: + if (GlobalConfigStore.EzConfig == null) + { + Logger.Log("!GlobalConfigStore.EzConfig", LoggingTarget.Runtime, LogLevel.Important); + break; + } + + return new ManiaEzStyleProSkinTransformer(skin, beatmap, GlobalConfigStore.EzConfig); + + case SbISkin: + return new ManiaSbISkinTransformer(skin, beatmap); + case DefaultLegacySkin: case RetroSkin: return new ManiaClassicSkinTransformer(skin, beatmap); @@ -288,6 +319,46 @@ namespace osu.Game.Rulesets.Mania new MultiMod(new ManiaModAutoplay(), new ManiaModCinema()), }; + case ModType.YuLiangSSS_Mod: + return new Mod[] + { + new ManiaModAdjust(), + new ManiaModNtoM(), + new ManiaModNtoMAnother(), + // new ManiaModChangeSpeedByAccuracy(), // 无法使用 + new ManiaModCleaner(), + new ManiaModDoublePlay(), + new ManiaModGracer(), + new ManiaModJackAdjust(), + new ManiaModJudgmentsAdjust(), + // new ManiaModLN(), + new ManiaModLNDoubleDistribution(), + new ManiaModLNJudgementAdjust(), + new ManiaModLNLongShortAddition(), + new ManiaModLNSimplify(), + new ManiaModLNTransformer(), + new ManiaModMalodyStyleLN(), + new ManiaModNewJudgement(), + new ManiaModNoteAdjust(), + new ManiaModO2Health(), + new ManiaModO2Judgement(), + new ManiaModPlayfieldTransformation(), //加载有问题 + new ManiaModReleaseAdjust(), + new ManiaModRemedy(), + new ModStarRatingRebirth(), + }; + + case ModType.LA_Mod: + return new Mod[] + { + new ManiaModEz2Settings(), + new ManiaModCleanColumn(), // 待调试 + new ManiaModNiceBPM(), + new ManiaModSpaceBody(), + new ManiaModLoopPlayClip(), + new ManiaModSRAdjust(), + }; + case ModType.Fun: return new Mod[] { @@ -333,8 +404,6 @@ namespace osu.Game.Rulesets.Mania { for (int i = 1; i <= MAX_STAGE_KEYS; i++) yield return (int)PlayfieldType.Single + i; - for (int i = 2; i <= MAX_STAGE_KEYS * 2; i += 2) - yield return (int)PlayfieldType.Dual + i; } } @@ -344,9 +413,6 @@ namespace osu.Game.Rulesets.Mania { case PlayfieldType.Single: return new SingleStageVariantGenerator(variant).GenerateMappings(); - - case PlayfieldType.Dual: - return new DualStageVariantGenerator(getDualStageKeyCount(variant)).GenerateMappings(); } return Array.Empty(); @@ -358,21 +424,9 @@ namespace osu.Game.Rulesets.Mania { default: return $"{variant}K"; - - case PlayfieldType.Dual: - { - int keys = getDualStageKeyCount(variant); - return $"{keys}K + {keys}K"; - } } } - /// - /// Finds the number of keys for each stage in a variant. - /// - /// The variant. - private int getDualStageKeyCount(int variant) => (variant - (int)PlayfieldType.Dual) / 2; - /// /// Finds the that corresponds to a variant value. /// @@ -392,30 +446,54 @@ namespace osu.Game.Rulesets.Mania HitResult.Good, HitResult.Ok, HitResult.Meh, + HitResult.Pool, + HitResult.IgnoreHit, + HitResult.IgnoreMiss, // HitResult.SmallBonus is used for awarding perfect bonus score but is not included here as // it would be a bit redundant to show this to the user. }; } - public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { - new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) + var hitEventsByColumn = score.HitEvents + .Where(e => e.HitObject is ManiaHitObject) + .GroupBy(e => ((ManiaHitObject)e.HitObject).Column) + .OrderBy(g => g.Key) + .ToList(); + + var statistics = new List { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }), - new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[] - { - new AverageHitError(score.HitEvents), - new UnstableRate(score.HitEvents) - }), true) - }; + new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }), + new StatisticItem("Space Graph", () => new EzManiaScoreGraph(score, playableBeatmap) + { + RelativeSizeAxes = Axes.X, + Height = 200 + }, true), + new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents) + { + RelativeSizeAxes = Axes.X, + Height = 120 + }, true), + new StatisticItem("Column Timing Distributions", () => new CreateRotatedColumnGraphs(hitEventsByColumn) + { + RelativeSizeAxes = Axes.X, + Height = 250, + }, true), + new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[] + { + new AverageHitError(score.HitEvents), + new UnstableRate(score.HitEvents) + }), true) + }; + + return statistics.ToArray(); + } /// public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo beatmapInfo, IReadOnlyCollection mods) diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 791f46d407..db23764531 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -8,9 +8,11 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mania.Configuration; +using osu.Game.Rulesets.Mania.LAsEZMania; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania @@ -25,22 +27,71 @@ namespace osu.Game.Rulesets.Mania } [BackgroundDependencyLoader] - private void load() + private void load(Ez2ConfigManager ezConfig) { var config = (ManiaRulesetConfigManager)Config; Children = new Drawable[] { + new SettingsEnumDropdown + { + ClassicDefault = EzMUGHitMode.EZ2AC, + LabelText = EzLocalizationManager.HitMode, + TooltipText = EzLocalizationManager.HitModeTooltip, + Current = ezConfig.GetBindable(Ez2Setting.HitMode), + Keywords = new[] { "mania" } + }, + new SettingsEnumDropdown + { + ClassicDefault = EnumHealthMode.Lazer, + Current = ezConfig.GetBindable(Ez2Setting.CustomHealthMode), + LabelText = EzLocalizationManager.HealthMode, + TooltipText = EzLocalizationManager.HealthModeTooltip, + Keywords = new[] { "mania" } + }, + new SettingsCheckbox + { + Current = ezConfig.GetBindable(Ez2Setting.CustomPoorHitResultBool), + LabelText = EzLocalizationManager.PoorHitResult, + TooltipText = EzLocalizationManager.PoorHitResultTooltip, + Keywords = new[] { "mania" } + }, + new SettingsCheckbox + { + Current = ezConfig.GetBindable(Ez2Setting.ManiaBarLinesBool), + LabelText = EzLocalizationManager.ManiaBarLinesBool, + TooltipText = EzLocalizationManager.ManiaBarLinesBoolTooltip, + Keywords = new[] { "mania" } + }, new SettingsEnumDropdown { LabelText = RulesetSettingsStrings.ScrollingDirection, Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, + new SettingsEnumDropdown + { + LabelText = "Scrolling style", + Current = config.GetBindable(ManiaRulesetSetting.ScrollStyle) + }, new SettingsSlider { LabelText = RulesetSettingsStrings.ScrollSpeed, Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed), - KeyboardStep = 1 + KeyboardStep = 1, + }, + new SettingsSlider + { + LabelText = "Scroll Base MS (when 200 Speed)", + Current = config.GetBindable(ManiaRulesetSetting.ScrollBaseSpeed), + KeyboardStep = 1, + Keywords = new[] { "base" } + }, + new SettingsSlider + { + LabelText = "MS / Speed", + Current = config.GetBindable(ManiaRulesetSetting.ScrollTimePerSpeed), + KeyboardStep = 1, + Keywords = new[] { "mps" } }, new SettingsCheckbox { @@ -71,7 +122,72 @@ namespace osu.Game.Rulesets.Mania private partial class ManiaScrollSlider : RoundedSliderBar { - public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value); + // 自定义提示 + private ManiaRulesetConfigManager config = null!; + + [BackgroundDependencyLoader] + private void load(ManiaRulesetConfigManager config) + { + this.config = config; + } + + public override LocalisableString TooltipText + { + get + { + double baseSpeed = config.Get(ManiaRulesetSetting.ScrollBaseSpeed); + double timePerSpeed = config.Get(ManiaRulesetSetting.ScrollTimePerSpeed); + int computedTime = (int)DrawableManiaRuleset.ComputeScrollTime(Current.Value, baseSpeed, timePerSpeed); + LocalisableString speedInfo = RulesetSettingsStrings.ScrollSpeedTooltip(computedTime, Current.Value); + return $"{baseSpeed}base - ( {Current.Value} - 200) * {timePerSpeed}mps\n = {speedInfo}"; + } + } + } + + private partial class ManiaScrollBaseSlider : RoundedSliderBar + { + private ManiaRulesetConfigManager config = null!; + + [BackgroundDependencyLoader] + private void load(ManiaRulesetConfigManager config) + { + this.config = config; + } + + public override LocalisableString TooltipText + { + get + { + double speed = config.Get(ManiaRulesetSetting.ScrollSpeed); + double timePerSpeed = config.Get(ManiaRulesetSetting.ScrollTimePerSpeed); + int computedTime = (int)DrawableManiaRuleset.ComputeScrollTime(speed, Current.Value, timePerSpeed); + LocalisableString speedInfo = RulesetSettingsStrings.ScrollSpeedTooltip(computedTime, speed); + return $"{Current.Value}base - ( {speed} - 200) * {timePerSpeed}mps\n = {speedInfo}"; + } + } + } + + private partial class ManiaScrollMsPerSpeedSlider : RoundedSliderBar + { + private ManiaRulesetConfigManager config = null!; + + [BackgroundDependencyLoader] + private void load(ManiaRulesetConfigManager config) + { + this.config = config; + } + + public override LocalisableString TooltipText + { + get + { + double speed = config.Get(ManiaRulesetSetting.ScrollSpeed); + double baseSpeed = config.Get(ManiaRulesetSetting.ScrollBaseSpeed); + int computedTime = (int)DrawableManiaRuleset.ComputeScrollTime(speed, baseSpeed, Current.Value); + LocalisableString speedInfo = RulesetSettingsStrings.ScrollSpeedTooltip(computedTime, speed); + return $"{baseSpeed}base - ( {speed} - 200) * {Current.Value}mps\n = {speedInfo}"; + } + } } } } diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModBasicScrollSpeed.txt b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModBasicScrollSpeed.txt new file mode 100644 index 0000000000..d5669ae16a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModBasicScrollSpeed.txt @@ -0,0 +1,93 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Rulesets.UI.Scrolling.Algorithms; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + public class ManiaModBasicScrollSpeed : Mod, ISupportConstantAlgorithmToggle, IDrawableScrollingRuleset + { + public override string Name => "Adjust Basic Scroll Speed"; + public override string Acronym => "ABSS"; + public override LocalisableString Description => "LaMod: Adjust the basic scrolling speed of different keys."; + public override ModType Type => ModType.CustomMod; + public override double ScoreMultiplier => 1; + + [SettingSource("Basic Scrolling Speed", "基础落速")] + public BindableNumber BasicScrollingSpeed { get; } = new BindableNumber + { + MinValue = 200, + MaxValue = 2000, + Default = 500, + Value = 1, + }; + + public BindableBool ShowSpeedChanges { get; } = new BindableBool(); + + public double? TimelineTimeRange { get; set; } + + public required IScrollingInfo ScrollingInfo; + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + this.ScrollingInfo = scrollingInfo; + } + + protected void LoadComplete() + { + ShowSpeedChanges.BindValueChanged(showChanges => VisualisationMethod = showChanges.NewValue ? ScrollVisualisationMethod.Sequential : ScrollVisualisationMethod.Constant, true); + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + double currentScrollSpeed = ScrollingInfo.TimeRange.Value; + + int totalKeys = beatmap.HitObjects.OfType().Max(h => h.Column) + 1; + double speedMultiplier = BasicScrollingSpeed.Value / 1000.0; + + double newScrollSpeed = currentScrollSpeed * speedMultiplier * totalKeys; + + ScrollingInfo.TimeRange.Value = newScrollSpeed; + } + + private ScrollVisualisationMethod visualisationMethod = ScrollVisualisationMethod.Sequential; + + public ScrollVisualisationMethod VisualisationMethod + { + get => visualisationMethod; + set + { + visualisationMethod = value; + updateScrollAlgorithm(); + } + } + + private void updateScrollAlgorithm() + { + switch (VisualisationMethod) + { + case ScrollVisualisationMethod.Sequential: + ScrollingInfo.Algorithm = new SequentialScrollAlgorithm(ControlPoints); + break; + + case ScrollVisualisationMethod.Overlapping: + ScrollingInfo.Algorithm.Value = new OverlappingScrollAlgorithm(ControlPoints); + break; + + case ScrollVisualisationMethod.Constant: + ScrollingInfo.Algorithm.Value = new ConstantScrollAlgorithm(); + break; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModCleanColumn.cs b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModCleanColumn.cs new file mode 100644 index 0000000000..03bc775d28 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModCleanColumn.cs @@ -0,0 +1,186 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Background; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + /// + /// 基于 YuLiangSSS 的 ManiaModDeleteColumn 修改而来 + /// 增加一些高阶功能 + /// + public class ManiaModCleanColumn : Mod, IApplicableToBeatmapConverter, IApplicableAfterBeatmapConversion, IHasApplyOrder + { + public override string Name => "Clean Column"; + + public override string Acronym => "CC"; + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => EzManiaModStrings.CleanColumn_Description; + + public override IconUsage? Icon => FontAwesome.Solid.Backspace; + + public override ModType Type => ModType.LA_Mod; + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + + public override bool ValidForFreestyleAsRequiredMod => false; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DeleteSColumn_Label), nameof(EzManiaModStrings.DeleteSColumn_Description))] + public BindableBool DeleteSColumn { get; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DeletePColumn_Label), nameof(EzManiaModStrings.DeletePColumn_Description))] + public BindableBool DeletePColumn { get; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DeleteEColumn_Label), nameof(EzManiaModStrings.DeleteEColumn_Description))] + public BindableBool DeleteEColumn { get; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.EnableCustomDelete_Label), nameof(EzManiaModStrings.EnableCustomDelete_Description))] + public BindableBool EnableCustomDelete { get; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomDeleteColumn_Label), nameof(EzManiaModStrings.CustomDeleteColumn_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable CustomDeleteColumn { get; } = new Bindable(0); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ApplyOrder_Label), nameof(EzManiaModStrings.ApplyOrder_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable ApplyOrderSetting { get; } = new Bindable(1000); + + public static int TargetColumns = 7; + + public void ApplyToBeatmapConverter(IBeatmapConverter converter) + { + var mbc = (ManiaBeatmapConverter)converter; + + float keys = mbc.TotalColumns; + + if (keys != 7) + { + return; + } + + mbc.TargetColumns = TargetColumns; + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + try + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + int keys = maniaBeatmap.TotalColumns; + + // 获取列类型 + if (GlobalConfigStore.EzConfig != null) + { + EzColumnType[] columnTypes = GlobalConfigStore.EzConfig.GetColumnTypes(keys); + + // 确定要删除的列 + HashSet columnsToDelete = new HashSet(); + + if (DeleteSColumn.Value) + { + for (int i = 0; i < keys; i++) + { + if (columnTypes[i] == EzColumnType.S) + columnsToDelete.Add(i); + } + } + + if (DeletePColumn.Value) + { + for (int i = 0; i < keys; i++) + { + if (columnTypes[i] == EzColumnType.P) + columnsToDelete.Add(i); + } + } + + if (DeleteEColumn.Value) + { + for (int i = 0; i < keys; i++) + { + if (columnTypes[i] == EzColumnType.E) + columnsToDelete.Add(i); + } + } + + if (EnableCustomDelete.Value && CustomDeleteColumn.Value.HasValue && CustomDeleteColumn.Value.Value >= 0 && CustomDeleteColumn.Value.Value < keys) + columnsToDelete.Add(CustomDeleteColumn.Value.Value); + + if (!columnsToDelete.Any()) + return; // 没有要删除的列 + + var newObjects = new List(); + + var locations = maniaBeatmap.HitObjects.OfType().Select(n => ( + startTime: n.StartTime, + samples: n.Samples, + column: n.Column, + endTime: n.StartTime, + duration: n.StartTime - n.StartTime + )) + .Concat(maniaBeatmap.HitObjects.OfType().Select(h => ( + startTime: h.StartTime, + samples: h.Samples, + column: h.Column, + endTime: h.EndTime, + duration: h.EndTime - h.StartTime + ))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList(); + + foreach (var note in locations) + { + int column = note.column; + + if (columnsToDelete.Contains(column)) + continue; + + if (note.startTime != note.endTime) + { + newObjects.Add(new HoldNote + { + Column = column, + StartTime = note.startTime, + Duration = note.endTime - note.startTime, + NodeSamples = [note.samples, Array.Empty()] + }); + } + else + { + newObjects.Add(new Note + { + Column = column, + StartTime = note.startTime, + Samples = note.samples + }); + } + } + + maniaBeatmap.HitObjects = newObjects.OrderBy(h => h.StartTime).ToList(); + } + } + catch + { + // 失败时返回原始谱面,不修改 + } + } + + // 确认此 Mod 在其他转换后 Mod 之后应用,返回更高的应用顺序。 + // 没有此接口的 Mod 被视为顺序 0。 + public int ApplyOrder => ApplyOrderSetting.Value ?? 1000; + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModEz2Settings.cs b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModEz2Settings.cs new file mode 100644 index 0000000000..bbe57b3d85 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModEz2Settings.cs @@ -0,0 +1,317 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +// #pragma warning disable + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + public class ManiaModEz2Settings : Mod, IApplicableToDifficulty, IApplicableToBeatmap //, IApplicableToSample //, IStoryboardElement + { + public override string Name => "Ez2 Settings"; + public override string Acronym => "ES"; + public override LocalisableString Description => EzManiaModStrings.Ez2Settings_Description; + public override ModType Type => ModType.LA_Mod; + public override IconUsage? Icon => FontAwesome.Solid.Tools; + + public override bool Ranked => false; + public override double ScoreMultiplier => 1; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoScratch_Label), nameof(EzManiaModStrings.NoScratch_Description))] + public BindableBool NoScratch { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoPanel_Label), nameof(EzManiaModStrings.NoPanel_Description))] + public BindableBool NoPanel { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.HealthyScratch_Label), nameof(EzManiaModStrings.HealthyScratch_Description))] + public BindableBool HealthScratch { get; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MaxBeat_Label), nameof(EzManiaModStrings.MaxBeat_Description), SettingControlType = typeof(MultiplierSettingsSlider))] + public BindableNumber MaxBeat { get; } = new BindableDouble(3) + { + MinValue = 1, + MaxValue = 16, + Precision = 1 + }; + + // [SettingSource("Global Speed Regulation", "全局调速,开局调速有暂停,全局屏蔽倒计时.")] + // public BindableBool GlobalScrollSpeed { get; } = new BindableBool(); + + public ManiaModEz2Settings() + { + NoScratch.ValueChanged += OnSettingChanged; + HealthScratch.ValueChanged += OnHealthScratchChanged; + } + + private void OnSettingChanged(ValueChangedEvent e) + { + if (e.NewValue) + { + HealthScratch.Value = false; + } + } + + private void OnHealthScratchChanged(ValueChangedEvent e) + { + if (e.NewValue) + { + NoScratch.Value = false; + } + } + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + var settings = new List<(LocalisableString setting, LocalisableString value)>(); + + if (NoScratch.Value) + settings.Add((new LocalisableString("No Scratch"), new LocalisableString("Enabled"))); + + if (NoPanel.Value) + settings.Add((new LocalisableString("No Panel"), new LocalisableString("Enabled"))); + + if (HealthScratch.Value) + settings.Add((new LocalisableString("Scratch MAX Beat Space"), new LocalisableString($"1/{MaxBeat.Value} Beat"))); + + return settings; + } + } + + private IBeatmap beatmap = null!; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + this.beatmap = beatmap; + var maniaBeatmap = (ManiaBeatmap)beatmap; + int keys = (int)maniaBeatmap.Difficulty.CircleSize; + + if (HealthScratch.Value) + { + NoScratch.Value = false; + } + + if (HealthScratch.Value && HealthTemplate.TryGetValue(keys, out var moveTargets)) + { + var notesToMove = maniaBeatmap.HitObjects + .Where(h => h is ManiaHitObject maniaHitObject && moveTargets.Contains(maniaHitObject.Column)) + .OrderBy(h => h.StartTime) + .ToList(); + + ManiaHitObject? previousNote = null; + + foreach (var note in notesToMove) + { + if (previousNote != null && note.StartTime - previousNote.StartTime <= beatmap.ControlPointInfo.TimingPointAt(note.StartTime).BeatLength / MaxBeat.Value) + { + bool moved = false; + + foreach (int targetColumn in MoveTemplate[keys]) + { + int newColumn = targetColumn; + note.Column = newColumn % keys; + + var targetColumnNotes = maniaBeatmap.HitObjects + .Where(h => h is ManiaHitObject maniaHitObject && maniaHitObject.Column == newColumn) + .OrderBy(h => h.StartTime) + .ToList(); + bool isValid = true; + + for (int i = 0; i < targetColumnNotes.Count - 1; i++) + { + var currentNote = targetColumnNotes[i]; + var nextNote = targetColumnNotes[i + 1]; + + if (nextNote.StartTime - currentNote.StartTime <= beatmap.ControlPointInfo.TimingPointAt(nextNote.StartTime).BeatLength / 4) + { + isValid = false; + break; + } + + if (currentNote is HoldNote holdNote && nextNote.StartTime <= holdNote.EndTime) + { + isValid = false; + break; + } + } + + if (isValid) + { + moved = true; + break; + } + } + + if (!moved) + { + note.Column = previousNote.Column; + } + } + + previousNote = note; + } + } + + if (NoScratch.Value && NoScratchTemplate.TryGetValue(keys, out var scratchToRemove)) + { + var scratchNotesToRemove = maniaBeatmap.HitObjects + .Where(h => h is ManiaHitObject maniaHitObject && scratchToRemove.Contains(maniaHitObject.Column)) + .OrderBy(h => h.StartTime) + .ToList(); + + foreach (var note in scratchNotesToRemove) + { + maniaBeatmap.HitObjects.Remove(note); + applySamples(note); + } + } + + if (NoPanel.Value && NoPanelTemplate.TryGetValue(keys, out var panelToRemove)) + { + var panelNotesToRemove = maniaBeatmap.HitObjects + .Where(h => h is ManiaHitObject maniaHitObject && panelToRemove.Contains(maniaHitObject.Column)) + .OrderBy(h => h.StartTime) + .ToList(); + + foreach (var note in panelNotesToRemove) + { + maniaBeatmap.HitObjects.Remove(note); + applySamples(note); + } + } + } + + private void applySamples(HitObject hitObject) + { + SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + 5) + ?? SampleControlPoint.DEFAULT; + hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); + } + + public ISample GetSample(ISampleInfo sampleInfo) + { + if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) + return new SampleVirtual(); + + return GetSample(sampleInfo); + } + + // public class RemovedNoteSample + // { + // public double StartTime { get; set; } + // public required string Sample { get; set; } + // } + + // public void ApplyToSample(IAdjustableAudioComponent sample) { } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) { } + + public override string ExtendedIconInformation => ""; + + public Dictionary> NoScratchTemplate { get; set; } = new Dictionary> + { + { 16, new List { 0, 15 } }, + { 14, new List { 0, 12 } }, + { 12, new List { 0, 11 } }, + { 9, new List { 0 } }, + { 8, new List { 0 } }, + { 7, new List { 0 } }, + { 6, new List { 0 } }, + }; + + public Dictionary> NoPanelTemplate { get; set; } = new Dictionary> + { + { 18, new List { 6, 11 } }, + { 14, new List { 6 } }, + { 9, new List { 8 } }, + { 7, new List { 6 } }, + }; + + public Dictionary> HealthTemplate { get; set; } = new Dictionary> + { + { 16, new List { 0, 15 } }, + { 14, new List { 0, 6, 12 } }, + { 12, new List { 0, 11 } }, + { 9, new List { 0, 8 } }, + { 8, new List { 0, 8 } }, + { 7, new List { 0 } }, + { 6, new List { 0 } }, + }; + + public Dictionary> MoveTemplate { get; set; } = new Dictionary> + { + { 16, new List { 15, 0, 2, 4, 8, 10, 6, 7, 8, 9, 5, 10 } }, + { 14, new List { 12, 0, 2, 4, 8, 10, 5, 7, 1, 3, 9, 11 } }, + { 12, new List { 11, 0, 2, 4, 8, 10, 5, 6, 7, 1, 3, 9 } }, + { 9, new List { 8, 0, 4, 2, 3, 1, 5, 7, 6 } }, + { 8, new List { 7, 0, 6, 4, 2, 5, 3, 1 } }, + { 7, new List { 6, 4, 2, 5, 3, 1 } }, + { 6, new List { 4, 2, 5, 3, 1 } }, + }; + + // public Dictionary> MoveTemplate { get; set; } = new Dictionary> + // { + // { 16, new List { 0, 15 } }, + // { 14, new List { 0, 12 } }, + // { 12, new List { 0, 11 } }, + // { 9, new List { 8, 0 } }, + // { 8, new List { 7, 0 } }, + // { 7, new List { 6 } }, + // { 6, new List { 5, 4 } }, + // }; + } +} +// #pragma warning restore +// public void SetTrackBackgroundColor(List trackIndices, Color4 color, List columnBackgrounds) +// { +// foreach (int trackIndex in trackIndices) +// { +// if (trackIndex >= 0 && trackIndex < columnBackgrounds.Count) +// { +// var columnBackground = columnBackgrounds[trackIndex]; +// columnBackground.background.Colour = color; +// } +// } +// } +// public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) +// { +// } +// string lines = note; +// beatmap.UnhandledEventLines.Add(lines); +// string path = note.Samples.GetHashCode().ToString(); +// double time = note.StartTime; +// storyboard.GetLayer("Background").Add(new StoryboardSampleInfo(path, time, 100)); +// storyboard.GetLayer("Background").Add(); +// applySamples(note); + +// private Storyboard storyboard = null!; + +// public void ApplyToSample(IAdjustableAudioComponent sample) +// { +// foreach (var noteSample in removedSamples) +// { +// string path = noteSample.Sample.ToString() ?? string.Empty; +// double time = noteSample.StartTime; +// storyboard.GetLayer("Background").Add(new StoryboardSampleInfo(path, time, 100)); +// } +// } +// processedTracks.AddRange(panelToRemove); +// setTrackBackgroundColor(panelToRemove, new Color4(0, 0, 0, 0)); diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModFreeHit.txt b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModFreeHit.txt new file mode 100644 index 0000000000..e65fe3b0dd --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModFreeHit.txt @@ -0,0 +1,293 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + public class ManiaModFreeHit : Mod, IHitWindows + { + public override string Name => "Custom HitWindows"; + public override string Acronym => "CH"; + public override double ScoreMultiplier => 1; + public override bool Ranked => false; + public override LocalisableString Description => @"LaMod: Custom HitWindows. Free Hit ms."; + public override ModType Type => ModType.CustomMod; + + [SettingSource("Adaptive Judgement(No Active)")] + public BindableBool AdaptiveJudgement { get; } = new BindableBool(); + + [SettingSource("Easy Style Judgement")] + public BindableBool UseEasyTemplate { get; } = new BindableBool(); + + [SettingSource("Hard Style Judgement")] + public BindableBool UseHardTemplate { get; } = new BindableBool(); + + [SettingSource("Perfect Offset (ms)")] + public BindableNumber PerfectOffset { get; } = new BindableDouble(22) + { + MinValue = 1, + MaxValue = 60, + Precision = 1 + }; + + [SettingSource("Great Offset (ms)")] + public BindableNumber GreatOffset { get; } = new BindableDouble(42) + { + MinValue = 10, + MaxValue = 120, + Precision = 1 + }; + + [SettingSource("Good Offset (ms)")] + public BindableNumber GoodOffset { get; } = new BindableDouble(82) + { + MinValue = 20, + MaxValue = 180, + Precision = 1 + }; + + [SettingSource("Ok Offset (ms)")] + public BindableNumber OkOffset { get; } = new BindableDouble(120) + { + MinValue = 40, + MaxValue = 240, + Precision = 1 + }; + + [SettingSource("Meh Offset (ms)")] + public BindableNumber MehOffset { get; } = new BindableDouble(150) + { + MinValue = 60, + MaxValue = 300, + Precision = 1 + }; + + [SettingSource("Miss Offset (ms)")] + public BindableNumber MissOffset { get; } = new BindableDouble(180) + { + MinValue = 80, + MaxValue = 500, + Precision = 1 + }; + + [Resolved] + private ScoreProcessor scoreProcessor { get; set; } = null!; + + public ManiaModFreeHit() + { + // HitWindows.SetCustomRanges(this); + + UseEasyTemplate.BindValueChanged(e => + { + if (e.NewValue) + { + applyTemplate(HitWindowTemplates.EASY); + UseHardTemplate.Value = false; + AdaptiveJudgement.Value = false; + } + }); + UseHardTemplate.BindValueChanged(e => + { + if (e.NewValue) + { + applyTemplate(HitWindowTemplates.HARD); + UseEasyTemplate.Value = false; + AdaptiveJudgement.Value = false; + } + }); + AdaptiveJudgement.BindValueChanged(e => + { + if (e.NewValue) + { + UseHardTemplate.Value = false; + UseEasyTemplate.Value = false; + + scoreProcessor?.Accuracy.BindValueChanged(acc => UpdateHitWindowsBasedOnScore(acc.NewValue), true); + } + // else + // { + // scoreProcessor.Accuracy.UnbindAll(); + // } + }, true); + PerfectOffset.BindValueChanged(_ => updateHitWindows()); + GreatOffset.BindValueChanged(_ => updateHitWindows()); + GoodOffset.BindValueChanged(_ => updateHitWindows()); + OkOffset.BindValueChanged(_ => updateHitWindows()); + MehOffset.BindValueChanged(_ => updateHitWindows()); + MissOffset.BindValueChanged(_ => updateHitWindows()); + } + + ~ManiaModFreeHit() + { + HitWindows.SetModActive(false); + } + + private void updateHitWindows() + { + HitWindows.SetModActive(true); + HitWindows.SetCustomRanges(this); + } + + // public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + // { + // } + + // public AdjustRank(ScoreRank rank, double accuracy) + // { + // return rank; + // } + + private void applyTemplate(HitWindowTemplate template) + { + PerfectOffset.Value = template.PerfectOffset; + GreatOffset.Value = template.GreatOffset; + GoodOffset.Value = template.GoodOffset; + OkOffset.Value = template.OkOffset; + MehOffset.Value = template.MehOffset; + MissOffset.Value = template.MissOffset; + } + + public class HitWindowTemplate + { + public double PerfectOffset { get; set; } + public double GreatOffset { get; set; } + public double GoodOffset { get; set; } + public double OkOffset { get; set; } + public double MehOffset { get; set; } + public double MissOffset { get; set; } + } + + public static class HitWindowTemplates + { + public static readonly HitWindowTemplate EASY = new HitWindowTemplate + { + PerfectOffset = 50, + GreatOffset = 100, + GoodOffset = 150, + OkOffset = 200, + MehOffset = 250, + MissOffset = 300 + }; + + public static readonly HitWindowTemplate HARD = new HitWindowTemplate + { + PerfectOffset = 20, + GreatOffset = 40, + GoodOffset = 60, + OkOffset = 80, + MehOffset = 100, + MissOffset = 120 + }; + + // 可以添加更多模板 + } + + public void UpdateHitWindowsBasedOnScore(double accuracy) + { + if (accuracy != 0) + { + if (accuracy > 0.95) + { + // 缩小判定区间 + PerfectOffset.Value = 10; + GreatOffset.Value = 20; + GoodOffset.Value = 21; + OkOffset.Value = 90; + MehOffset.Value = 100; + MissOffset.Value = 120; + } + else if (accuracy < 0.95) + { + // 放宽判定区间 + PerfectOffset.Value = 30; + GreatOffset.Value = 60; + GoodOffset.Value = 100; + OkOffset.Value = 150; + MehOffset.Value = 151; + MissOffset.Value = 200; + } + } + } + + public bool IsHitResultAllowed(HitResult result) + { + return result switch + { + HitResult.Perfect => true, + HitResult.Great => true, + HitResult.Good => true, + HitResult.Ok => true, + HitResult.Meh => true, + HitResult.Miss => true, + _ => false, + }; + } + + public double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + return PerfectOffset.Value; + + case HitResult.Great: + return GreatOffset.Value; + + case HitResult.Good: + return GoodOffset.Value; + + case HitResult.Ok: + return OkOffset.Value; + + case HitResult.Meh: + return MehOffset.Value; + + case HitResult.Miss: + return MissOffset.Value; + + default: + throw new ArgumentException("Unknown enum member", nameof(result)); + } + } + + public DifficultyRange[] GetRanges() => new[] + { + new DifficultyRange(HitResult.Perfect, PerfectOffset.Value, PerfectOffset.Value, PerfectOffset.Value), + new DifficultyRange(HitResult.Great, GreatOffset.Value, GreatOffset.Value, GreatOffset.Value), + new DifficultyRange(HitResult.Good, GoodOffset.Value, GoodOffset.Value, GoodOffset.Value), + new DifficultyRange(HitResult.Ok, OkOffset.Value, OkOffset.Value, OkOffset.Value), + new DifficultyRange(HitResult.Meh, MehOffset.Value, MehOffset.Value, MehOffset.Value), + new DifficultyRange(HitResult.Miss, MissOffset.Value, MissOffset.Value, MissOffset.Value), + }; + + public override string SettingDescription + { + get + { + string perfect = $"Perfect {PerfectOffset.Value}"; + string great = $"Great {GreatOffset.Value}"; + string good = $"Good {GoodOffset.Value}"; + string ok = $"Ok {OkOffset.Value}"; + string meh = $"Meh {MehOffset.Value}"; + string miss = $"Miss {MissOffset.Value}"; + return string.Join(", ", new[] + { + perfect, + great, + good, + ok, + meh, + miss + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModLoopPlayClip.cs b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModLoopPlayClip.cs new file mode 100644 index 0000000000..a9c930b628 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModLoopPlayClip.cs @@ -0,0 +1,538 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Select; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + /// + /// 基于凉雨的 Duplicate Mod, 解决无循环音频问题; + /// 备注部分为我修改的内容, 增加IApplicableToPlayer, IApplicableToHUD, IPreviewOverrideProvider接口的使用 + /// + public class ManiaModLoopPlayClip : Mod, IApplicableAfterBeatmapConversion, + IHasSeed, + IApplicableToPlayer, + IApplicableToHUD, + IPreviewOverrideProvider, + ILoopTimeRangeMod, + IApplicableFailOverride, + IApplicableToRate, + IApplicableToDrawableRuleset + { + private DuplicateVirtualTrack? duplicateTrack; + private IWorkingBeatmap? pendingWorkingBeatmap; + internal double? ResolvedCutTimeStart { get; private set; } + internal double? ResolvedCutTimeEnd { get; private set; } + internal double ResolvedSegmentLength { get; private set; } + public override string Name => "Loop Play Clip (No Fail)"; + + public override string Acronym => "LP"; + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => EzManiaModStrings.LoopPlayClip_Description; + + public override IconUsage? Icon => FontAwesome.Solid.ArrowCircleDown; + + public override ModType Type => ModType.LA_Mod; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => false; + public override bool ValidForFreestyleAsRequiredMod => false; + + // LP 内置变速(复刻 HT 的实现)后,为避免叠加导致体验混乱,直接与其它变速 Mod 互斥。 + public override Type[] IncompatibleMods => new[] + { + typeof(ModRateAdjust), + typeof(ModTimeRamp), + typeof(ModAdaptiveSpeed), + typeof(ManiaModConstantSpeed), + typeof(ManiaModNoFail), + }; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ($"Speed x{SpeedChange.Value:N2}", AdjustPitch.Value ? "Pitch Adjusted" : "Pitch Unchanged"); + yield return ($"{LoopCount.Value}", "Loop Count"); + yield return ("Break", $"{BreakTime:N1}s"); + yield return ("Start", $"{(CutTimeStart.Value is null ? "Original Start Time" : (Millisecond.Value ? $"{CutTimeStart.Value} ms" : CalculateTime((int)CutTimeStart.Value)))}"); + yield return ("End", $"{(CutTimeEnd.Value is null ? "Original End Time" : (Millisecond.Value ? $"{CutTimeEnd.Value} ms" : CalculateTime((int)CutTimeEnd.Value)))}"); + yield return ("Infinite Loop", InfiniteLoop.Value ? "Enabled" : "Disabled"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LoopCount_Label), nameof(EzManiaModStrings.LoopCount_Description))] + public BindableInt LoopCount { get; set; } = new BindableInt(20) + { + MinValue = 1, + MaxValue = 100, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SpeedChange_Label), nameof(EzManiaModStrings.SpeedChange_Description), SettingControlType = typeof(MultiplierSettingsSlider))] + public BindableNumber SpeedChange { get; } = new BindableDouble(1) + { + MinValue = 0.5, + MaxValue = 2.0, + Precision = 0.01, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))] + public BindableBool AdjustPitch { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ConstantSpeed_Label), nameof(EzManiaModStrings.ConstantSpeed_Description))] + public BindableBool ConstantSpeed { get; } = new BindableBool(true); + + /*[SettingSource("Cut Time Start", "Select your part(second).", SettingControlType = typeof(SettingsSlider))] + public BindableInt CutTimeStart { get; set; } = new BindableInt(-10) + { + MinValue = -10, + MaxValue = 1800, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CutTimeEnd_Label), nameof(EzManiaModStrings.CutTimeEnd_Description), SettingControlType = typeof(SettingsSlider))] + public BindableInt CutTimeEnd { get; set; } = new BindableInt(1800) + { + MinValue = -10, + MaxValue = 1800, + Precision = 1 + };*/ + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CutStartTime_Label), nameof(EzManiaModStrings.CutStartTime_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable CutTimeStart { get; set; } = new Bindable(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CutEndTime_Label), nameof(EzManiaModStrings.CutEndTime_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable CutTimeEnd { get; set; } = new Bindable(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.UseMillisecond_Label), nameof(EzManiaModStrings.UseMillisecond_Description))] + public BindableBool Millisecond { get; set; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.UseGlobalABRange_Label), nameof(EzManiaModStrings.UseGlobalABRange_Description))] + public BindableBool UseGlobalAbRange { get; set; } = new BindableBool(true); + + private readonly RateAdjustModHelper rateAdjustHelper; + + public ManiaModLoopPlayClip() + { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); + + UseGlobalAbRange.BindValueChanged(_ => applyRangeFromStore(), true); + + // 当全局A/B范围改变时,更新设置 + LoopTimeRangeStore.START_TIME_MS.BindValueChanged(_ => applyRangeFromStoreIfGlobal()); + LoopTimeRangeStore.END_TIME_MS.BindValueChanged(_ => applyRangeFromStoreIfGlobal()); + } + + public void ApplyToTrack(IAdjustableAudioComponent track) => rateAdjustHelper.ApplyToTrack(track); + + public void ApplyToSample(IAdjustableAudioComponent sample) + { + // 与 ModRateAdjust 一致:sample 仅做音高/频率调整即可。 + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); + } + + public double ApplyToRate(double time, double rate = 1) => rate * SpeedChange.Value; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + if (!ConstantSpeed.Value) + return; + + if (drawableRuleset is DrawableManiaRuleset maniaRuleset) + maniaRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant; + } + + public override void ResetSettingsToDefaults() + { + base.ResetSettingsToDefaults(); + applyRangeFromStore(); + } + + private void applyRangeFromStore() + { + if (!UseGlobalAbRange.Value) + return; + + if (!LoopTimeRangeStore.TryGet(out double startMs, out double endMs)) + return; + + // Store is always milliseconds. + setCutTimeFromMs(startMs, endMs); + setResolvedCut(null, null); + } + + private void applyRangeFromStoreIfGlobal() + { + if (UseGlobalAbRange.Value) + applyRangeFromStore(); + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.BreakTime_Label), nameof(EzManiaModStrings.BreakTime_Description))] + public BindableDouble BreakTime { get; set; } = new BindableDouble(0) + { + MinValue = 0, + MaxValue = 20, + Precision = 0.1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Random_Label), nameof(EzManiaModStrings.Random_Description))] + public BindableBool Rand { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mirror_Label), nameof(EzManiaModStrings.Mirror_Description))] + public BindableBool Mirror { get; set; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InfiniteLoop_Label), nameof(EzManiaModStrings.InfiniteLoop_Description))] + public BindableBool InfiniteLoop { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MirrorTime_Label), nameof(EzManiaModStrings.MirrorTime_Description))] + public BindableInt MirrorTime { get; set; } = new BindableInt(1) + { + MinValue = 1, + MaxValue = 10, + Precision = 1 + }; + + //[SettingSource("Invert", "Invert next part.")] + //public BindableBool Invert { get; set; } = new BindableBool(false); + + //[SettingSource("Invert Time", "Every next time part will be inverted.")] + //public BindableInt InvertTime { get; set; } = new BindableInt(1) + //{ + // MinValue = 1, + // MaxValue = 10, + // Precision = 1 + //}; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + // 提供切片时间点给 DuplicateVirtualTrack 使用 + private void setResolvedCut(double? start, double? end) + { + ResolvedCutTimeStart = start; + ResolvedCutTimeEnd = end; + ResolvedSegmentLength = start.HasValue && end.HasValue ? Math.Max(0, end.Value - start.Value) : 0; + } + + private bool ensureResolvedForPreview(IWorkingBeatmap beatmap) + { + if (ResolvedSegmentLength > 0 && ResolvedCutTimeStart is not null && ResolvedCutTimeEnd is not null) + return true; + + try + { + var maniaBeatmap = (ManiaBeatmap)beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset); + + var (cutTimeStart, cutTimeEnd) = getEffectiveCutTimeMs(); + + // 若开始为空则取最早物件时间,若结束为空则取最晚物件时间(不再整体判无效)。 + var minTime = maniaBeatmap.HitObjects.MinBy(h => h.StartTime); + var maxTime = maniaBeatmap.HitObjects.MaxBy(h => h.GetEndTime()); + cutTimeStart ??= minTime?.StartTime; + cutTimeEnd ??= maxTime?.GetEndTime(); + + double? length = cutTimeEnd - cutTimeStart; + + if (length is null || length <= 0) + { + setResolvedCut(null, null); + return false; + } + + setResolvedCut(cutTimeStart, cutTimeEnd); + return true; + } + catch + { + setResolvedCut(null, null); + return false; + } + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + var maniaBeatmap = (ManiaBeatmap)beatmap; + + maniaBeatmap.Breaks.Clear(); + + var (cutTimeStart, cutTimeEnd) = getEffectiveCutTimeMs(); + + double breakTime = BreakTime.Value * 1000; + + // 改为最少一个非空设置 + var minTimeBeatmap = maniaBeatmap.HitObjects.MinBy(h => h.StartTime); + var maxTimeBeatmap = maniaBeatmap.HitObjects.MaxBy(h => h.GetEndTime()); + cutTimeStart ??= minTimeBeatmap?.StartTime; + cutTimeEnd ??= maxTimeBeatmap?.GetEndTime(); + + // IMPORTANT: compute length only after null defaults have been applied. + // Otherwise, when both cut times are null (default settings and no global A/B range), + // this mod would early-return and appear to have no effect (and thus no analysis change). + double? length = cutTimeEnd - cutTimeStart; + + var selectedPart = maniaBeatmap.HitObjects.Where(h => h.StartTime > cutTimeStart && h.GetEndTime() < cutTimeEnd).ToList(); + + if (length is null || length <= 0) + { + setResolvedCut(null, null); + return; + } + + setResolvedCut(cutTimeStart, cutTimeEnd); + + var newPart = new List(); + + for (int timeIndex = 0; timeIndex < LoopCount.Value; timeIndex++) + { + if (timeIndex == 0) + { + if (Rand.Value) + { + var shuffledColumns = Enumerable.Range(0, maniaBeatmap.TotalColumns).OrderBy(_ => rng.Next()).ToList(); + selectedPart.ForEach(h => h.Column = shuffledColumns[h.Column]); + } + + if (Mirror.Value) + { + } + + // 调整时间从切片起点开始 + foreach (var note in selectedPart) + { + note.StartTime -= (float)cutTimeStart!; + if (note is HoldNote holdNote) + holdNote.EndTime -= (float)cutTimeStart; + } + + newPart.AddRange(selectedPart); + continue; + } + + var obj = new List(); + + foreach (var note in selectedPart) + { + if (note.GetEndTime() != note.StartTime) + { + obj.Add(new HoldNote + { + Column = note.Column, + StartTime = note.StartTime + timeIndex * (breakTime + (double)length), + EndTime = note.GetEndTime() + timeIndex * (breakTime + (double)length), + NodeSamples = [note.Samples, Array.Empty()] + }); + } + else + { + obj.Add(new Note + { + Column = note.Column, + StartTime = note.StartTime + timeIndex * (breakTime + (double)length), + Samples = note.Samples, + }); + } + } + + if (Rand.Value) + { + var shuffledColumns = Enumerable.Range(0, maniaBeatmap.TotalColumns).OrderBy(_ => rng.Next()).ToList(); + obj.OfType().ForEach(h => h.Column = shuffledColumns[h.Column]); + } + + newPart.AddRange(obj); + } + + maniaBeatmap.HitObjects = newPart; + } + + // 将 Beatmap 交给 DuplicateVirtualTrack,用独立 Track 实例按切片参数播放 + public void ApplyToPlayer(Player player) + { + if (ResolvedSegmentLength <= 0) + return; + + pendingWorkingBeatmap = player.Beatmap.Value; + + // 计算总循环长度 + double totalLength = InfiniteLoop.Value ? double.MaxValue : LoopCount.Value * (ResolvedSegmentLength + BreakTime.Value * 1000); + pendingWorkingBeatmap.Track.Length = totalLength; + + duplicateTrack = new DuplicateVirtualTrack + { + OverrideProvider = this, + PendingOverrides = null, + }; + } + + public static string CalculateTime(double time) + { + int minute = Math.Abs((int)time / 60); + double second = Math.Abs(time % 60); + string minus = time < 0 ? "-" : string.Empty; + string secondLessThan10 = second < 10 ? "0" : string.Empty; + return $"{minus}{minute}:{secondLessThan10}{second:N1}"; + } + + // 需要有一个Drawable来承载虚拟音轨 + public void ApplyToHUD(HUDOverlay overlay) + { + if (duplicateTrack == null) + return; + + if (pendingWorkingBeatmap == null) + return; + + overlay.Add(duplicateTrack); + duplicateTrack.StartPreview(pendingWorkingBeatmap); + } + + public PreviewOverrideSettings? GetPreviewOverrides(IWorkingBeatmap beatmap) + { + if (!ensureResolvedForPreview(beatmap)) + return null; + + return new PreviewOverrideSettings + { + PreviewStart = ResolvedCutTimeStart, + PreviewDuration = ResolvedSegmentLength, + LoopCount = LoopCount.Value, + LoopInterval = BreakTime.Value * 1000, + ForceLooping = true, + EnableHitSounds = false + }; + } + + public void SetLoopTimeRange(double startTime, double endTime) + { + if (endTime <= startTime) + return; + + LoopTimeRangeStore.Set(startTime, endTime); + + // The editor timeline works in milliseconds, while this mod exposes seconds by default. + setCutTimeFromMs(startTime, endTime); + + // Reset preview cache so changes take effect immediately where used. + setResolvedCut(null, null); + + // Keep current instance in sync with the session store when global mode is enabled. + if (UseGlobalAbRange.Value) + applyRangeFromStore(); + } + + public bool PerformFail() => false; + + public bool RestartOnFail => false; + + // 简化后的统一参数访问器,自动适配全局/本地,单位换算集中 + private double? cutTimeStartMs + { + get => UseGlobalAbRange.Value && LoopTimeRangeStore.TryGet(out double startMs, out _) + ? startMs + : toMs(CutTimeStart.Value); + set + { + if (UseGlobalAbRange.Value) + setGlobalRange(value, cutTimeEndMs); + else + CutTimeStart.Value = fromMs(value); + } + } + + private double? cutTimeEndMs + { + get => UseGlobalAbRange.Value && LoopTimeRangeStore.TryGet(out _, out double endMs) + ? endMs + : toMs(CutTimeEnd.Value); + set + { + if (UseGlobalAbRange.Value) + setGlobalRange(cutTimeStartMs, value); + else + CutTimeEnd.Value = fromMs(value); + } + } + + // 工具方法,集中单位换算和全局写入 + private double? toMs(int? v) => v == null ? null : v * (Millisecond.Value ? 1 : 1000); + private int? fromMs(double? ms) => ms == null ? null : (Millisecond.Value ? (int)ms : (int)(ms / 1000d)); + private void setGlobalRange(double? start, double? end) => LoopTimeRangeStore.Set(start ?? 0, end ?? 0); + + // 新增:根据当前单位设置 CutTimeStart/End + private void setCutTimeFromMs(double startMs, double endMs) + { + CutTimeStart.Value = Millisecond.Value ? (int)startMs : (int)(startMs / 1000d); + CutTimeEnd.Value = Millisecond.Value ? (int)endMs : (int)(endMs / 1000d); + } + + // 获取当前生效的切片起止时间(毫秒) + private (double? startMs, double? endMs) getEffectiveCutTimeMs() + { + if (UseGlobalAbRange.Value && LoopTimeRangeStore.TryGet(out double startMs, out double endMs)) + return (startMs, endMs); + + return (cutTimeStartMs, cutTimeEndMs); + } + } + + /*public partial class CutStart : RoundedSliderBar + { + public override LocalisableString TooltipText + { + get + { + double value = Current.Value; + if (value == -10) + { + return "Original Start Time"; + } + return ManiaModLoopPlayClip.CalculateTime(value); + } + } + } + + public partial class CutEnd : RoundedSliderBar + { + public override LocalisableString TooltipText + { + get + { + double value = Current.Value; + if (value == 1800) + { + return "Original End Time"; + } + return ManiaModLoopPlayClip.CalculateTime(value); + } + } + }*/ +} diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModNiceBPM.cs b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModNiceBPM.cs new file mode 100644 index 0000000000..db34a9c750 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModNiceBPM.cs @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + public class ManiaModNiceBPM : Mod, IApplicableToRate, IApplicableToDrawableHitObject, IApplicableToBeatmap, IUpdatableByPlayfield + { + public override string Name => "Nice BPM"; + + public override string Acronym => "NB"; + + public override LocalisableString Description => EzManiaModStrings.NiceBPM_Description; + + public override ModType Type => ModType.LA_Mod; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + // public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp), typeof(ModAutoplay) }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InitialRate_Label), nameof(EzManiaModStrings.InitialRate_Description), SettingControlType = typeof(MultiplierSettingsSlider))] + public BindableNumber InitialRate { get; } = new BindableDouble(1) + { + MinValue = 0.2, + MaxValue = 2, + Precision = 0.01 + }; + + // [SettingSource("Free BPM", "BPM to speed", SettingControlType = typeof(SettingsNumberBox))] + // public Bindable FreeBPM { get; } = new Bindable(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))] + public BindableBool AdjustPitch { get; } = new BindableBool(false); + + public BindableNumber SpeedChange { get; } = new BindableDouble(1) + { + MinValue = min_allowable_rate, + MaxValue = max_allowable_rate, + }; + + private const double min_allowable_rate = 0.4d; + private const double max_allowable_rate = 2.5d; + + private const double min_allowable_rate_change = 0.9d; + private const double max_allowable_rate_change = 1.11d; + + private const double rate_change_on_miss = 0.95d; + + private double targetRate = 1d; + + private const int recent_rate_count = 8; + + /// + /// Stores the most recent approximated track rates + /// which are averaged to calculate the value of . + /// + /// + /// This list is used as a double-ended queue with fixed capacity + /// (items can be enqueued/dequeued at either end of the list). + /// When time is elapsing forward, items are dequeued from the start and enqueued onto the end of the list. + /// When time is being rewound, items are dequeued from the end and enqueued onto the start of the list. + /// + /// + /// + /// The track rate approximation is calculated as follows: + /// + /// + /// Consider a hitobject which ends at 1000ms, and assume that its preceding hitobject ends at 500ms. + /// This gives a time difference of 1000 - 500 = 500ms. + /// + /// + /// Now assume that the user hit this object at 980ms rather than 1000ms. + /// When compared to the preceding hitobject, this gives 980 - 500 = 480ms. + /// + /// + /// With the above assumptions, the player is rushing / hitting early, which means that the track should speed up to match. + /// Therefore, the approximated target rate for this object would be equal to 500 / 480 * . + /// + /// + private readonly List recentRates = Enumerable.Repeat(1d, recent_rate_count).ToList(); + + /// + /// For each given in the map, this dictionary maps the object onto the latest end time of any other object + /// that precedes the end time of the given object. + /// This can be loosely interpreted as the end time of the preceding hit object in rulesets that do not have overlapping hit objects. + /// + private readonly Dictionary precedingEndTimes = new Dictionary(); + + /// + /// For each given in the map, this dictionary maps the object onto the track rate dequeued from + /// (i.e. the oldest value in the queue) when the object is hit. If the hit is then reverted, + /// the mapped value can be re-introduced to to properly rewind the queue. + /// + private readonly Dictionary ratesForRewinding = new Dictionary(); + + private readonly RateAdjustModHelper rateAdjustHelper; + + // [Resolved] + // private IBindable beatmap { get; set; } = null!; + + public ManiaModNiceBPM() + { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); + + // if (beatmap == null) { throw new InvalidOperationException("Beatmap is not initialized."); } + // double bpm = beatmap.Value.BeatmapInfo.BPM; + + InitialRate.BindValueChanged(val => + { + SpeedChange.Value = val.NewValue; + targetRate = val.NewValue; + }, true); + + // FreeBPM.BindValueChanged(val => + // { + // SpeedChange.Value = val.NewValue / bpm; + // targetRate = val.NewValue / bpm; + // }, true); + } + + public void ApplyToTrack(IAdjustableAudioComponent track) + { + InitialRate.TriggerChange(); + recentRates.Clear(); + recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count)); + + rateAdjustHelper.ApplyToTrack(track); + } + + public void ApplyToSample(IAdjustableAudioComponent sample) + { + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); + } + + public void Update(Playfield playfield) + { + SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetRate, 50, playfield.Clock.ElapsedFrameTime); + } + + public double ApplyToRate(double time, double rate = 1) => rate * InitialRate.Value; + + public void ApplyToDrawableHitObject(DrawableHitObject drawable) + { + drawable.OnNewResult += (_, result) => + { + if (ratesForRewinding.ContainsKey(result.HitObject)) return; + if (!shouldProcessResult(result)) return; + + ratesForRewinding.Add(result.HitObject, recentRates[0]); + recentRates.RemoveAt(0); + + recentRates.Add(Math.Clamp(getRelativeRateChange(result) * SpeedChange.Value, min_allowable_rate, max_allowable_rate)); + + updateTargetRate(); + }; + drawable.OnRevertResult += (_, result) => + { + if (!ratesForRewinding.TryGetValue(result.HitObject, out double rate)) return; + if (!shouldProcessResult(result)) return; + + recentRates.Insert(0, rate); + ratesForRewinding.Remove(result.HitObject); + + recentRates.RemoveAt(recentRates.Count - 1); + + updateTargetRate(); + }; + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var hitObjects = getAllApplicableHitObjects(beatmap.HitObjects).ToList(); + var endTimes = hitObjects.Select(x => x.GetEndTime()).Order().Distinct().ToList(); + + foreach (HitObject hitObject in hitObjects) + { + int index = endTimes.BinarySearch(hitObject.GetEndTime()); + if (index < 0) index = ~index; // BinarySearch returns the next larger element in bitwise complement if there's no exact match + index -= 1; + + if (index >= 0) + precedingEndTimes.Add(hitObject, endTimes[index]); + } + } + + private IEnumerable getAllApplicableHitObjects(IEnumerable hitObjects) + { + foreach (var hitObject in hitObjects) + { + if (hitObject.HitWindows != HitWindows.Empty) + yield return hitObject; + + foreach (HitObject nested in getAllApplicableHitObjects(hitObject.NestedHitObjects)) + yield return nested; + } + } + + private bool shouldProcessResult(JudgementResult result) + { + if (!result.Type.AffectsAccuracy()) return false; + if (!precedingEndTimes.ContainsKey(result.HitObject)) return false; + + return true; + } + + private double getRelativeRateChange(JudgementResult result) + { + if (!result.IsHit) + return rate_change_on_miss; + + double prevEndTime = precedingEndTimes[result.HitObject]; + return Math.Clamp( + (result.HitObject.GetEndTime() - prevEndTime) / (result.TimeAbsolute - prevEndTime), + min_allowable_rate_change, + max_allowable_rate_change + ); + } + + /// + /// Update based on the values in . + /// + private void updateTargetRate() + { + // Compare values in recentRates to see how consistent the player's speed is + // If the player hits half of the notes too fast and the other half too slow: Abs(consistency) = 0 + // If the player hits all their notes too fast or too slow: Abs(consistency) = recent_rate_count - 1 + int consistency = 0; + + for (int i = 1; i < recentRates.Count; i++) + { + consistency += Math.Sign(recentRates[i] - recentRates[i - 1]); + } + + // Scale the rate adjustment based on consistency + targetRate = Interpolation.Lerp(targetRate, recentRates.Average(), Math.Abs(consistency) / (recent_rate_count - 1d)); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSRAdjust.cs b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSRAdjust.cs new file mode 100644 index 0000000000..90c27d225e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSRAdjust.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Globalization; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Mania.LAsEZMania.Analysis; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + public class ManiaModSRAdjust : Mod, IApplicableToDifficulty + { + public override string Name => "SR Adjust"; + + public override string Acronym => "SRA"; + + public override LocalisableString Description => "修正xxySR计算中的一些系数。影响的是难度卡上的SR(月亮星)数值。"; + + public override ModType Type => ModType.LA_Mod; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + + [SettingSource("Rescale Threshold", "超过此阈值后将降低难度膨胀速度", SettingControlType = typeof(MultiplierSettingsSlider))] + public BindableNumber RescaleThreshold { get; } = new BindableDouble(SRCalculator.RescaleHighThreshold) + { + MinValue = 5, + MaxValue = 10, + Precision = 1 + }; + + [SettingSource("LN Integral Multiplier", "LN 因子", SettingControlType = typeof(MultiplierSettingsSlider))] + public BindableNumber LnMultiplier { get; } = new BindableDouble(SRCalculator.LnIntegralMultiplier) + { + MinValue = 4, + MaxValue = 8, + Precision = 0.5 + }; + + public ManiaModSRAdjust() + { + RescaleThreshold.BindValueChanged(e => SRCalculator.RescaleHighThreshold = e.NewValue, true); + LnMultiplier.BindValueChanged(e => SRCalculator.LnIntegralMultiplier = e.NewValue, true); + } + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Rescale Threshold", new LocalisableString(RescaleThreshold.Value.ToString(CultureInfo.InvariantCulture))); + yield return ("LN Integral Multiplier", new LocalisableString(LnMultiplier.Value.ToString(CultureInfo.InvariantCulture))); + } + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSpaceBody.cs b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSpaceBody.cs new file mode 100644 index 0000000000..2ad3acb4db --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/LAsMods/ManiaModSpaceBody.cs @@ -0,0 +1,137 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.LAsMods +{ + /// + /// 需要同时使用IApplicableAfterBeatmapConversion, IHasApplyOrder + ///否则时序错误 + /// + public class ManiaModSpaceBody : Mod, IApplicableAfterBeatmapConversion, IHasApplyOrder + { + public override string Name => "Space Body"; + + public override string Acronym => "SB"; + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => EzManiaModStrings.SpaceBody_Description; + + public override ModType Type => ModType.LA_Mod; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + public override Type[] IncompatibleMods => new[] { typeof(ManiaModHoldOff) }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SpaceBody_Label), nameof(EzManiaModStrings.SpaceBodyGap_Description), SettingControlType = typeof(MultiplierSettingsSlider))] + public BindableNumber SpaceBeat { get; } = new BindableDouble(4) + { + MinValue = 1, + MaxValue = 16, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AddShield_Label), nameof(EzManiaModStrings.AddShield_Description))] + public BindableBool Shield { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ApplyOrder_Label), nameof(EzManiaModStrings.ApplyOrder_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable ApplyOrderSetting { get; } = new Bindable(100); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + var newObjects = new List(); + var lastHolds = new List(); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + var newColumnObjects = new List(); + + var locations = Shield.Value + ? column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0)), + (startTime: h.EndTime, samples: h.GetNodeSamples(1)) + })) + .OrderBy(h => h.startTime).ToList() + : column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0)), + })) + .OrderBy(h => h.startTime).ToList(); + + for (int i = 0; i < locations.Count - 1; i++) + { + // 长按音符的完整持续时间。 + double duration = locations[i + 1].startTime - locations[i].startTime; + + // 长按音符结束时的拍长。 + double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i + 1].startTime).BeatLength; + + // 减少持续时间最多1/4拍,以确保没有瞬时音符。 + // duration = Math.Max(duration / 2, duration - beatLength / 4); + duration = Math.Max(duration / 2, duration - beatLength / SpaceBeat.Value); + + newColumnObjects.Add(new HoldNote + { + Column = Math.Clamp(column.Key, 0, maniaBeatmap.TotalColumns - 1), + StartTime = locations[i].startTime, + Duration = duration, + NodeSamples = new List> { locations[i].samples, Array.Empty() } + }); + } + + newObjects.AddRange(newColumnObjects); + + if (newColumnObjects.Any()) + { + var last = (HoldNote)newColumnObjects.Last(); + lastHolds.Add(last); + } + } + + // 将每列最后一个长按音符的结束时间对齐到下一个 1/4 节拍 + if (lastHolds.Any()) + { + double maxEndTime = lastHolds.Max(h => h.StartTime + h.Duration); + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(maxEndTime); + double beatLength = timingPoint.BeatLength; + double offset = timingPoint.Time; + double currentBeats = (maxEndTime - offset) / beatLength; + double alignedBeats = Math.Ceiling(currentBeats * 4) / 4; + double alignedEndTime = offset + alignedBeats * beatLength; + + foreach (var last in lastHolds) + { + last.Duration = alignedEndTime - last.StartTime; + } + } + + maniaBeatmap.HitObjects = newObjects.OrderBy(h => h.StartTime).ToList(); + + // 无休息时间 + maniaBeatmap.Breaks.Clear(); + } + + // 确认此 Mod 在其他转换后 Mod 之后应用,返回更高的应用顺序。 + // 没有此接口的 Mod 被视为顺序 0。 + public int ApplyOrder => ApplyOrderSetting.Value ?? 100; + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModAdjust.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModAdjust.cs new file mode 100644 index 0000000000..2eb563edb1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModAdjust.cs @@ -0,0 +1,614 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public partial class ManiaModAdjust : ModRateAdjust, + IApplicableAfterConversion, + IApplicableToDifficulty, + IApplicableToBeatmap, + IManiaRateAdjustmentMod, + IApplicableToDrawableRuleset, + IApplicableFailOverride, + IApplicableToHUD, + IReadFromConfig, + IApplicableToHealthProcessor, + IApplicableToScoreProcessor, + IHasSeed + { + public override string Name => @"Adjust"; + + public override LocalisableString Description => EzManiaModStrings.Adjust_Description; + + public override string Acronym => "AJ"; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IconUsage? Icon => FontAwesome.Solid.Atlas; + + public override double ScoreMultiplier => ScoreMultiplierAdjust.Value; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => false; + public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock), typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) }; + + public BindableDouble OriginalOD = new BindableDouble(); + + [SettingSource("Score Multiplier")] + public BindableNumber ScoreMultiplierAdjust { get; } = new BindableDouble(1) + { + MinValue = 0, + MaxValue = 10, + Precision = 0.01 + }; + + public ManiaHitWindows HitWindows { get; set; } = new ManiaHitWindows(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.HPDrain_Label), nameof(EzManiaModStrings.HPDrain_Description), SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable DrainRate { get; } = new DifficultyBindable(0) + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 10, + ExtendedMaxValue = 15, + ReadCurrentFromDifficulty = diff => diff.DrainRate + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustAccuracy_Label), nameof(EzManiaModStrings.AdjustAccuracy_Description), + SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable(0) + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 10, + ExtendedMaxValue = 15, + ReadCurrentFromDifficulty = diff => diff.OverallDifficulty + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ReleaseLenience_Label), nameof(EzManiaModStrings.ReleaseLenience_Description))] + public BindableDouble ReleaseLenience { get; } = new BindableDouble(2) + { + MaxValue = 4, + MinValue = 0.1, + Precision = 0.1 + }; + + [SettingSource("Custom HP")] + public BindableBool CustomHP { get; } = new BindableBool(false); + + [SettingSource("Custom OD")] + public BindableBool CustomOD { get; } = new BindableBool(true); + + [SettingSource("Custom Release")] + public BindableBool CustomRelease { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ExtendedLimits_Label), nameof(EzManiaModStrings.ExtendedLimits_Description))] + public BindableBool ExtendedLimits { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustConstantSpeed_Label), nameof(EzManiaModStrings.AdjustConstantSpeed_Description))] + public BindableBool ConstantSpeed { get; } = new BindableBool(true); + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (!ScoreMultiplierAdjust.IsDefault) yield return ("Score Multiplier", $"{ScoreMultiplierAdjust.Value:N3}"); + + if (CustomHP.Value) yield return ("HP", $"{DrainRate.Value:N1}"); + + if (CustomOD.Value) yield return ("OD", $"{OverallDifficulty.Value:N1}"); + + if (CustomRelease.Value) yield return ("Release Lenience", $"{ReleaseLenience.Value:N1}"); + + if (!SpeedChange.IsDefault) yield return ("Speed", $"{SpeedChange.Value:N3}"); + + if (AdjustPitch.Value) yield return ("Adjust Pitch", "On"); + + if (ConstantSpeed.Value) yield return ("Constant Speed", "On"); + + if (Mirror.Value) yield return ("Mirror", "On"); + + if (RandomMirror.Value) yield return ("Random Mirror", "On"); + + if (NoFail.Value) yield return ("No Fail", "On"); + + if (Restart.Value) yield return ("Restart", "On"); + + if (RandomSelect.Value) yield return ("Random", "On"); + + if (TrueRandom.Value) yield return ("True Random", "On"); + + if (Seed.Value is not null) yield return ("Seed", $"Seed {Seed.Value}"); + + if (CustomHitRange.Value) + { + yield return ("Perfect Hit", $"{PerfectHit.Value}ms"); + yield return ("Great Hit", $"{GreatHit.Value}ms"); + yield return ("Good Hit", $"{GoodHit.Value}ms"); + yield return ("Ok Hit", $"{OkHit.Value}ms"); + yield return ("Meh Hit", $"{MehHit.Value}ms"); + yield return ("Miss Hit", $"{MissHit.Value}ms"); + } + + if (CustomProportionScore.Value) + { + yield return ("Perfect", $"{Perfect.Value}"); + yield return ("Great", $"{Great.Value}"); + yield return ("Good", $"{Good.Value}"); + yield return ("Ok", $"{Ok.Value}"); + yield return ("Meh", $"{Meh.Value}"); + yield return ("Miss", $"{Miss.Value}"); + } + } + } + + public override string ExtendedIconInformation => ""; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SpeedChange_Label), nameof(EzManiaModStrings.SpeedChange_Description), + SettingControlType = typeof(MultiplierSettingsSlider))] + public override BindableNumber SpeedChange { get; } = new BindableDouble(1) + { + MinValue = 0.1, + MaxValue = 2.5, + Precision = 0.025 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))] + public virtual BindableBool AdjustPitch { get; } = new BindableBool(); + + private readonly RateAdjustModHelper rateAdjustHelper; + + public ManiaModAdjust() + { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + + foreach (var (_, property) in this.GetOrderedSettingsSourceProperties()) + { + if (property.GetValue(this) is DifficultyBindable diffAdjustBindable) + diffAdjustBindable.ExtendedLimits.BindTo(ExtendedLimits); + } + + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); + + CustomHitRange.BindValueChanged(_ => updateCustomHitRange()); + PerfectHit.BindValueChanged(_ => updateCustomHitRange()); + GreatHit.BindValueChanged(_ => updateCustomHitRange()); + GoodHit.BindValueChanged(_ => updateCustomHitRange()); + OkHit.BindValueChanged(_ => updateCustomHitRange()); + MehHit.BindValueChanged(_ => updateCustomHitRange()); + MissHit.BindValueChanged(_ => updateCustomHitRange()); + } + + private void updateCustomHitRange() + { + if (CustomHitRange.Value) + { + HitWindows.ModifyManiaHitRange(new ManiaModifyHitRange( + PerfectHit.Value, + GreatHit.Value, + GoodHit.Value, + OkHit.Value, + MehHit.Value, + MissHit.Value + )); + } + else + { + HitWindows.ResetRange(); + } + } + + /// + /// Apply all custom settings to the provided beatmap. + /// + /// The beatmap to have settings applied. + protected void ApplySettings(BeatmapDifficulty difficulty) + { + if (DrainRate.Value != null && CustomHP.Value) + difficulty.DrainRate = DrainRate.Value.Value; + + if (OverallDifficulty.Value != null && CustomOD.Value && !CustomHitRange.Value) + { + OriginalOD.Value = difficulty.OverallDifficulty; + difficulty.OverallDifficulty = OverallDifficulty.Value.Value; + } + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + HitWindows.SpeedMultiplier = SpeedChange.Value; + + HitWindows.SetDifficulty(difficulty.OverallDifficulty); + + ApplySettings(difficulty); + AdjustHoldNote.ReleaseLenience = ReleaseLenience.Value; + AdjustTailNote.ReleaseLenience = ReleaseLenience.Value; + AdjustDrawableHoldNoteTail.ReleaseLenience = ReleaseLenience.Value; + } + + public override void ApplyToTrack(IAdjustableAudioComponent track) + { + rateAdjustHelper.ApplyToTrack(track); + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + + if (ConstantSpeed.Value) maniaRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant; + + if (CustomRelease.Value) + { + foreach (var stage in maniaRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) column.RegisterPool(10, 50); + } + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mirror_Label), nameof(EzManiaModStrings.Mirror_Description))] + public BindableBool Mirror { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.RandomMirror_Label), nameof(EzManiaModStrings.RandomMirror_Description))] + public BindableBool RandomMirror { get; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoFail_Label), nameof(EzManiaModStrings.NoFail_Description))] + public BindableBool NoFail { get; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Restart_Label), nameof(EzManiaModStrings.Restart_Description))] + public BindableBool Restart { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.RandomSelect_Label), nameof(EzManiaModStrings.RandomSelect_Description))] + public BindableBool RandomSelect { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.TrueRandom_Label), nameof(EzManiaModStrings.TrueRandom_Description))] + public BindableBool TrueRandom { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + if (Test.Value) + { + var obj = maniaBeatmap; + var groups = obj.HitObjects.GroupBy(c => c.Column).OrderBy(c => c.Key); + // int note = obj.HitObjects.Select(h => h.GetEndTime() != h.StartTime).Count(); + // int note = obj.HitObjects.Count - note; + foreach (var column in groups) Logger.Log($"Column {column.Key + 1}: {column.Count()} notes", level: LogLevel.Important); + //Logger.Log($"Test:\nThis beatmap has {obj.HitObjects.Count} HitObjects.\n", level: LogLevel.Important); + } + + if (RandomSelect.Value) + { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + int availableColumns = maniaBeatmap.TotalColumns; + var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(_ => rng.Next()).ToList(); + beatmap.HitObjects.OfType().ForEach(h => h.Column = shuffledColumns[h.Column]); + } + + if (Mirror.Value) + { + int availableColumns = maniaBeatmap.TotalColumns; + beatmap.HitObjects.OfType().ForEach(h => h.Column = availableColumns - 1 - h.Column); + } + + if (RandomMirror.Value) + { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + if (rng.Next() % 2 == 0) + { + int availableColumns = maniaBeatmap.TotalColumns; + beatmap.HitObjects.OfType().ForEach(h => h.Column = availableColumns - 1 - h.Column); + } + } + + if (TrueRandom.Value) + { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + int availableColumns = maniaBeatmap.TotalColumns; + + foreach (var obj in beatmap.HitObjects.OfType().GroupBy(c => c.StartTime)) + { + var columnList = new List(); + foreach (var hit in obj) columnList.Add(hit.Column); + var newColumn = Enumerable.Range(0, availableColumns).SelectRandom(rng, columnList.Count).ToList(); + int index = 0; + + foreach (var hit in obj) + { + hit.Column = newColumn[index]; + index++; + } + } + } + } + + //------Fail Condition------ + private Action? triggerFailureDelegate; + + private readonly Bindable showHealthBar = new Bindable(); + + public bool PerformFail() + { + return !NoFail.Value; + } + + public bool RestartOnFail + { + get + { + if (NoFail.Value) return !NoFail.Value; + + return Restart.Value; + } + } + + public void ReadFromConfig(OsuConfigManager config) + { + config.BindWith(OsuSetting.ShowHealthDisplayWhenCantFail, showHealthBar); + } + + public void ApplyToHUD(HUDOverlay overlay) + { + overlay.ShowHealthBar.BindTo(showHealthBar); + } + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) + { + triggerFailureDelegate = healthProcessor.TriggerFailure; + } + + protected void TriggerFailure() + { + triggerFailureDelegate?.Invoke(); + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomHitRange_Label), nameof(EzManiaModStrings.CustomHitRange_Description))] + public BindableBool CustomHitRange { get; } = new BindableBool(); + + [SettingSource("Perfect")] + public BindableDouble PerfectHit { get; } = new BindableDouble(22.4D) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Great")] + public BindableDouble GreatHit { get; } = new BindableDouble(64) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Good")] + public BindableDouble GoodHit { get; } = new BindableDouble(97) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Ok")] + public BindableDouble OkHit { get; } = new BindableDouble(127) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Meh")] + public BindableDouble MehHit { get; } = new BindableDouble(151) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Miss")] + public BindableDouble MissHit { get; } = new BindableDouble(188) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Custom Proportion Score")] + public BindableBool CustomProportionScore { get; } = new BindableBool(); + + [SettingSource("Perfect")] + public BindableInt Perfect { get; } = new BindableInt(300) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Great")] + public BindableInt Great { get; } = new BindableInt(300) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Good")] + public BindableInt Good { get; } = new BindableInt(200) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Ok")] + public BindableInt Ok { get; } = new BindableInt(100) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Meh")] + public BindableInt Meh { get; } = new BindableInt(50) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Miss")] + public BindableInt Miss { get; } = new BindableInt(0) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Test")] + public BindableBool Test { get; } = new BindableBool(); + + private readonly BindableInt combo = new BindableInt(); + + private readonly BindableDouble accuracy = new BindableDouble(); + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) + { + return rank; + } + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + // var mania = (ManiaScoreProcessor)scoreProcessor; + + // if (CustomProportionScore.Value) + // { + // mania.HitProportionScore.Perfect = Perfect.Value; + // mania.HitProportionScore.Great = Great.Value; + // mania.HitProportionScore.Good = Good.Value; + // mania.HitProportionScore.Ok = Ok.Value; + // mania.HitProportionScore.Meh = Meh.Value; + // mania.HitProportionScore.Miss = Miss.Value; + // } + + combo.UnbindAll(); + accuracy.UnbindAll(); + combo.BindTo(scoreProcessor.Combo); + accuracy.BindTo(scoreProcessor.Accuracy); + } + + public override void ResetSettingsToDefaults() + { + base.ResetSettingsToDefaults(); + HitWindows.ResetRange(); + } + + public void ApplyToBeatmapAfterConversion(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + if (CustomRelease.Value) + { + var hitObjects = maniaBeatmap.HitObjects.Select(obj => + { + if (obj is HoldNote hold) + return new AdjustHoldNote(hold); + + return obj; + }).ToList(); + + maniaBeatmap.HitObjects = hitObjects; + } + } + + public partial class AdjustDrawableHoldNoteTail : DrawableHoldNoteTail + { + public static double ReleaseLenience; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + base.CheckForResult(userTriggered, timeOffset * TailNote.RELEASE_WINDOW_LENIENCE / ReleaseLenience); + } + } + + private class AdjustTailNote : TailNote + { + public static double ReleaseLenience; + + public override double MaximumJudgementOffset => base.MaximumJudgementOffset / RELEASE_WINDOW_LENIENCE * ReleaseLenience; + } + + private class AdjustHoldNote : HoldNote + { + public static double ReleaseLenience; + + public AdjustHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new HeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0) + }); + + AddNested(Tail = new AdjustTailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1) + }); + + AddNested(Body = new HoldNoteBody + { + StartTime = StartTime, + Column = Column + }); + } + + public override double MaximumJudgementOffset => base.MaximumJudgementOffset / TailNote.RELEASE_WINDOW_LENIENCE * ReleaseLenience; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModChangeSpeedByAccuracy.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModChangeSpeedByAccuracy.cs new file mode 100644 index 0000000000..11f30d35b2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModChangeSpeedByAccuracy.cs @@ -0,0 +1,125 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModChangeSpeedByAccuracy : Mod, IUpdatableByPlayfield, IApplicableToScoreProcessor, IApplicableToRate + { + public override string Name => "Speed & Accuracy"; + + public override string Acronym => "SA"; + + public override LocalisableString Description => EzManiaModStrings.ChangeSpeedByAccuracy_Description; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IconUsage? Icon => FontAwesome.Solid.ChartLine; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + + public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) }; + + private readonly BindableDouble accuracy = new BindableDouble(); + + private readonly RateAdjustModHelper rateAdjustHelper; + + public BindableNumber SpeedChange { get; } = new BindableDouble(1) + { + Precision = 0.01 + }; + + private double targetSpeed = 1; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ChangeSpeedAccuracy_Label), nameof(EzManiaModStrings.ChangeSpeedAccuracy_Description))] + public BindableDouble Accuracy { get; } = new BindableDouble(95) + { + MinValue = 0, + MaxValue = 100, + Precision = 0.5, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MaxSpeed_Label), nameof(EzManiaModStrings.MaxSpeed_Description))] + public BindableDouble MaxSpeed { get; } = new BindableDouble(1.5) + { + MinValue = 1, + MaxValue = 2, + Precision = 0.1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MinSpeed_Label), nameof(EzManiaModStrings.MinSpeed_Description))] + public BindableDouble MinSpeed { get; } = new BindableDouble(0.5) + { + MinValue = 0.5, + MaxValue = 1, + Precision = 0.1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))] + public virtual BindableBool AdjustPitch { get; } = new BindableBool(); + + public ManiaModChangeSpeedByAccuracy() + { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); + } + + public void Update(Playfield playfield) + { + UpdateTargetSpeed(); + SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetSpeed, 40, playfield.Clock.ElapsedFrameTime); + } + + public void UpdateTargetSpeed() + { + double currentAccuracy = accuracy.Value; + + double accuracyDifference = currentAccuracy - Accuracy.Value; + + if (accuracyDifference > 0) + { + targetSpeed = Math.Min(MaxSpeed.Value, targetSpeed + accuracyDifference * 0.01); + } + else + { + targetSpeed = Math.Max(MinSpeed.Value, targetSpeed - Math.Abs(accuracyDifference) * 0.01); + } + + SpeedChange.Value = targetSpeed; + } + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + accuracy.UnbindAll(); + accuracy.BindTo(scoreProcessor.Accuracy); + } + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + public double ApplyToRate(double time, double rate = 1) => rate * SpeedChange.Value; + + public void ApplyToTrack(IAdjustableAudioComponent track) + { + rateAdjustHelper.ApplyToTrack(track); + } + + public void ApplyToSample(IAdjustableAudioComponent sample) + { + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModCleaner.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModCleaner.cs new file mode 100644 index 0000000000..1fc1ec806e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModCleaner.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModCleaner : Mod, IApplicableAfterBeatmapConversion + { + public override string Name => "Cleaner"; + + public override string Acronym => "CL"; + + public override LocalisableString Description => EzManiaModStrings.Cleaner_Description; + + public override IconUsage? Icon => FontAwesome.Solid.Broom; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Style", $"{Style.Value}"); + yield return ("Interval", $"{Interval.Value}ms"); + yield return ("LN Interval", $"{LNInterval.Value}ms"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Style_Label), nameof(EzManiaModStrings.Style_Description))] + public BindableNumber Style { get; set; } = new BindableInt(2) + { + MinValue = 1, + MaxValue = 2, + Precision = 1, + }; + + // DurationEveryDivide = 60 / bpm / divide * 10000 + // 125ms is equivalent to duration time between adjacent every two 120BPM 1/4 timing line. + // 125ms 相当于 120BPM 1/4 叠键每两行的时间间隔 + // 以下个人用方便消除乱键子弹使用 + // + // Level 1: 125.00ms + // 120BPM - 125.00ms 130BPM - 115.38ms 140BPM - 107.14ms + // 150BPM - 100.00ms + // + // Level 2: 100.00ms + // 160BPM - 93.75ms 170BPM - 88.23ms 180BPM - 83.33ms + // 190BPM - 78.94ms + // + // Level 3: 75.00ms + // 200BPM - 75.00ms 210BPM - 71.42ms 220BPM - 68.18ms + // 230BPM - 65.21ms 240BPM - 62.50ms + // + // Level4: 60.00ms + // 250BPM - 60.00ms 260BPM - 57.69ms 270BPM - 55.55ms + // 280BPM - 53.57ms 290BPM - 51.72ms 300BPM - 50.00ms + // + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Interval_Label), nameof(EzManiaModStrings.Interval_Description))] + public BindableNumber Interval { get; set; } = new BindableInt(80) + { + MinValue = 1, + MaxValue = 125, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LNInterval_Label), nameof(EzManiaModStrings.LNInterval_Description))] + public BindableNumber LNInterval { get; set; } = new BindableInt(30) + { + MinValue = 1, + MaxValue = 125, + Precision = 1, + }; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + var newObjects = new List(); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + var newColumnObjects = new List(); + + var locations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + })) + .OrderBy(h => h.startTime).ToList(); + + double lastStartTime = locations[0].startTime; + double lastEndTime = locations[0].endTime; + var lastSample = locations[0].samples; + + // Zero + //if (lastStartTime != lastEndTime) + //{ + // newColumnObjects.Add(new HoldNote + // { + // Column = column.Key, + // StartTime = lastStartTime, + // Duration = lastEndTime - lastStartTime, + // NodeSamples = [locations[0].samples, Array.Empty()] + // }); + //} + //else + //{ + // newColumnObjects.Add(new Note + // { + // Column = column.Key, + // StartTime = lastStartTime, + // Samples = locations[0].samples + // }); + //} + + for (int i = 0; i < locations.Count; i++) + { + if (i == 0) + { + lastStartTime = locations[0].startTime; + lastEndTime = locations[0].endTime; + lastSample = locations[0].samples; + continue; + } + + if (locations[i].startTime >= lastStartTime && locations[i].startTime <= lastEndTime) + { + locations.RemoveAt(i); + i--; + continue; + } // if the note in a LN + + if (Math.Abs(locations[i].startTime - lastStartTime) <= Interval.Value) + { + if (Style.Value == 2) + { + lastStartTime = locations[i].startTime; + lastEndTime = locations[i].endTime; + lastSample = locations[i].samples; + } + + locations.RemoveAt(i); + i--; + continue; + } // interval judgement + + if (Math.Abs(locations[i].startTime - lastEndTime) <= LNInterval.Value) + { + if (Style.Value == 2) + { + lastStartTime = locations[i].startTime; + lastEndTime = locations[i].endTime; + lastSample = locations[i].samples; + } + + locations.RemoveAt(i); + i--; + continue; + } // LN interval judgement + + newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime); + + lastStartTime = locations[i].startTime; + lastEndTime = locations[i].endTime; + lastSample = locations[i].samples; + } + + newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime); + + // Last + //if (lastStartTime != lastEndTime) + //{ + // newColumnObjects.Add(new HoldNote + // { + // Column = column.Key, + // StartTime = locations[locations.Count - 1].startTime, + // Duration = locations[locations.Count - 1].endTime - locations[locations.Count - 1].startTime, + // NodeSamples = [locations[locations.Count - 1].samples, Array.Empty()] + // }); + //} + //else + //{ + // newColumnObjects.Add(new Note + // { + // Column = column.Key, + // StartTime = lastStartTime, + // Samples = locations[locations.Count - 1].samples + // }); + //} + + newObjects.AddRange(newColumnObjects); + } + + maniaBeatmap.HitObjects = [.. newObjects.OrderBy(h => h.StartTime)]; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModDoublelPlay.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModDoublelPlay.cs new file mode 100644 index 0000000000..e0718f87ea --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModDoublelPlay.cs @@ -0,0 +1,509 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModDoublePlay : Mod, IApplicableToBeatmapConverter, IApplicableAfterBeatmapConversion + { + public override string Name => "Double Play"; + + public override string Acronym => "DP"; + + public override IconUsage? Icon => FontAwesome.Solid.Sun; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override LocalisableString Description => "Convert 4k to 8k (Double 4k)."; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Style", $"Style {Style.Value}"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DoublePlayStyle_Label), nameof(EzManiaModStrings.DoublePlayStyle_Description))] + public BindableNumber Style { get; } = new BindableInt(1) + { + MinValue = 1, + MaxValue = 8, + Precision = 1, + }; + + public void ApplyToBeatmapConverter(IBeatmapConverter converter) + { + var mbc = (ManiaBeatmapConverter)converter; + + float keys = mbc.TotalColumns; + + if (keys != 4) + { + return; + } + + mbc.TargetColumns = 8; + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + int keys = (int)maniaBeatmap.Difficulty.CircleSize; + + if (keys != 4) + { + return; + } + + var newObjects = new List(); + + var newColumnObjects = new List(); + + var locations = maniaBeatmap.HitObjects.OfType().Select(n => ( + startTime: n.StartTime, + samples: n.Samples, + column: n.Column, + endTime: n.StartTime + )) + .Concat(maniaBeatmap.HitObjects.OfType().Select(h => ( + startTime: h.StartTime, + samples: h.Samples, + column: h.Column, + endTime: h.EndTime + ))).OrderBy(h => h.startTime).ToList(); + + for (int i = 0; i < locations.Count; i++) + { + bool isLN = false; + var note = new Note(); + var hold = new HoldNote(); + int columnIndex = locations[i].column; + + switch (columnIndex) + { + case 1: + { + columnIndex = 0; + } + break; + + case 3: + { + columnIndex = 1; + } + break; + + case 5: + { + columnIndex = 2; + + if (Style.Value >= 5 && Style.Value <= 8) + { + columnIndex = 4; + } + } + break; + + case 7: + { + columnIndex = 3; + + if (Style.Value >= 5 && Style.Value <= 8) + { + columnIndex = 5; + } + } + break; + } + + if (locations[i].startTime == locations[i].endTime) + { + note.StartTime = locations[i].startTime; + note.Samples = locations[i].samples; + } + else + { + hold.StartTime = locations[i].startTime; + hold.Samples = locations[i].samples; + hold.EndTime = locations[i].endTime; + isLN = true; + } + + if (isLN) + { + switch (Style.Value) + { + case 1: + { + newColumnObjects.Add(new HoldNote + { + Column = columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 4 + columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + break; + + case 2: + { + newColumnObjects.Add(new HoldNote + { + Column = 3 - columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 7 - columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + break; + + case 3: + { + newColumnObjects.Add(new HoldNote + { + Column = columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 7 - columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + break; + + case 4: + { + newColumnObjects.Add(new HoldNote + { + Column = 3 - columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 4 + columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + break; + + case 5: + { + newColumnObjects.Add(new HoldNote + { + Column = columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 2 + columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + break; + + case 6: + { + if (columnIndex <= 1) + { + columnIndex = 3 - columnIndex; + } + + if (columnIndex >= 4) + { + columnIndex = 7 - columnIndex + 4; + } + + newColumnObjects.Add(new HoldNote + { + Column = columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = columnIndex - 2, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + break; + + case 7: + case 8: + { + if (Style.Value == 8) + { + if (columnIndex == 0 || columnIndex == 4) + { + columnIndex++; + } + else if (columnIndex == 1 || columnIndex == 5) + { + columnIndex--; + } + } + + if (columnIndex < 4) + { + newColumnObjects.Add(new HoldNote + { + Column = columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 3 - columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + + if (columnIndex > 3) + { + newColumnObjects.Add(new HoldNote + { + Column = columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 7 - (columnIndex - 4), + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + } + break; + } + } + else + { + switch (Style.Value) + { + case 1: + { + newColumnObjects.Add(new Note + { + Column = columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + newColumnObjects.Add(new Note + { + Column = columnIndex + 4, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + break; + + case 2: + { + newColumnObjects.Add(new Note + { + Column = 3 - columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + newColumnObjects.Add(new Note + { + Column = 7 - columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + break; + + case 3: + { + newColumnObjects.Add(new Note + { + Column = columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + newColumnObjects.Add(new Note + { + Column = 7 - columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + break; + + case 4: + { + newColumnObjects.Add(new Note + { + Column = 3 - columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + newColumnObjects.Add(new Note + { + Column = 4 + columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + break; + + case 5: + { + newColumnObjects.Add(new Note + { + Column = columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + newColumnObjects.Add(new Note + { + Column = 2 + columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + break; + + case 6: + { + if (columnIndex <= 1) + { + columnIndex = 3 - columnIndex; + } + + if (columnIndex >= 4) + { + columnIndex = 7 - columnIndex + 4; + } + + newColumnObjects.Add(new Note + { + Column = columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + newColumnObjects.Add(new Note + { + Column = columnIndex - 2, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + break; + + case 7: + case 8: + { + if (Style.Value == 8) + { + if (columnIndex == 0 || columnIndex == 4) + { + columnIndex++; + } + else if (columnIndex == 1 || columnIndex == 5) + { + columnIndex--; + } + } + + if (columnIndex < 4) + { + newColumnObjects.Add(new Note + { + Column = columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + newColumnObjects.Add(new Note + { + Column = 3 - columnIndex, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + + if (columnIndex > 3) + { + newColumnObjects.Add(new HoldNote + { + Column = columnIndex, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + newColumnObjects.Add(new HoldNote + { + Column = 7 - (columnIndex - 4), + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + } + break; + } + } + } + + newObjects.AddRange(newColumnObjects); + maniaBeatmap.HitObjects = newObjects; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModGracer.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModGracer.cs new file mode 100644 index 0000000000..1036adb837 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModGracer.cs @@ -0,0 +1,196 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModGracer : Mod, IApplicableAfterBeatmapConversion, IHasSeed + { + public const double MIN_INTERVAL = 10; + + public const double PLUS_INTERVAL = 2.2; + + public override string Name => "Gracer"; + + public override string Acronym => "GR"; + + public override double ScoreMultiplier => 1; + + public override IconUsage? Icon => FontAwesome.Solid.Star; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override LocalisableString Description => EzManiaModStrings.Gracer_Description; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Bias", $"{Bias.Value}"); + yield return ("Interval", $"{Interval.Value}ms"); + yield return ("Probability", $"{Probability.Value}%"); + yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Bias_Label), nameof(EzManiaModStrings.Bias_Description))] + public BindableNumber Bias { get; set; } = new BindableInt(16) + { + MinValue = 1, + MaxValue = 50, + Precision = 1 + }; + + // If interval is too high which will have bug taken place. + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Interval_Label), nameof(EzManiaModStrings.Interval_Description))] + public BindableNumber Interval { get; set; } = new BindableNumber(20) + { + MinValue = 1, + MaxValue = 50, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Probability_Label), nameof(EzManiaModStrings.Probability_Description))] + public BindableNumber Probability { get; set; } = new BindableInt(100) + { + MinValue = 0, + MaxValue = 100, + Precision = 5 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + var newObjects = new List(); + + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + var newColumnObjects = new List(); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + var locations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + })) + .OrderBy(h => h.startTime).ToList(); + + double lastStartTime = int.MinValue; + double lastEndTime = int.MaxValue; + bool? lastIsLN = null; + + for (int i = 0; i < locations.Count; i++) + { + bool isLN = locations[i].startTime != locations[i].endTime; + double startTime = locations[i].startTime + rng.Next(-Bias.Value, Bias.Value) + rng.NextDouble(); + double endTime = locations[i].endTime + rng.Next(-Bias.Value, Bias.Value) + rng.NextDouble(); + + if (lastStartTime != int.MinValue && lastEndTime != int.MaxValue) + { + if (lastIsLN == true) + { + while (startTime >= lastStartTime && startTime <= lastEndTime + Interval.Value) + { + startTime += PLUS_INTERVAL; + } + + while (endTime <= startTime /* + Interval.Value*/) + { + endTime += PLUS_INTERVAL; + } + } + else + { + while (startTime <= lastStartTime + Interval.Value) + { + startTime += PLUS_INTERVAL; + } + + while (endTime <= startTime /* + Interval.Value */) + { + endTime += PLUS_INTERVAL; + } + } + } + + if (rng.Next(100) < Probability.Value) + { + if (locations[i].startTime != locations[i].endTime) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = startTime, + Duration = endTime - startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = startTime, + Samples = locations[i].samples + }); + } + } + else + { + if (locations[i].startTime != locations[i].endTime) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + } + + lastStartTime = startTime; + lastEndTime = endTime; + lastIsLN = isLN; + } + } + + newObjects.AddRange(newColumnObjects); + maniaBeatmap.HitObjects = [.. newObjects.OrderBy(h => h.StartTime)]; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModHelper.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModHelper.cs new file mode 100644 index 0000000000..6e5d1e0b89 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModHelper.cs @@ -0,0 +1,635 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public static class ManiaModHelper + { + public static readonly int[] DIVIDE_NUMBER = [2, 4, 8, 3, 6, 9, 5, 7, 12, 16, 48, 35, 64]; + + public static void AddOriginalNoteByColumn(List newObjects, IGrouping column) + { + var newColumnObjects = new List(); + var locations = column.OfType().Select(n => (startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, endTime: h.EndTime, samples: h.GetNodeSamples(0)) + //(startTime: h.EndTime, samples: h.GetNodeSamples(1)) + })) + .OrderBy(h => h.startTime).ToList(); + + for (int i = 0; i < locations.Count; i++) + { + if (locations[i].startTime != locations[i].endTime) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[i].startTime, + EndTime = locations[i].endTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + } + + newObjects.AddRange(newColumnObjects); + } + + [Obsolete] + public static void Transform(Random rng, + double mu, + double sigmaDivisor, + int divide, + int percentage, + double error, + bool originalLN, + IBeatmap beatmap, + List newObjects, + List oldObjects, + int gap = -1, + int forTransformColumnNum = 0, + int divide2 = -1, + double mu2 = -2, + double mu1Dmu2 = -1) + { + var locations = oldObjects.OfType().Select(n => (column: n.Column, startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples)) + .Concat(oldObjects.OfType().SelectMany(h => new[] + { + (column: h.Column, startTime: h.StartTime, endTime: h.EndTime, samples: h.GetNodeSamples(0)) + })) + .OrderBy(h => h.startTime).ToList(); + var maniaBeatmap = (ManiaBeatmap)beatmap; + int keys = maniaBeatmap.TotalColumns; + int maxGap = gap; + var randomColumnList = SelectRandom(Enumerable.Range(0, keys), rng, forTransformColumnNum == 0 ? keys : forTransformColumnNum).ToList(); + var noteList = new List<(double lastStartTime, double lastEndTime, bool lastLN, double thisStartTime, double thisEndTime, bool thisLN)>(keys); + noteList = Enumerable.Repeat((double.NaN, double.NaN, false, double.NaN, double.NaN, false), keys).ToList(); + var sampleList = new List<(IList lastSample, IList thisSample)>(keys); + + foreach (var timeGroup in locations.GroupBy(h => h.startTime)) + { + foreach (var note in timeGroup) + { + if (randomColumnList.Contains(note.column)) + { + if (double.IsNaN(noteList[note.column].thisStartTime)) + { + noteList[note.column] = (double.NaN, double.NaN, false, note.startTime, note.endTime, note.startTime != note.endTime); + sampleList[note.column] = (sampleList[note.column].thisSample, note.samples); + } + else + { + noteList[note.column] = (noteList[note.column].thisStartTime, noteList[note.column].thisEndTime, noteList[note.column].thisLN, note.startTime, note.endTime, + note.startTime != note.endTime); + sampleList[note.column] = (sampleList[note.column].thisSample, note.samples); + + double fullDuration = noteList[note.column].thisStartTime - noteList[note.column].lastStartTime; + + double duration = GetDurationByDistribution(rng, beatmap, noteList[note.column].lastStartTime, fullDuration, mu, sigmaDivisor, divide, error, divide2, mu2, mu1Dmu2); + + JudgementToNote(rng, newObjects, noteList[note.column].lastStartTime, note.column, noteList[note.column].lastEndTime, sampleList[note.column].lastSample, originalLN, + noteList[note.column].lastLN, percentage, duration); + } + } + else + { + if (note.startTime != note.endTime && originalLN) + newObjects.AddNote(note.samples, note.column, note.startTime, note.endTime); + else + newObjects.AddNote(note.samples, note.column, note.startTime); + } + } + + gap--; + + if (gap == 0) + { + randomColumnList = SelectRandom(Enumerable.Range(0, keys), rng, forTransformColumnNum).ToList(); + gap = maxGap; + } + } + + for (int i = 0; i < keys; i++) + { + if (!double.IsNaN(noteList[i].lastStartTime)) + { + double fullDuration = noteList[i].thisStartTime - noteList[i].lastStartTime; + + double duration = GetDurationByDistribution(rng, beatmap, noteList[i].lastStartTime, fullDuration, mu, sigmaDivisor, divide, error, divide2, mu2, mu1Dmu2); + + JudgementToNote(rng, newObjects, noteList[i].lastStartTime, i, noteList[i].lastEndTime, sampleList[i].lastSample, originalLN, noteList[i].lastLN, percentage, duration); + } + + if (!double.IsNaN(noteList[i].thisStartTime)) + { + if (rng.Next(100) >= percentage || Math.Abs(noteList[i].thisEndTime - noteList[i].thisStartTime) <= error) + newObjects.AddNote(sampleList[i].thisSample, i, noteList[i].thisStartTime); + else + newObjects.AddNote(sampleList[i].thisSample, i, noteList[i].thisStartTime, noteList[i].thisEndTime); + } + } + } + + public static void JudgementToNote(Random rng, + List newObjects, + double startTime, + int column, + double endTime, + IList samples, + bool originalLN, + bool isLN, + int percentage, + double duration) + { + if (originalLN && isLN) + newObjects.AddNote(samples, column, startTime, endTime); + else if (rng.Next(100) < percentage && !double.IsNaN(duration)) + newObjects.AddLNByDuration(samples, column, startTime, duration); + else + newObjects.AddNote(samples, column, startTime); + } + + /// + /// Return original LN objects. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static List Transform(Random rng, + double mu, + double sigmaDivisor, + int divide, + int percentage, + double error, + bool originalLN, + IBeatmap beatmap, + List newObjects, + IGrouping column, + int divide2 = -1, + double mu2 = -2, + double mu1Dmu2 = -1) + { + var originalLNObjects = new List(); + var newColumnObjects = new List(); + var locations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + })) + .OrderBy(h => h.startTime).ToList(); + + for (int i = 0; i < locations.Count - 1; i++) + { + // double offset = locations[0].startTime; + double fullDuration = locations[i + 1].startTime - locations[i].startTime; // Full duration of the hold note. + double duration = GetDurationByDistribution(rng, beatmap, locations[i].startTime, fullDuration, mu, sigmaDivisor, divide, error, divide2, mu2, mu1Dmu2); + + // Try to make timing point more precision. + // double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i].startTime).BeatLength; + // double endTime = PreciseTime(locations[i].startTime + duration, beatLength, offset, error); + + if (originalLN && locations[i].startTime != locations[i].endTime) + { + newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime); + originalLNObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime); + } + else if (rng.Next(100) < percentage && !double.IsNaN(duration)) + newColumnObjects.AddLNByDuration(locations[i].samples, column.Key, locations[i].startTime, duration); + else + newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime); + } + + // Dispose last note on the column + + if (Math.Abs(locations[^1].startTime - locations[^1].endTime) <= error || rng.Next(100) >= percentage) + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[^1].startTime, + Samples = locations[^1].samples + }); + } + else + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[^1].startTime, + Duration = locations[^1].endTime - locations[^1].startTime, + NodeSamples = [locations[^1].samples, Array.Empty()] + }); + } + + newObjects.AddRange(newColumnObjects); + + return originalLNObjects; + } + + public static double GetDurationByDistribution(Random rng, + IBeatmap beatmap, + double startTime, + double limitDuration, + double mu, + double sigmaDivisor, + int divide, + double error, + int divide2 = -1, + double mu2 = -2, + double mu1Dmu2 = -1) + { + // Beat length at the end of the hold note. + double beatLength = beatmap.ControlPointInfo.TimingPointAt(startTime).BeatLength; + // double beatBPM = beatmap.ControlPointInfo.TimingPointAt(startTime).BPM; + double timeDivide = beatLength / divide; //beatBPM / 60 * 100 / Divide.Value; + bool flag = true; // Can be transformed to LN + double sigma = timeDivide / sigmaDivisor; // LN duration σ + int timenum = (int)Math.Round(limitDuration / timeDivide, 0); + double duration = TimeRound(timeDivide, RandDistribution(rng, limitDuration * mu / 100, sigma)); + + if (mu1Dmu2 != -1) + { + if (rng.Next(100) >= mu1Dmu2) + { + timeDivide = beatLength / divide2; + sigma = timeDivide / sigmaDivisor; + timenum = (int)Math.Round(limitDuration / timeDivide, 0); + duration = TimeRound(timeDivide, RandDistribution(rng, limitDuration * mu2 / 100, sigma)); + } + } + + if (mu == -1) + { + if (timenum < 1) + duration = timeDivide; + else + { + int rdtime = rng.Next(1, timenum); + duration = rdtime * timeDivide; + duration = TimeRound(timeDivide, duration); + } + } + + if (duration > limitDuration - timeDivide) + { + duration = limitDuration - timeDivide; + duration = TimeRound(timeDivide, duration); + } + + if (duration <= timeDivide) duration = timeDivide; + + if (duration >= limitDuration - error) // Additional processing. + flag = false; + + return flag ? duration : double.NaN; + } + + public static void AfterTransform(List afterObjects, + List originalLNObjects, + IBeatmap beatmap, + Random rng, + bool originalLN, + int gap = -1, + int transformColumnNum = 0, + double limitDuration = 0, + int lineSpacing = 0, + bool invertSpacing = false) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var resultObjects = new List(); + var originalLNSet = new HashSet(originalLNObjects); + int keys = maniaBeatmap.TotalColumns; + if (transformColumnNum > keys) transformColumnNum = keys; + var randomColumnSet = SelectRandom(Enumerable.Range(0, keys), rng, transformColumnNum == 0 ? keys : transformColumnNum).ToHashSet(); + + int maxGap = gap; + + foreach (var timeGroup in afterObjects.GroupBy(h => h.StartTime)) + { + foreach (var note in timeGroup) + { + if (originalLNSet.Contains(note) && originalLN) + resultObjects.Add(note); + else if (randomColumnSet.Contains(note.Column) && note.StartTime != note.GetEndTime() + && (limitDuration > 0 && note.GetEndTime() - note.StartTime <= limitDuration * 1000 || limitDuration == 0)) + resultObjects.Add(note); + else + resultObjects.AddNote(note.Samples, note.Column, note.StartTime); + } + + gap--; + + if (gap == 0) + { + randomColumnSet = SelectRandom(Enumerable.Range(0, keys), rng, transformColumnNum).ToHashSet(); + gap = maxGap; + } + } + + int maxSpacing = lineSpacing; + + if (maxSpacing > 0) + { + afterObjects = resultObjects.OrderBy(h => h.StartTime).ToList(); + resultObjects = new List(); + + foreach (var timeGroup in afterObjects.GroupBy(h => h.StartTime)) + { + foreach (var note in timeGroup) + { + if (originalLNSet.Contains(note) && originalLN) + { + resultObjects.Add(note); + continue; + } + + if (invertSpacing) + { + if (lineSpacing > 0) + resultObjects.Add(note); + else + resultObjects.AddNote(note.Samples, note.Column, note.StartTime); + } + else + { + if (lineSpacing > 0) + resultObjects.AddNote(note.Samples, note.Column, note.StartTime); + else + resultObjects.Add(note); + } + } + + lineSpacing--; + + if (lineSpacing < 0) lineSpacing = maxSpacing; + } + } + + maniaBeatmap.HitObjects = resultObjects.OrderBy(h => h.StartTime).ToList(); + maniaBeatmap.Breaks.Clear(); + } + + public static double RandDistribution(Random rng, double u, double d) + { + if (d <= 0) return u; + + double u1 = rng.NextDouble(); + double u2 = rng.NextDouble(); + double z = Math.Sqrt(-2 * Math.Log(u1)) * Math.Sin(2 * Math.PI * u2); + double x = u + d * z; + return x; + } + + public static double TimeRound(double timedivide, double num) + { + double remainder = num % timedivide; + if (remainder < timedivide / 2) + return num - remainder; + + return num + timedivide - remainder; + } + + /// + /// Try to make conversion timing point(EndTime) more precision. + /// + /// + /// + /// + /// + /// + public static double PreciseTime(double time, double bpm, double offset, double error) + { + foreach (int t in DIVIDE_NUMBER) + { + double tem = time; + time = offset + Math.Round((time - offset) / (bpm / t)) * bpm / t; + if (Math.Abs(time - tem) < error) + return time; + else + time = tem; + } + + return time; + //try + //{ + //} + //catch (Exception e) + //{ + // Logger.Log(e.Message, level: LogLevel.Error); + // return null; + //} + } + + /// + /// Return false if error. + /// + /// + public static bool SelectRandomNumberForThis(this List list, Random rng, int minValue, int maxValue, int times, bool duplicate = false) + { + if (duplicate) + { + for (int i = 0; i < times; i++) + list.Add(rng.Next(minValue, maxValue)); + } + else + { + if (maxValue - minValue < times) return false; + + while (times > 0) + { + int num = rng.Next(minValue, maxValue); + + if (!list.Contains(num)) + { + list.Add(num); + times--; + } + } + } + + return true; + } + + public static bool SelectRandomNumberForThis(this List list, Random rng, int maxValue, int times, bool duplicate = false) + { + return list.SelectRandomNumberForThis(rng, 0, maxValue, times, duplicate); + } + + public static IEnumerable SelectRandom(this IEnumerable enumerable, Random rng, int times = 1, bool duplicate = false) + { + if (times <= 0) return Enumerable.Empty(); + + var result = new List(); + var list = enumerable.ToList(); + + if (duplicate) + { + while (times > 0) + { + int index = rng.Next(list.Count); + result.Add(list[index]); + times--; + } + } + else + { + while (times > 0) + { + int index = rng.Next(list.Count); + result.Add(list[index]); + list.RemoveAt(index); + times--; + } + } + + return result.AsEnumerable(); + } + + public static T SelectRandomOne(this List list, Random rng) + { + return list[rng.Next(list.Count)]; + } + + public static void AddNote(this List obj, IList samples, int column, double startTime, double? endTime = null) + { + if (endTime is null || endTime == startTime) + { + obj.Add(new Note + { + Column = column, + StartTime = startTime, + Samples = samples + }); + } + else + obj.AddLNByDuration(samples, column, startTime, (double)endTime - startTime); + } + + public static void RemoveNote(this List obj, int column, double startTime) + { + for (int i = obj.Count - 1; i >= 0; i--) + { + if (obj[i].Column == column && obj[i].StartTime == startTime) + { + obj.Remove(obj[i]); + return; + } + } + } + + public static void AddLNByDuration(this List obj, IList samples, int column, double startTime, double duration) + { + obj.Add(new HoldNote + { + Column = column, + StartTime = startTime, + Duration = duration, + NodeSamples = [samples, Array.Empty()] + }); + } + + public static bool FindOverlapInList(List hitobj, int column, double starttime, double endtime) + { + foreach (var obj in hitobj) + { + if (obj.Column == column && starttime <= obj.StartTime && starttime >= obj.StartTime) return true; + + if (obj.StartTime != obj.GetEndTime()) + { + if (obj.Column == column && starttime >= obj.StartTime && starttime <= obj.GetEndTime()) + { + if (endtime != starttime) + { + if (endtime >= obj.StartTime && endtime <= obj.GetEndTime()) + return true; + } + + return true; + } + } + } + + return false; + } + + public static bool FindOverlapInList(ManiaHitObject hitobj, List objs) + { + return FindOverlapInList(objs, hitobj.Column, hitobj.StartTime, hitobj.GetEndTime()); + } + + public static bool FindOverlapByNote(ManiaHitObject hitobj, int column, double starttime, double endtime) + { + List onenote = [hitobj]; + return FindOverlapInList(onenote, column, starttime, endtime); + } + + public static bool FindOverlapByList(List hitobj) + { + for (int i = 0; i < hitobj.Count; i++) + { + for (int j = i + 1; j < hitobj.Count; j++) + { + if (hitobj[i].Column == hitobj[j].Column && hitobj[i].StartTime == hitobj[j].StartTime) return true; + + if (hitobj[j].StartTime != hitobj[j].GetEndTime()) + { + if (hitobj[i].Column == hitobj[j].Column && hitobj[i].StartTime >= hitobj[j].StartTime - 2 && hitobj[i].StartTime <= hitobj[j].GetEndTime() + 2) + { + if (hitobj[i].GetEndTime() != hitobj[j].StartTime) + { + if (hitobj[i].GetEndTime() >= hitobj[j].StartTime - 2 && hitobj[i].GetEndTime() <= hitobj[j].GetEndTime() + 2) + return true; + } + + return true; + } + } + } + } + + return false; + } + + public static IEnumerable ShuffleIndex(this IEnumerable list, Random rng) + { + var result = list.ToList(); + + for (int i = 0; i < result.Count; i++) + { + int toIndex = rng.Next(result.Count); + (result[i], result[toIndex]) = (result[toIndex], result[i]); + } + + return result.AsEnumerable(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJackAdjust.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJackAdjust.cs new file mode 100644 index 0000000000..bcbb44f561 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJackAdjust.cs @@ -0,0 +1,319 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModJackAdjust : Mod, IApplicableAfterBeatmapConversion, IHasSeed + { + public const int MAX_KEY = 18; + + public override string Name => "Jack Adjust"; + + public override string Acronym => "JA"; + + public override LocalisableString Description => EzManiaModStrings.JackAdjust_Description; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IconUsage? Icon => FontAwesome.Solid.Bars; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Probability", $"{Probability.Value}"); + yield return ("Line", $"{Line.Value}"); + yield return ("Alignment", Align.Value ? "First Line" : "Last Line"); + yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ToStream_Label), nameof(EzManiaModStrings.ToStream_Description))] + public BindableBool Stream { get; set; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Probability_Label), nameof(EzManiaModStrings.Probability_Description))] + public BindableInt Probability { get; set; } = new BindableInt(100) + { + Precision = 1, + MinValue = 0, + MaxValue = 100 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Line_Label), nameof(EzManiaModStrings.Line_Description))] + public BindableInt Line { get; set; } = new BindableInt(3) + { + Precision = 1, + MinValue = 2, + MaxValue = 16 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Alignment_Label), nameof(EzManiaModStrings.Alignment_Description))] + public BindableBool Align { get; set; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + var maniaBeatmap = (ManiaBeatmap)beatmap; + var newObjects = new List(); + var areaObjects = new List(); + int keys = maniaBeatmap.TotalColumns; + int line = Line.Value; + var lastLine = new List(); + + foreach (var timingPoint in maniaBeatmap.HitObjects.GroupBy(h => h.StartTime)) + { + var thisLine = new List(); + thisLine.AddRange(timingPoint); + + if (!Stream.Value) + { + if (line > 0) + { + areaObjects.AddRange(thisLine); + line--; + } + else + { + var processed = ProcessArea(rng, areaObjects, Line.Value, keys, Probability.Value, Align.Value); + newObjects.AddRange(processed); + line = Line.Value; + areaObjects.Clear(); + areaObjects.AddRange(thisLine); + line--; + } + } + else + { + var duplicateColumn = lastLine.Select(h => h.Column).ToList(); + var notDuplicate = Enumerable.Range(0, keys).ToList(); + notDuplicate = notDuplicate.Except(duplicateColumn).ToList(); + int count = notDuplicate.Count; + thisLine = thisLine.ShuffleIndex(rng).ToList(); + int selectError = 0; + + for (int i = 0; i < thisLine.Count; i++) + { + if (count == 0) break; + + if (duplicateColumn.Contains(thisLine[i].Column) && rng.Next(100) < Probability.Value) + { + bool jumpLoop = false; + int randColumn = notDuplicate.SelectRandomOne(rng); + + while (thisLine.Any(c => c.Column == randColumn)) + { + if (selectError > MAX_KEY) + { + jumpLoop = true; + selectError = 0; + break; + } + + randColumn = notDuplicate.SelectRandomOne(rng); + selectError++; + } + + if (jumpLoop) continue; + + duplicateColumn.Remove(thisLine[i].Column); + thisLine[i].Column = randColumn; + count--; + } + } + + newObjects.AddRange(thisLine); + } + + lastLine = thisLine.ToList(); + } + + if (!Stream.Value && areaObjects.Count != 0) + { + var processed = ProcessArea(rng, areaObjects, Line.Value, keys, Probability.Value, Align.Value); + newObjects.AddRange(processed); + } + + var cleanObjects = new List(); + + foreach (var column in newObjects.GroupBy(c => c.Column)) + { + var newColumnObjects = new List(); + + var cleanLocations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + })) + .OrderBy(h => h.startTime).ToList(); + + double lastStartTime = cleanLocations[0].startTime; + double lastEndTime = cleanLocations[0].endTime; + var lastSample = cleanLocations[0].samples; + + for (int i = 0; i < cleanLocations.Count; i++) + { + if (i == 0) + { + lastStartTime = cleanLocations[0].startTime; + lastEndTime = cleanLocations[0].endTime; + lastSample = cleanLocations[0].samples; + continue; + } + + if (cleanLocations[i].startTime >= lastStartTime && cleanLocations[i].startTime <= lastEndTime) + { + cleanLocations.RemoveAt(i); + i--; + continue; + } // if the note in a LN + + newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime); + lastStartTime = cleanLocations[i].startTime; + lastEndTime = cleanLocations[i].endTime; + lastSample = cleanLocations[i].samples; + } + + newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime); + + cleanObjects.AddRange(newColumnObjects); + } + + maniaBeatmap.HitObjects = cleanObjects.OrderBy(h => h.StartTime).ToList(); + } + + public List ProcessArea(Random rng, List area, int line, int keys, int probability, bool align) + { + var resultObjects = new List(); + var jackLine = new List(); // first line + var lastLine = new List(); + bool init = true; + // int jackCount = 0; + + foreach (var group in area.GroupBy(h => h.StartTime)) + { + var thisLine = group.ToList(); + + if (init) + { + jackLine = thisLine; + lastLine = thisLine; + resultObjects.AddRange(thisLine); + init = false; + // jackCount = jackLine.Count; + continue; + } + + //if (init) + //{ + // var select = SelectNote(Rng, thisLine, probability); + // var remain = select.remain.ShuffleIndex(Rng).ToList(); + // var result = select.result.ShuffleIndex(Rng).ToList(); + // var duplicateColumn = remain.Select(c => c.Column).ShuffleIndex(Rng).ToList(); + // var forAlign = Align.Value ? SelectNote(Rng, jackLine, probability, jackCount) : SelectNote(Rng, lastLine, probability, jackCount); + // var alignResult = forAlign.result.ShuffleIndex(Rng).ToList(); + // for (int i = 0; i < jackCount; i++) + // { + // if (!duplicateColumn.Contains(alignResult[i].Column)) + // { + // result[i].Column = alignResult[i].Column; + // duplicateColumn.Add(alignResult[i].Column); + // } + // } + // resultObjects.AddRange(remain); + // resultObjects.AddRange(result); + //} + + //int count = Math.Min(jackCount, thisLine.Count); + //var select = SelectNote(Rng, thisLine, probability, count); + //count = select.result.Count; + // var select = thisLine; + var jackColumn = jackLine.Select(c => c.Column).ShuffleIndex(rng).ToList(); + if (!align) jackColumn = lastLine.Select(c => c.Column).ShuffleIndex(rng).ToList(); + thisLine = thisLine.ShuffleIndex(rng).ToList(); + + for (int i = 0; i < thisLine.Count; i++) + { + if (!jackColumn.Contains(thisLine[i].Column) && jackColumn.Count > 0 && thisLine[i].GetEndTime() == thisLine[i].StartTime) + { + int randColumn = jackColumn.SelectRandomOne(rng); + int opportunity = 0; + const int max = 20; + + while (opportunity < max) + { + if (randColumn == thisLine[i].Column || thisLine.Except(Enumerable.Repeat(thisLine[i], 1)).Any(c => c.Column == randColumn)) + { + randColumn = jackColumn.SelectRandomOne(rng); + + if (randColumn != thisLine[i].Column && thisLine.Except(Enumerable.Repeat(thisLine[i], 1)).All(c => c.Column != randColumn)) + { + thisLine[i].Column = randColumn; + jackColumn.Remove(randColumn); + break; + } + } + + opportunity++; + } + } + } + + resultObjects.AddRange(thisLine); + + lastLine = thisLine; + } + + return resultObjects.OrderBy(s => s.StartTime).ToList(); + } + + public static (List remain, List result) SelectNote(Random rng, List obj, int probability = 100, int num = 1) + { + if (num > obj.Count) + { + var nullList = new List(); + return (nullList, nullList); + } + + var remainList = obj.ToList(); + var resultList = new List(); + + for (int i = 0; i < num; i++) + { + if (rng.Next(100) < probability) + { + int index = rng.Next(remainList.Count); + resultList.Add(remainList[index]); + remainList.RemoveAt(index); + } + } + + return (remainList, resultList); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJudgmentsAdjust.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJudgmentsAdjust.cs new file mode 100644 index 0000000000..5d77d54806 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModJudgmentsAdjust.cs @@ -0,0 +1,214 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModJudgmentsAdjust : Mod, IApplicableToScoreProcessor + { + public override string Name => "Judgments Adjust"; + + public override string Acronym => "JU"; + + public override LocalisableString Description => EzManiaModStrings.JudgmentsAdjust_Description; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IconUsage? Icon => FontAwesome.Solid.Shower; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (CustomHitRange.Value) + { + yield return ("Custom Hit Range", "On"); + yield return ("Perfect Range", $"{PerfectHit.Value:0.#}"); + yield return ("Great Range", $"{GreatHit.Value:0.#}"); + yield return ("Good Range", $"{GoodHit.Value:0.#}"); + yield return ("Ok Range", $"{OkHit.Value:0.#}"); + yield return ("Meh Range", $"{MehHit.Value:0.#}"); + yield return ("Miss Range", $"{MissHit.Value:0.#}"); + } + + // if (CustomProportionScore.Value) + // { + // yield return ("Custom Proportion Score", "On"); + // yield return ("Perfect Score", $"{Perfect.Value:0.#}"); + // yield return ("Great Score", $"{Great.Value:0.#}"); + // yield return ("Good Score", $"{Good.Value:0.#}"); + // yield return ("Ok Score", $"{Ok.Value:0.#}"); + // yield return ("Meh Score", $"{Meh.Value:0.#}"); + // yield return ("Miss Score", $"{Miss.Value:0.#}"); + // } + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomHitRange_Label), nameof(EzManiaModStrings.CustomHitRange_Description))] + public BindableBool CustomHitRange { get; set; } = new BindableBool(true); + + [SettingSource("Perfect")] + public BindableDouble PerfectHit { get; set; } = new BindableDouble(22.4D) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Great")] + public BindableDouble GreatHit { get; set; } = new BindableDouble(64) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Good")] + public BindableDouble GoodHit { get; set; } = new BindableDouble(97) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Ok")] + public BindableDouble OkHit { get; set; } = new BindableDouble(127) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Meh")] + public BindableDouble MehHit { get; set; } = new BindableDouble(151) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource("Miss")] + public BindableDouble MissHit { get; set; } = new BindableDouble(188) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 250 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomProportionScore_Label), nameof(EzManiaModStrings.CustomProportionScore_Description))] + public BindableBool CustomProportionScore { get; set; } = new BindableBool(true); + + [SettingSource("Perfect")] + public BindableInt Perfect { get; set; } = new BindableInt(300) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Great")] + public BindableInt Great { get; set; } = new BindableInt(300) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Good")] + public BindableInt Good { get; set; } = new BindableInt(200) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Ok")] + public BindableInt Ok { get; set; } = new BindableInt(100) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Meh")] + public BindableInt Meh { get; set; } = new BindableInt(50) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + [SettingSource("Miss")] + public BindableInt Miss { get; set; } = new BindableInt(0) + { + Precision = 5, + MinValue = 0, + MaxValue = 500 + }; + + public ManiaHitWindows HitWindows { get; set; } = new ManiaHitWindows(); + + public ManiaModJudgmentsAdjust() + { + CustomHitRange.BindValueChanged(_ => updateCustomHitRange()); + PerfectHit.BindValueChanged(_ => updateCustomHitRange()); + GreatHit.BindValueChanged(_ => updateCustomHitRange()); + GoodHit.BindValueChanged(_ => updateCustomHitRange()); + OkHit.BindValueChanged(_ => updateCustomHitRange()); + MehHit.BindValueChanged(_ => updateCustomHitRange()); + MissHit.BindValueChanged(_ => updateCustomHitRange()); + } + + private void updateCustomHitRange() + { + if (CustomHitRange.Value) + { + HitWindows.ModifyManiaHitRange(new ManiaModifyHitRange( + PerfectHit.Value, + GreatHit.Value, + GoodHit.Value, + OkHit.Value, + MehHit.Value, + MissHit.Value + )); + } + else + { + HitWindows.ResetRange(); + } + } + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) + { + return rank; + } + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + // var mania = (ManiaScoreProcessor)scoreProcessor; + // mania.HitProportionScore.Perfect = Perfect.Value; + // mania.HitProportionScore.Great = Great.Value; + // mania.HitProportionScore.Good = Good.Value; + // mania.HitProportionScore.Ok = Ok.Value; + // mania.HitProportionScore.Meh = Meh.Value; + // mania.HitProportionScore.Miss = Miss.Value; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLN.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLN.cs new file mode 100644 index 0000000000..4cd41109a8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLN.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModLN : Mod, IHasSeed + { + public override string Name => "LN"; + + public override string Acronym => "LN"; + + public override LocalisableString Description => EzManiaModStrings.LN_Description; + + public override double ScoreMultiplier => 1; + + public override IconUsage? Icon => FontAwesome.Solid.YinYang; + + public override ModType Type => ModType.YuLiangSSS_Mod; + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Divide_Label), nameof(EzManiaModStrings.Divide_Description))] + public BindableNumber Divide { get; set; } = new BindableInt(4) + { + MinValue = 1, + MaxValue = 16, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Percentage_Label), nameof(EzManiaModStrings.Percentage_Description))] + public BindableNumber Percentage { get; set; } = new BindableInt(100) + { + MinValue = 5, + MaxValue = 100, + Precision = 5 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.OriginalLN_Label), nameof(EzManiaModStrings.OriginalLN_Description))] + public BindableBool OriginalLN { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ColumnNum_Label), nameof(EzManiaModStrings.ColumnNum_Description))] + public BindableInt SelectColumn { get; set; } = new BindableInt(10) + { + MinValue = 1, + MaxValue = 20, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Gap_Label), nameof(EzManiaModStrings.Gap_Description))] + public BindableInt Gap { get; set; } = new BindableInt(12) + { + MinValue = 0, + MaxValue = 20, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LineSpacing_Label), nameof(EzManiaModStrings.LineSpacing_Description))] + public BindableInt LineSpacing { get; set; } = new BindableInt(0) + { + MinValue = 0, + MaxValue = 20, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InvertLineSpacing_Label), nameof(EzManiaModStrings.InvertLineSpacing_Description))] + public BindableBool InvertLineSpacing { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DurationLimit_Label), nameof(EzManiaModStrings.DurationLimit_Description))] + public BindableDouble DurationLimit { get; set; } = new BindableDouble(5) + { + MinValue = 0, + MaxValue = 15, + Precision = 0.5 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Divide", $"1/{Divide.Value}"); + yield return ("Percentage", $"{Percentage.Value}%"); + + if (OriginalLN.Value) yield return ("Original LN", "On"); + + yield return ("Column Num", $"{SelectColumn.Value}"); + yield return ("Gap", $"{Gap.Value}"); + + if (DurationLimit.Value > 0) yield return ("Duration Limit", $"{DurationLimit.Value}s"); + + yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}"); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNDoubleDistribution.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNDoubleDistribution.cs new file mode 100644 index 0000000000..c5c3b47975 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNDoubleDistribution.cs @@ -0,0 +1,205 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModLNDoubleDistribution : Mod, IApplicableAfterBeatmapConversion, IHasSeed + { + public override string Name => "LN Double Distribution"; + + public override string Acronym => "DD"; + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => EzManiaModStrings.LNDoubleDistribution_Description; + + public override IconUsage? Icon => FontAwesome.Solid.YinYang; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + public readonly int[] DivideNumber = [2, 4, 8, 3, 6, 9, 5, 7, 12, 16, 48, 35, 64]; + + public readonly double ERROR = 2; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Divide 1", $"1/{Divide1.Value}"); + yield return ("Divide 2", $"1/{Divide2.Value}"); + yield return ("Mu 1", $"{Mu1.Value}"); + yield return ("Mu 2", $"{Mu2.Value}"); + yield return ("Mu 1 : Mu 2", $"{Mu1DMu2.Value} : {1 - Mu1DMu2.Value}"); + yield return ("Sigma", $"{SigmaInteger.Value + SigmaDouble.Value}"); + yield return ("Percentage", $"{Percentage.Value}%"); + + if (OriginalLN.Value) + { + yield return ("Original LN", "On"); + } + + yield return ("Column Num", $"{SelectColumn.Value}"); + yield return ("Gap", $"{Gap.Value}"); + + if (DurationLimit.Value > 0) + { + yield return ("Duration Limit", $"{DurationLimit.Value}s"); + } + + yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Divide1_Label), nameof(EzManiaModStrings.Divide1_Description), 0)] + public BindableNumber Divide1 { get; set; } = new BindableInt(4) + { + MinValue = 1, + MaxValue = 16, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Divide2_Label), nameof(EzManiaModStrings.Divide2_Description), 1)] + public BindableNumber Divide2 { get; set; } = new BindableInt(4) + { + MinValue = 1, + MaxValue = 16, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mu1_Label), nameof(EzManiaModStrings.Mu1_Description), 2)] + public BindableNumber Mu1 { get; set; } = new BindableInt(20) + { + MinValue = -1, + MaxValue = 100, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mu2_Label), nameof(EzManiaModStrings.Mu2_Description), 3)] + public BindableNumber Mu2 { get; set; } = new BindableInt(70) + { + MinValue = -1, + MaxValue = 100, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MuRatio_Label), nameof(EzManiaModStrings.MuRatio_Description), 4)] + public BindableInt Mu1DMu2 { get; set; } = new BindableInt(50) + { + MinValue = 0, + MaxValue = 100, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SigmaInteger_Label), nameof(EzManiaModStrings.SigmaInteger_Description), 5)] + public BindableInt SigmaInteger { get; set; } = new BindableInt(0) + { + MinValue = 0, + MaxValue = 20, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SigmaDecimal_Label), nameof(EzManiaModStrings.SigmaDecimal_Description), 6)] + public BindableDouble SigmaDouble { get; set; } = new BindableDouble(0.85) + { + MinValue = 0.01, + MaxValue = 0.99, + Precision = 0.01, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Percentage_Label), nameof(EzManiaModStrings.Percentage_Description))] + public BindableNumber Percentage { get; set; } = new BindableInt(100) + { + MinValue = 0, + MaxValue = 100, + Precision = 5, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.OriginalLN_Label), nameof(EzManiaModStrings.OriginalLN_Description))] + public BindableBool OriginalLN { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ColumnNum_Label), nameof(EzManiaModStrings.ColumnNum_Description))] + public BindableInt SelectColumn { get; set; } = new BindableInt(10) + { + MinValue = 1, + MaxValue = 20, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Gap_Label), nameof(EzManiaModStrings.Gap_Description))] + public BindableInt Gap { get; set; } = new BindableInt(12) + { + MinValue = 0, + MaxValue = 20, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DurationLimit_Label), nameof(EzManiaModStrings.DurationLimit_Description))] + public BindableDouble DurationLimit { get; set; } = new BindableDouble(5) + { + MinValue = 0, + MaxValue = 15, + Precision = 0.5, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LineSpacing_Label), nameof(EzManiaModStrings.LineSpacing_Description))] + public BindableInt LineSpacing { get; set; } = new BindableInt(0) + { + MinValue = 0, + MaxValue = 20, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InvertLineSpacing_Label), nameof(EzManiaModStrings.InvertLineSpacing_Description))] + public BindableBool InvertLineSpacing { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var newObjects = new List(); + var originalLNObjects = new List(); + // int keys = maniaBeatmap.TotalColumns; + var notTransformColumn = new List(); + + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + if (notTransformColumn.Contains(column.Key)) + { + ManiaModHelper.AddOriginalNoteByColumn(newObjects, column); + continue; + } + + originalLNObjects = ManiaModHelper.Transform(rng, Mu1.Value, SigmaDouble.Value + SigmaInteger.Value, Divide1.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, + column, Divide2.Value, Mu2.Value, Mu1DMu2.Value); + } + + ManiaModHelper.AfterTransform(newObjects, originalLNObjects, maniaBeatmap, rng, OriginalLN.Value, Gap.Value, SelectColumn.Value, DurationLimit.Value, LineSpacing.Value, + InvertLineSpacing.Value); + + maniaBeatmap.Breaks.Clear(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNJudgementAdjust.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNJudgementAdjust.cs new file mode 100644 index 0000000000..8fb9a1fc12 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNJudgementAdjust.cs @@ -0,0 +1,346 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public partial class ManiaModLNJudgementAdjust : Mod, IApplicableToDifficulty, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset + { + public override string Name => "LN Judgement Adjust"; + + public override string Acronym => "LA"; + + public override LocalisableString Description => EzManiaModStrings.LNJudgementAdjust_Description; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public HitWindows HitWindows { get; set; } = new ManiaHitWindows(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.BodyJudgementSwitch_Label), nameof(EzManiaModStrings.BodyJudgementSwitch_Description))] + public BindableBool BodyJudgementSwitch { get; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.TailJudgementSwitch_Label), nameof(EzManiaModStrings.TailJudgementSwitch_Description))] + public BindableBool TailJudgementSwitch { get; } = new BindableBool(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var hitObjects = maniaBeatmap.HitObjects.Select(obj => + { + //if (obj is Note note) + // return new NoLNNote(note); + + if (obj is HoldNote hold) return new LNHoldNote(hold); + + return obj; + }).ToList(); + + maniaBeatmap.HitObjects = hitObjects; + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + + foreach (var stage in maniaRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + + if (!TailJudgementSwitch.Value && !BodyJudgementSwitch.Value) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + } + + if (BodyJudgementSwitch.Value && !TailJudgementSwitch.Value) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + } + + if (BodyJudgementSwitch.Value && TailJudgementSwitch.Value) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + } + + if (!BodyJudgementSwitch.Value && TailJudgementSwitch.Value) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + // Vanilla LN + } + } + } + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + HitWindows = new ManiaHitWindows(); + HitWindows.SetDifficulty(difficulty.OverallDifficulty); + + NoLNDrawableHoldNoteTail.HitWindows = HitWindows; + LNHoldNote.BodyJudgementSwitch = BodyJudgementSwitch.Value; + LNHoldNote.TailJudgementSwitch = TailJudgementSwitch.Value; + } + + private class NoLNNote : Note + { + public NoLNNote(Note note) + { + StartTime = note.StartTime; + Column = note.Column; + Samples = note.Samples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + } + } + + private class NoLNHeadNote : HeadNote + { + } + + private class NoLNBodyNote : HoldNoteBody + { + public override Judgement CreateJudgement() + { + return new NoLNBodyJudgement(); + } + + protected override HitWindows CreateHitWindows() + { + return HitWindows.Empty; + } + } + + private class AllLNBodyNote : HoldNoteBody + { + public override Judgement CreateJudgement() + { + return new AllLNBodyJudgement(); + } + + protected override HitWindows CreateHitWindows() + { + return HitWindows.Empty; + } + } + + private class NoLNTailNote : TailNote + { + public override Judgement CreateJudgement() + { + return new NoLNTailJudgement(); + } + + protected override HitWindows CreateHitWindows() + { + return new ManiaHitWindows(); + } + } + + private class LNHoldNote : HoldNote + { + public static bool BodyJudgementSwitch; + + public static bool TailJudgementSwitch; + + public LNHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + static LNHoldNote() + { + TailJudgementSwitch = false; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new HeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0) + }); + + if (!BodyJudgementSwitch && !TailJudgementSwitch) + { + AddNested(Body = new NoLNBodyNote + { + StartTime = StartTime, + Column = Column + }); + + AddNested(Tail = new NoLNTailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1) + }); + } + else if (BodyJudgementSwitch && !TailJudgementSwitch) + { + AddNested(Body = new AllLNBodyNote + { + StartTime = StartTime, + Column = Column + }); + + AddNested(Tail = new NoLNTailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1) + }); + } + else if (!BodyJudgementSwitch && TailJudgementSwitch) + { + AddNested(Body = new HoldNoteBody + { + StartTime = StartTime, + Column = Column + }); + + AddNested(Tail = new TailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1) + }); + } + else + { + AddNested(Body = new AllLNBodyNote + { + StartTime = StartTime, + Column = Column + }); + + AddNested(Tail = new TailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1) + }); + } + } + } + + private class NoLNBodyJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + + public override HitResult MinResult => HitResult.IgnoreMiss; + } + + private class AllLNBodyJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.Perfect; + + public override HitResult MinResult => HitResult.Miss; + } + + private class NoLNTailJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + + public override HitResult MinResult => HitResult.ComboBreak; + } + + public partial class NoLNDrawableHoldNoteBody : DrawableHoldNoteBody + { + public new bool HasHoldBreak => false; + + internal override void TriggerResult(bool hit) + { + if (AllJudged) return; + + ApplyMaxResult(); + } + } + + public partial class AllLNDrawableHoldNoteBody : DrawableHoldNoteBody + { + public override bool DisplayResult => true; + + protected internal DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject; + + internal override void TriggerResult(bool hit) + { + if (AllJudged) return; + + if (hit) + ApplyResult(HoldNote.Head.Result.Type); + else + ApplyResult(HitResult.Miss); + } + } + + public partial class NoLNDrawableHoldNoteTail : DrawableHoldNoteTail + { + public static HitWindows HitWindows = new ManiaHitWindows(); + + public override bool DisplayResult => false; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (!HoldNote.Head.IsHit) return; + + if (timeOffset > 0 && HoldNote.Head.IsHit) + { + ApplyMaxResult(); + return; + } + else if (timeOffset > 0) + { + ApplyMinResult(); + return; + } + + if (HoldNote.IsHolding.Value) return; + + if (HoldNote.Head.IsHit && Math.Abs(timeOffset) < Math.Abs(HitWindows.WindowFor(HitResult.Meh) * TailNote.RELEASE_WINDOW_LENIENCE)) + { + ApplyMaxResult(); + } + else + { + ApplyMinResult(); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNLongShortAddition.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNLongShortAddition.cs new file mode 100644 index 0000000000..5b1c2687bd --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNLongShortAddition.cs @@ -0,0 +1,150 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModLNLongShortAddition : ManiaModLN, IApplicableAfterBeatmapConversion + { + public override string Name => "LN Long & Short"; + + public override string Acronym => "LS"; + + public override LocalisableString Description => EzManiaModStrings.LNLongShortAddition_Description; + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + public readonly int[] DivideNumber = [2, 4, 8, 3, 6, 9, 5, 7, 12, 16, 48, 35, 64]; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Long / Short %", $"{LongShort.Value}%"); + + foreach (var (setting, value) in base.SettingDescription) + yield return (setting, value); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LongShortPercent_Label), nameof(EzManiaModStrings.LongShortPercent_Description), 0)] + public BindableNumber LongShort { get; set; } = new BindableInt(40) + { + MinValue = 0, + MaxValue = 100, + Precision = 5, + }; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var newObjects = new List(); + var originalLNObjects = new List(); + + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + var newColumnObjects = new List(); + var locations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + })) + .OrderBy(h => h.startTime).ToList(); + + for (int i = 0; i < locations.Count - 1; i++) + { + double fullDuration = locations[i + 1].startTime - locations[i].startTime; + double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i + 1].startTime).BeatLength; + double beatBPM = beatmap.ControlPointInfo.TimingPointAt(locations[i + 1].startTime).BPM; + double timeDivide = beatLength / Divide.Value; //beatBPM / 60 * 100 / Divide.Value; + double duration = rng.Next(100) < LongShort.Value ? fullDuration - timeDivide : timeDivide; + bool flag = true; // Can be transformed to LN + + if (duration < timeDivide) + { + duration = timeDivide; + } + + if (duration >= fullDuration - 2) + { + flag = false; + } + + if (OriginalLN.Value && locations[i].startTime != locations[i].endTime) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[i].startTime, + EndTime = locations[i].endTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + originalLNObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime); + } + else if (rng.Next(100) < Percentage.Value && flag) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[i].startTime, + Duration = duration, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + } + + if (Math.Abs(locations[locations.Count - 1].startTime - locations[locations.Count - 1].endTime) <= 2 || rng.Next(100) >= Percentage.Value) + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[locations.Count - 1].startTime, + Samples = locations[locations.Count - 1].samples + }); + } + else + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[locations.Count - 1].startTime, + Duration = locations[locations.Count - 1].endTime - locations[locations.Count - 1].startTime, + NodeSamples = [locations[locations.Count - 1].samples, Array.Empty()] + }); + } + + newObjects.AddRange(newColumnObjects); + } + + ManiaModHelper.AfterTransform(newObjects, originalLNObjects, maniaBeatmap, rng, OriginalLN.Value, Gap.Value, SelectColumn.Value, DurationLimit.Value, LineSpacing.Value, + InvertLineSpacing.Value); + + maniaBeatmap.Breaks.Clear(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNSimplify.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNSimplify.cs new file mode 100644 index 0000000000..1a66b26092 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNSimplify.cs @@ -0,0 +1,191 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModLNSimplify : Mod, IApplicableAfterBeatmapConversion + { + public override string Name => "LN Simplify"; + + public override string Acronym => "SP"; + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => EzManiaModStrings.LNSimplify_Description; + + public override IconUsage? Icon => FontAwesome.Solid.YinYang; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public readonly double ERROR = 1.5; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Limit Divide", $"{LimitDivide.Value}"); + yield return ("Easier Divide", $"{EasierDivide.Value}"); + yield return ("Longest LN", $"{Gap.Value}"); + yield return ("Shortest LN", $"{Len.Value}"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LimitDivide_Label), nameof(EzManiaModStrings.LimitDivide_Description))] + public BindableInt LimitDivide { get; set; } = new BindableInt(4) + { + MinValue = 1, + MaxValue = 16, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.EasierDivide_Label), nameof(EzManiaModStrings.EasierDivide_Description))] + public BindableInt EasierDivide { get; set; } = new BindableInt(4) + { + MinValue = 1, + MaxValue = 16, + Precision = 1, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LongestLN_Label), nameof(EzManiaModStrings.LongestLN_Description))] + public BindableBool Gap { get; set; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ShortestLN_Label), nameof(EzManiaModStrings.ShortestLN_Description))] + public BindableBool Len { get; set; } = new BindableBool(true); + + //[SettingSource("Allowable ms", "Minimum ms.")] + //public BindableInt Allowable { get; set; } = new BindableInt(10) + //{ + + //}; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + var newObjects = new List(); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + var locations = column.OfType().Select(n => (startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, endTime: h.EndTime, samples: h.GetNodeSamples(0)) + })) + .OrderBy(h => h.startTime).ToList(); + + var newColumnObjects = new List(); + + for (int i = 0; i < locations.Count - 1; i++) + { + if (locations[i].startTime == locations[i].endTime) + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[i].startTime, + Samples = locations[i].samples, + }); + continue; + } + + double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i].startTime).BeatLength; + + double gap = locations[i + 1].startTime - locations[i].endTime; + + double timeDivide = beatLength / LimitDivide.Value; + + double easierDivide = beatLength / EasierDivide.Value; + + double duration = locations[i].endTime - locations[i].startTime; + + if (duration < timeDivide + ERROR && Len.Value) + { + duration = easierDivide; + gap = locations[i + 1].startTime - (locations[i].startTime + duration); + + if (gap < timeDivide + ERROR) + { + duration = locations[i + 1].startTime - locations[i].startTime - easierDivide; + } + } + + if (gap < timeDivide + ERROR && Gap.Value) + { + duration = locations[i + 1].startTime - locations[i].startTime - easierDivide; + } + + if (duration < easierDivide - ERROR) + { + duration = 0; + } + + if (duration > 0) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[i].startTime, + Duration = duration, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[i].startTime, + Samples = locations[i].samples, + }); + } + } + + int last = locations.Count - 1; + + if (locations[last].startTime == locations[last].endTime) + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[last].startTime, + Samples = locations[last].samples, + }); + } + else + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[last].startTime, + EndTime = locations[last].endTime, + NodeSamples = [locations[last].samples, Array.Empty()] + }); + } + + newObjects.AddRange(newColumnObjects); + } + + maniaBeatmap.HitObjects = [.. newObjects.OrderBy(h => h.StartTime)]; + + //maniaBeatmap.Breaks.Clear(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNTransformer.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNTransformer.cs new file mode 100644 index 0000000000..a9228da626 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModLNTransformer.cs @@ -0,0 +1,398 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +// using osu.Framework.Logging; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModLNTransformer : ManiaModLN, IApplicableAfterBeatmapConversion + { + public override string Name => "LN Transformer"; + + public override string Acronym => "LT"; + + public override LocalisableString Description => EzManiaModStrings.LNTransformer_Description; + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + public readonly double ERROR = 2; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Level", $"{Level.Value}"); + + foreach (var (setting, value) in base.SettingDescription) + yield return (setting, value); + } + } + + [SettingSource("Level", + "LN Transform Level (-3: Hold Off -2: Real RandomLN(Random ms) -1: RandomLN(Random TimingPoint) 0: RegularLN 3: LightLN 5: MediumLN 8: HeavyLN 10: FullLN)", 0)] + public BindableNumber Level { get; set; } = new BindableInt(3) + { + MinValue = -3, + MaxValue = 10, + Precision = 1, + }; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + int keys = maniaBeatmap.TotalColumns; + + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + var newObjects = new List(); + var oldObjects = maniaBeatmap.HitObjects.ToList(); + var originalLNObjects = new List(); + + if (Level.Value == -3) + { + int transformColumnNum = SelectColumn.Value; + + if (transformColumnNum > keys) + { + transformColumnNum = keys; + } + + var randomColumnSet = ManiaModHelper.SelectRandom(Enumerable.Range(0, keys), rng, transformColumnNum == 0 ? keys : transformColumnNum).ToHashSet(); + int gap = Gap.Value; + + foreach (var timeGroup in oldObjects.GroupBy(x => x.StartTime)) + { + foreach (var note in timeGroup) + { + if (randomColumnSet.Contains(note.Column) && rng.Next(100) < Percentage.Value) + { + newObjects.Add(new Note + { + Column = note.Column, + StartTime = note.StartTime, + Samples = note.Samples + }); + } + else + { + newObjects.Add(note); + } + } + + gap--; + + if (gap <= 0) + { + randomColumnSet = ManiaModHelper.SelectRandom(Enumerable.Range(0, keys), rng, transformColumnNum).ToHashSet(); + gap = Gap.Value; + } + } + + maniaBeatmap.HitObjects = newObjects.OrderBy(h => h.StartTime).ToList(); + return; + } + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + switch (Level.Value) + { + case -2: + { + TrueRandom(beatmap, newObjects, rng, column); + } + break; + + case -1: + { + double mu = -1; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 1, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 0: + { + double mu = 1; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 100, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 1: + { + double mu = 11; //LN duration μ + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.85, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 2: + { + double mu = 22; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.85, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 3: + { + double mu = 33; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.85, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 4: + { + double mu = 44; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.85, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 5: + { + double mu = 55; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.85, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 6: + { + double mu = 66; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.85, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 7: + { + double mu = 77; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.85, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 8: + { + double mu = 88; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 0.9, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 9: + { + double mu = 99; + originalLNObjects = ManiaModHelper.Transform(rng, mu, 1, Divide.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects, column); + } + break; + + case 10: + { + originalLNObjects = Invert(beatmap, newObjects, rng, column); + } + break; + } + } + + ManiaModHelper.AfterTransform(newObjects, originalLNObjects, beatmap, rng, OriginalLN.Value, Gap.Value, SelectColumn.Value, DurationLimit.Value, LineSpacing.Value, + InvertLineSpacing.Value); + maniaBeatmap.Breaks.Clear(); + } + + public List Invert(IBeatmap beatmap, List newObjects, Random rng, IGrouping column) + { + var locations = column.OfType().Select(n => (column: n.Column, startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (column: h.Column, startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + //(startTime: h.EndTime, samples: h.GetNodeSamples(1)) Invert Mod Bug + })) + .OrderBy(h => h.startTime).ToList(); + + var newColumnObjects = new List(); + var originalLNObjects = new List(); + + for (int i = 0; i < locations.Count - 1; i++) + { + // Full duration of the hold note. + double fullDuration = locations[i + 1].startTime - locations[i].startTime; + // Beat length at the end of the hold note. + double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i + 1].startTime).BeatLength; + bool flag = true; + double duration = fullDuration - beatLength / Divide.Value; + + if (duration < beatLength / Divide.Value) + { + duration = beatLength / Divide.Value; + } + + if (duration > fullDuration - 3) + { + flag = false; + } + + if (OriginalLN.Value && locations[i].startTime != locations[i].endTime) + { + newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime); + originalLNObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime); + } + else if (rng.Next(100) < Percentage.Value && flag) + { + newColumnObjects.AddLNByDuration(locations[i].samples, column.Key, locations[i].startTime, duration); + } + else + { + newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime); + } + } + + double lastStartTime = locations[locations.Count - 1].startTime; + double lastEndTime = locations[locations.Count - 1].endTime; + + if (OriginalLN.Value && lastStartTime != lastEndTime) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[locations.Count - 1].startTime, + EndTime = locations[locations.Count - 1].endTime, + NodeSamples = [locations[locations.Count - 1].samples, Array.Empty()] + }); + originalLNObjects.AddNote(locations[locations.Count - 1].samples, column.Key, locations[locations.Count - 1].startTime, locations[locations.Count - 1].endTime); + } + else + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = locations[locations.Count - 1].startTime, + Samples = locations[locations.Count - 1].samples + }); + } + + newObjects.AddRange(newColumnObjects); + + return originalLNObjects; + } + + public List TrueRandom(IBeatmap beatmap, List newObjects, Random Rng, IGrouping column) + { + var locations = column.OfType().Select(n => (column: n.Column, startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples)) + .Concat(column.OfType().SelectMany(h => new[] + { + (column: h.Column, startTime: h.StartTime, endTime: h.EndTime, samples: h.GetNodeSamples(0)) + //(startTime: h.EndTime, samples: h.GetNodeSamples(1)) + })) + .OrderBy(h => h.startTime).ToList(); + + var newColumnObjects = new List(); + var originalLNObjects = new List(); + + for (int i = 0; i < locations.Count - 1; i++) + { + // Full duration of the hold note. + double fullDuration = locations[i + 1].startTime - locations[i].startTime; + + // Beat length at the end of the hold note. + // double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i + 1].startTime).BeatLength; + + double duration = Rng.Next((int)fullDuration) + Rng.NextDouble(); + while (duration > fullDuration) + duration--; + + if (OriginalLN.Value && locations[i].startTime != locations[i].endTime) + { + newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime); + originalLNObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime); + } + else if (Rng.Next(100) < Percentage.Value) + { + newColumnObjects.AddLNByDuration(locations[i].samples, column.Key, locations[i].startTime, duration); + } + else + { + newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime); + } + } + + newObjects.AddRange(newColumnObjects); + + return originalLNObjects; + } + + [Obsolete] + public void Invert(IBeatmap beatmap, + List newObjects, + Random Rng, + List<(int column, double startTime, IList samples, double endTime)> locations, + List<(double lastStartTime, double lastEndTime, bool lastLN, double thisStartTime, double thisEndTime, bool thisLN)> noteList, + List<(IList lastSample, IList thisSample)> sampleList, + int column) + { + double fullDuration = noteList[column].thisStartTime - noteList[column].lastStartTime; + + double beatLength = beatmap.ControlPointInfo.TimingPointAt(noteList[column].thisStartTime).BeatLength; + + bool flag = true; + + double duration = fullDuration - beatLength / Divide.Value; + + if (duration < beatLength / Divide.Value) + { + duration = beatLength / Divide.Value; + } + + if (duration > fullDuration - 3) + { + flag = false; + } + + if (OriginalLN.Value && noteList[column].lastStartTime != noteList[column].lastEndTime) + { + newObjects.AddNote(sampleList[column].lastSample, column, noteList[column].lastStartTime, noteList[column].lastEndTime); + } + else if (Rng.Next(100) < Percentage.Value && flag) + { + newObjects.AddLNByDuration(sampleList[column].lastSample, column, noteList[column].lastStartTime, duration); + } + else + { + newObjects.AddNote(sampleList[column].lastSample, column, noteList[column].lastStartTime); + } + } + + [Obsolete] + public void TrueRandomTransform(List newObjects, + Random? Rng, + List<(double lastTime, double lastEndTime, bool lastLN, double thisTime, double thisEndTime, bool thisLN)> noteList, + List<(IList lastSample, IList thisSample)> sampleList, + int column, + double fullDuration) + { + double duration = Rng!.Next((int)fullDuration) + Rng.NextDouble(); + while (duration > fullDuration) + duration--; + + if (OriginalLN.Value && noteList[column].lastTime != noteList[column].lastEndTime) + { + newObjects.AddNote(sampleList[column].lastSample, column, noteList[column].lastTime); + } + else if (Rng.Next(100) < Percentage.Value) + { + newObjects.AddNote(sampleList[column].lastSample, column, noteList[column].lastTime, noteList[column].lastTime + duration); + } + else + { + newObjects.AddNote(sampleList[column].lastSample, column, noteList[column].lastTime); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModMalodyStyleLN.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModMalodyStyleLN.cs new file mode 100644 index 0000000000..da1b4b5f70 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModMalodyStyleLN.cs @@ -0,0 +1,214 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + internal partial class ManiaModMalodyStyleLN : Mod, IApplicableToDifficulty, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset + { + public override string Name => "No LN Judgement"; + + public override string Acronym => "NL"; + + public override LocalisableString Description => EzManiaModStrings.MalodyStyleLN_Description; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public HitWindows HitWindows { get; set; } = new ManiaHitWindows(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var hitObjects = maniaBeatmap.HitObjects.Select(obj => + { + if (obj is Note note) + return new NoLNNote(note); + + if (obj is HoldNote hold) + return new NoLNHoldNote(hold); + + return obj; + }).ToList(); + + maniaBeatmap.HitObjects = hitObjects; + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + + foreach (var stage in maniaRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + } + } + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + HitWindows = new ManiaHitWindows(); + HitWindows.SetDifficulty(difficulty.OverallDifficulty); + + NoLNDrawableHoldNoteTail.HitWindows = HitWindows; + } + + private class NoLNNote : Note + { + public NoLNNote(Note note) + { + StartTime = note.StartTime; + Column = note.Column; + Samples = note.Samples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + } + } + + private class NoLNHeadNote : HeadNote + { + } + + private class NoLNBodyNote : HoldNoteBody + { + public override Judgement CreateJudgement() => new NoLNBodyJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + } + + private class NoLNTailNote : TailNote + { + public override Judgement CreateJudgement() => new NoLNJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + } + + private class NoLNHoldNote : HoldNote + { + public NoLNHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new NoLNHeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0), + }); + + AddNested(Tail = new NoLNTailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1), + }); + + AddNested(Body = new NoLNBodyNote + { + StartTime = StartTime, + Column = Column + }); + } + } + + private class NoLNBodyJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + + public override HitResult MinResult => HitResult.IgnoreMiss; + } + + private class NoLNJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + + public override HitResult MinResult => HitResult.ComboBreak; + } + + public partial class NoLNDrawableHoldNoteBody : DrawableHoldNoteBody + { + public new bool HasHoldBreak => false; + + internal new void TriggerResult(bool hit) + { + if (AllJudged) return; + + ApplyMaxResult(); + } + } + + public partial class NoLNDrawableHoldNoteTail : DrawableHoldNoteTail + { + public static HitWindows HitWindows = new ManiaHitWindows(); + + public override bool DisplayResult => false; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (!HoldNote.Head.IsHit) + { + return; + } + + if (timeOffset > 0 && HoldNote.Head.IsHit) + { + ApplyMaxResult(); + return; + } + else if (timeOffset > 0) + { + ApplyMinResult(); + return; + } + + if (HoldNote.IsHolding.Value) + { + return; + } + + if (HoldNote.Head.IsHit && Math.Abs(timeOffset) < Math.Abs(HitWindows.WindowFor(HitResult.Meh) * TailNote.RELEASE_WINDOW_LENIENCE)) + { + ApplyMaxResult(); + } + else + { + ApplyMinResult(); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNewJudgement.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNewJudgement.cs new file mode 100644 index 0000000000..51a26fb3ab --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNewJudgement.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModNewJudgement : Mod, IApplicableToBeatmap + { + public override string Name => "New Judgement"; + + public override string Acronym => "NJ"; + + public override LocalisableString Description => EzManiaModStrings.NewJudgement_Description; + + public override ModType Type => ModType.YuLiangSSS_Mod; + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + public override double ScoreMultiplier => 1.0; + + public ManiaHitWindows HitWindows { get; set; } = new ManiaHitWindows(); + + [SettingSource("Custom BPM", SettingControlType = typeof(SettingsNumberBox))] + public Bindable BPM { get; set; } = new Bindable(); + + [SettingSource("Divide")] + public BindableDouble Divide { get; set; } = new BindableDouble(7.5) + { + MinValue = 1, + MaxValue = 16, + Precision = 0.5 + }; + + [SettingSource("For 1/4 Jack")] + public BindableBool For14Jack { get; set; } = new BindableBool(); + + [SettingSource("For 1/6 Stream")] + public BindableBool For16Stream { get; set; } = new BindableBool(); + + [SettingSource("For 1/3 Jack")] + public BindableBool For13Jack { get; set; } = new BindableBool(); + + public double BeatmapBPM; + + public ManiaModNewJudgement() + { + Divide.BindValueChanged(_ => + { + updateHitRanges(); + }); + } + + private void updateHitRanges() + { + double perBeatLength = 60 / BeatmapBPM * 1000; + if (BPM.Value is not null) perBeatLength = 60 / (double)BPM.Value * 1000; + + if (For14Jack.Value) perBeatLength /= 2; + + if (For16Stream.Value) perBeatLength /= 1.5; + + if (For13Jack.Value) perBeatLength = perBeatLength * 4 / 6; + + double perfectRange = perBeatLength / Divide.Value; + double greatRange = perBeatLength / (Divide.Value / 1.5); + double goodRange = perBeatLength / (Divide.Value / 2); + double okRange = perBeatLength / (Divide.Value / 2.5); + double mehRange = perBeatLength / (Divide.Value / 3); + double missRange = perBeatLength / (Divide.Value / 3.5); + + HitWindows.ModifyManiaHitRange(new ManiaModifyHitRange( + perfectRange, + greatRange, + goodRange, + okRange, + mehRange, + missRange + )); + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + BeatmapBPM = beatmap.BeatmapInfo.BPM > 0 + ? beatmap.BeatmapInfo.BPM + : 200; + } + + public override void ResetSettingsToDefaults() + { + base.ResetSettingsToDefaults(); + HitWindows.ResetRange(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNoteAdjust.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNoteAdjust.cs new file mode 100644 index 0000000000..83068cec68 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNoteAdjust.cs @@ -0,0 +1,734 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModNoteAdjust : Mod, IApplicableAfterBeatmapConversion, IHasSeed + { + public override string Name => "Note Adjust"; + + public override string Acronym => "NA"; + + public override LocalisableString Description => EzManiaModStrings.NoteAdjust_Description; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IconUsage? Icon => FontAwesome.Solid.Brain; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (Style.Value != 6) + { + yield return ("Style", $"{Style.Value}"); + yield return ("Probability", $"{Probability.Value}%"); + yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}"); + } + else + { + yield return ("Style", $"{Style.Value}"); + yield return ("Probability", $"{Probability.Value}%"); + yield return ("Extremum", $"{Extremum.Value}"); + yield return ("Comparison Style", $"{ComparisonStyle.Value}"); + yield return ("Line", $"{Line.Value}"); + yield return ("Step", $"{Step.Value}"); + yield return ("Ignore Comparison", $"{IgnoreComparison}"); + yield return ("Ignore Interval", $"{IgnoreInterval}"); + yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}"); + } + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoteAdjustStyle_Label), nameof(EzManiaModStrings.NoteAdjustStyle_Description))] + public BindableInt Style { get; set; } = new BindableInt(1) + { + Precision = 1, + MinValue = 1, + MaxValue = 6 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoteAdjustProbability_Label), nameof(EzManiaModStrings.NoteAdjustProbability_Description))] + public BindableDouble Probability { get; set; } = new BindableDouble(100) + { + Precision = 2.5, + MinValue = -100, + MaxValue = 100, + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Extremum_Label), nameof(EzManiaModStrings.Extremum_Description))] + public BindableInt Extremum { get; set; } = new BindableInt(10) + { + Precision = 1, + MinValue = 1, + MaxValue = 10 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ComparisonStyle_Label), nameof(EzManiaModStrings.ComparisonStyle_Description))] + public BindableInt ComparisonStyle { get; set; } = new BindableInt(1) + { + Precision = 1, + MinValue = 1, + MaxValue = 2 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoteAdjustLine_Label), nameof(EzManiaModStrings.NoteAdjustLine_Description))] + public BindableInt Line { get; set; } = new BindableInt(1) + { + Precision = 1, + MinValue = 0, + MaxValue = 10 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Step_Label), nameof(EzManiaModStrings.Step_Description))] + public BindableInt Step { get; set; } = new BindableInt(-1) + { + Precision = 1, + MinValue = -1, + MaxValue = 10 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.IgnoreComparison_Label), nameof(EzManiaModStrings.IgnoreComparison_Description))] + public BindableBool IgnoreComparison { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.IgnoreInterval_Label), nameof(EzManiaModStrings.IgnoreInterval_Description))] + public BindableBool IgnoreInterval { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + // Column Number: 0 to n - 1 + public void ApplyToBeatmap(IBeatmap beatmap) + { + if (Probability.Value == 0) + { + return; + } + + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + var maniaBeatmap = (ManiaBeatmap)beatmap; + + var newObjects = new List(); + + var newColumnObjects = new List(); + + int keys = maniaBeatmap.TotalColumns; + + switch (Style.Value) + { + case 1: + { + if (Probability.Value > 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 0, keys, 1, keys, -1); + } + else if (Probability.Value < 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 0, keys, 1, 1, -1); + } + } + break; + + case 2: + { + if (Probability.Value > 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 1, keys, 1, keys, -1); + } + else if (Probability.Value < 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 1, keys, 1, 1, -1); + } + } + break; + + case 3: + { + if (Probability.Value > 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 1, keys, 2, keys, -1); + } + else if (Probability.Value < 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 1, keys, 2, 1, -1); + } + } + break; + + case 4: + { + if (Probability.Value > 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 2, keys, 1, keys, -1); + } + else if (Probability.Value < 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 2, keys, 1, 1, -1); + } + } + break; + + case 5: + { + if (Probability.Value > 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 2, keys, 2, keys, -1); + } + else if (Probability.Value < 0) + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, 2, keys, 2, 1, -1); + } + } + break; + + case 6: + { + Transform(newColumnObjects, maniaBeatmap, rng, Probability.Value, Line.Value, keys, ComparisonStyle.Value, Extremum.Value, Step.Value, IgnoreComparison.Value, + IgnoreInterval.Value); + } + break; + } + + newObjects.AddRange(newColumnObjects); + + maniaBeatmap.HitObjects = [.. newObjects.OrderBy(h => h.StartTime)]; + } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// If is 1, will convert the line: note greater or equal than Next and Last Line
If is 2, will convert the line: note less or equal than Next and Last Line + /// Maximum or minimum note on a line. + /// Skip line when converting successfully. + /// + /// + public void Transform(List obj, + ManiaBeatmap beatmap, + Random rng, + double probability, + int interval, + int keys, + int compare, + int extremum, + int skipStep, + bool ignoreComparison = false, + bool ignoreInterval = false) + { + List columnWithNoNote = new List(Enumerable.Range(0, keys)); + List columnWithNote = new List(); + + if (interval == 0) + { + foreach (var timingPoint in beatmap.HitObjects.GroupBy(h => h.StartTime)) + { + var locations = timingPoint.OfType().Select(n => (column: n.Column, startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples)) + .Concat(timingPoint.OfType().SelectMany(h => new[] + { + ( + column: h.Column, + startTime: h.StartTime, + endTime: h.EndTime, + samples: h.GetNodeSamples(0) + ) + })) + .OrderBy(h => h.startTime).ToList(); + + int quantity = timingPoint.Count(); + + foreach (var note in locations) + { + obj.AddNote(note.samples, note.column, note.startTime, note.endTime); + columnWithNoNote.Remove(note.column); + columnWithNote.Add(note.column); + } + + columnWithNoNote = columnWithNoNote.ShuffleIndex(rng).ToList(); + columnWithNote = columnWithNote.ShuffleIndex(rng).ToList(); + + if (probability > 0) + { + foreach (int column in columnWithNoNote) + { + if (quantity < Extremum.Value && rng.Next(100) < probability) + { + if (!InLN(obj, column, timingPoint.Key)) + { + obj.AddNote(locations[0].samples, column, timingPoint.Key); + quantity++; + } + } + } + } + else if (probability < 0) + { + foreach (int column in columnWithNote) + { + if (quantity > Extremum.Value && rng.Next(100) < probability) + { + obj.RemoveNote(column, timingPoint.Key); + quantity--; + } + } + } + + columnWithNoNote = new List(Enumerable.Range(0, keys)); + columnWithNote = new List(); + } + + return; + } + + List<(int column, double startTime, double endTime, IList samples)> line = new List<(int column, double startTime, double endTime, IList samples)>(); + List samples)>> manyLine = + new List samples)>>(); + + var middleLine = new List<(int column, double startTime, double endTime, IList samples)>(); + var lastLine = new List<(int column, double startTime, double endTime, IList samples)>(); + var nextLine = new List<(int column, double startTime, double endTime, IList samples)>(); + + double? middleTime = null; + IList? samples = null; + int lastQuantity = 0, middleQuantity = 0, nextQuantity = 0; + int i = 0, skip = 0; + + foreach (var timingPoint in beatmap.HitObjects.GroupBy(h => h.StartTime)) + { + var locations = timingPoint.OfType().Select(n => (column: n.Column, startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples)) + .Concat(timingPoint.OfType().SelectMany(h => new[] + { + ( + column: h.Column, + startTime: h.StartTime, + endTime: h.EndTime, + samples: h.GetNodeSamples(0) + ) + })) + .OrderBy(h => h.startTime).ToList(); + + if (i < 1 + interval * 2) + { + foreach (var note in locations) + { + line.Add((note.column, note.startTime, note.endTime, note.samples)); + obj.AddNote(note.samples, note.column, note.startTime, note.endTime); + } + + manyLine.Add(line); + + if (i >= interval) + { + foreach (var inLine in manyLine) + { + foreach (var note in inLine) + { + columnWithNoNote.Remove(note.column); + } + } + + middleLine = manyLine[i - interval]; + nextLine = manyLine[i - interval + 1]; + + foreach (var note in middleLine) + { + columnWithNote.Add(note.column); + } + + middleQuantity = columnWithNote.Count; + nextQuantity = nextLine.Count; + middleTime = middleLine[^1].startTime; + samples = middleLine[^1].samples; + + if (ignoreInterval) + { + columnWithNoNote = new List(Enumerable.Range(0, keys)); + + foreach (var note in middleLine) + { + columnWithNoNote.Remove(note.column); + } + } + + columnWithNoNote = columnWithNoNote.ShuffleIndex(rng).ToList(); + columnWithNote = columnWithNote.ShuffleIndex(rng).ToList(); + + if (compare == 1) + { + if (probability > 0 && (middleQuantity >= nextQuantity && middleQuantity >= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNoNote) + { + if (middleQuantity < extremum && rng.Next(100) < probability && middleTime is not null && samples is not null) + { + if (!InLN(obj, column, (double)middleTime)) + { + middleLine.Add((column, (double)middleTime, (double)middleTime, samples)); + middleQuantity++; + obj.AddNote(samples, column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + else if (probability < 0 && (middleQuantity >= nextQuantity && middleQuantity >= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNote) + { + if (middleQuantity > extremum && rng.Next(100) < -probability && middleTime is not null) + { + middleLine = middleLine.Where(s => s.column != column).ToList(); + middleQuantity--; + obj.RemoveNote(column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + else if (compare == 2) + { + if (probability > 0 && (middleQuantity <= nextQuantity && middleQuantity <= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNoNote) + { + if (middleQuantity < extremum && rng.Next(100) < probability && middleTime is not null && samples is not null) + { + if (!InLN(obj, column, (double)middleTime)) + { + middleLine.Add((column, (double)middleTime, (double)middleTime, samples)); + middleQuantity++; + obj.AddNote(samples, column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + else if (probability < 0 && (middleQuantity <= nextQuantity && middleQuantity <= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNote) + { + if (middleQuantity > extremum && rng.Next(100) < -probability && middleTime is not null) + { + middleLine = middleLine.Where(s => s.column != column).ToList(); + middleQuantity--; + obj.RemoveNote(column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + } + + line = new List<(int column, double startTime, double endTime, IList samples)>(); + lastLine = middleLine; + middleLine = new List<(int column, double startTime, double endTime, IList samples)>(); + nextLine = new List<(int column, double startTime, double endTime, IList samples)>(); + lastQuantity = middleQuantity; + middleQuantity = 0; + nextQuantity = 0; + columnWithNoNote = new List(Enumerable.Range(0, keys)); + columnWithNote = new List(); + i++; + + if (i == 1 + interval * 2) + { + manyLine.RemoveAt(0); + } + + continue; + } + + foreach (var note in locations) + { + line.Add((note.column, note.startTime, note.endTime, note.samples)); + obj.AddNote(note.samples, note.column, note.startTime, note.endTime); + } + + manyLine.Add(line); + + foreach (var inLine in manyLine) + { + foreach (var note in inLine) + { + columnWithNoNote.Remove(note.column); + } + } + + middleLine = manyLine[interval]; + nextLine = manyLine[interval + 1]; + + foreach (var note in middleLine) + { + columnWithNote.Add(note.column); + } + + middleQuantity = columnWithNote.Count; + nextQuantity = nextLine.Count; + middleTime = middleLine[^1].startTime; + samples = middleLine[^1].samples; + + if (ignoreInterval) + { + columnWithNoNote = new List(Enumerable.Range(0, keys)); + + foreach (var note in middleLine) + { + columnWithNoNote.Remove(note.column); + } + } + + if (skip > 0) + { + goto skip; + } + + columnWithNoNote = columnWithNoNote.ShuffleIndex(rng).ToList(); + columnWithNote = columnWithNote.ShuffleIndex(rng).ToList(); + + if (compare == 1) + { + if (probability > 0 && (middleQuantity >= nextQuantity && middleQuantity >= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNoNote) + { + if (middleQuantity < extremum && rng.Next(100) < probability && middleTime is not null && samples is not null) + { + if (!InLN(obj, column, (double)middleTime)) + { + middleLine.Add((column, (double)middleTime, (double)middleTime, samples)); + middleQuantity++; + obj.AddNote(samples, column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + else if (probability < 0 && (middleQuantity >= nextQuantity && middleQuantity >= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNote) + { + if (middleQuantity > extremum && rng.Next(100) < -probability && middleTime is not null) + { + middleLine = middleLine.Where(s => s.column != column).ToList(); + middleQuantity--; + obj.RemoveNote(column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + else if (compare == 2) + { + if (probability > 0 && (middleQuantity <= nextQuantity && middleQuantity <= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNoNote) + { + if (middleQuantity < extremum && rng.Next(100) < probability && middleTime is not null && samples is not null) + { + if (!InLN(obj, column, (double)middleTime)) + { + middleLine.Add((column, (double)middleTime, (double)middleTime, samples)); + middleQuantity++; + obj.AddNote(samples, column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + else if (probability < 0 && (middleQuantity <= nextQuantity && middleQuantity <= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNote) + { + if (middleQuantity > extremum && rng.Next(100) < -probability && middleTime is not null) + { + middleLine = middleLine.Where(s => s.column != column).ToList(); + middleQuantity--; + obj.RemoveNote(column, (double)middleTime); + skip = skipStep + 1; + } + } + } + } + + skip: + + if (skip > 0) + { + skip--; + } + + line = new List<(int column, double startTime, double endTime, IList samples)>(); + lastLine = middleLine; + middleLine = new List<(int column, double startTime, double endTime, IList samples)>(); + nextLine = new List<(int column, double startTime, double endTime, IList samples)>(); + lastQuantity = middleQuantity; + middleQuantity = 0; + nextQuantity = 0; + columnWithNoNote = new List(Enumerable.Range(0, keys)); + columnWithNote = new List(); + manyLine.RemoveAt(0); + } + + // Dispose last n line. + + for (i = interval; i < manyLine.Count; i++) + { + foreach (var inLine in manyLine) + { + foreach (var note in inLine) + { + columnWithNoNote.Remove(note.column); + } + } + + middleLine = manyLine[i]; + + if (i + 1 < manyLine.Count) + { + nextLine = manyLine[i + 1]; + } + + foreach (var note in middleLine) + { + columnWithNote.Add(note.column); + } + + middleQuantity = columnWithNote.Count; + nextQuantity = nextLine.Count; + middleTime = middleLine[^1].startTime; + samples = middleLine[^1].samples; + + if (ignoreInterval) + { + columnWithNoNote = new List(Enumerable.Range(0, keys)); + + foreach (var note in middleLine) + { + columnWithNoNote.Remove(note.column); + } + } + + columnWithNoNote = columnWithNoNote.ShuffleIndex(rng).ToList(); + columnWithNote = columnWithNote.ShuffleIndex(rng).ToList(); + + if (compare == 1) + { + if (probability > 0 && (middleQuantity >= nextQuantity && middleQuantity >= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNoNote) + { + if (middleQuantity < extremum && rng.Next(100) < probability && middleTime is not null && samples is not null) + { + if (!InLN(obj, column, (double)middleTime)) + { + middleLine.Add((column, (double)middleTime, (double)middleTime, samples)); + middleQuantity++; + obj.AddNote(samples, column, (double)middleTime); + } + } + } + } + else if (probability < 0 && (middleQuantity >= nextQuantity && middleQuantity >= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNote) + { + if (middleQuantity > extremum && rng.Next(100) < -probability && middleTime is not null) + { + middleLine = middleLine.Where(s => s.column != column).ToList(); + middleQuantity--; + obj.RemoveNote(column, (double)middleTime); + } + } + } + } + else if (compare == 2) + { + if (probability > 0 && (middleQuantity <= nextQuantity && middleQuantity <= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNoNote) + { + if (middleQuantity < extremum && rng.Next(100) < probability && middleTime is not null && samples is not null) + { + if (!InLN(obj, column, (double)middleTime)) + { + middleLine.Add((column, (double)middleTime, (double)middleTime, samples)); + middleQuantity++; + obj.AddNote(samples, column, (double)middleTime); + } + } + } + } + else if (probability < 0 && (middleQuantity <= nextQuantity && middleQuantity <= lastQuantity || ignoreComparison)) + { + foreach (int column in columnWithNote) + { + if (middleQuantity > extremum && rng.Next(100) < -probability && middleTime is not null) + { + middleLine = middleLine.Where(s => s.column != column).ToList(); + middleQuantity--; + obj.RemoveNote(column, (double)middleTime); + } + } + } + } + + line = new List<(int column, double startTime, double endTime, IList samples)>(); + lastLine = middleLine; + middleLine = new List<(int column, double startTime, double endTime, IList samples)>(); + nextLine = new List<(int column, double startTime, double endTime, IList samples)>(); + lastQuantity = middleQuantity; + middleQuantity = 0; + nextQuantity = 0; + columnWithNoNote = new List(Enumerable.Range(0, keys)); + columnWithNote = new List(); + } + } + + public bool InLN(List obj, int column, double startTime) + { + var temp = obj.Where(x => x.Column == column); + var times = temp.OfType().Select(n => (column: n.Column, startTime: n.StartTime, endTime: n.EndTime)); + + //var temp2 = newColumnObjects.GroupBy(x => x.Column == column); + //var times2 = temp2.OfType().Select(n => (column: n.Column, startTime: n.StartTime, endTime: n.EndTime)); + + foreach (var time in times) + { + if (time.column == column && startTime >= time.startTime && startTime <= time.endTime) + { + return true; + } + } + + return false; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoM.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoM.cs new file mode 100644 index 0000000000..7bd42e9d22 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoM.cs @@ -0,0 +1,338 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModNtoM : Mod, IApplicableToBeatmapConverter, IApplicableAfterBeatmapConversion, IHasSeed, IHasApplyOrder + { + public override string Name => "Nk to Mk Converter"; + + public override string Acronym => "NTM"; //Nk to Mk Letter + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => EzManiaModStrings.NtoM_Description; + + public override IconUsage? Icon => FontAwesome.Solid.Moon; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Probability", $"{Probability.Value}%"); + yield return ("Key", $"{Key.Value}"); + yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Probability_Label), nameof(EzManiaModStrings.Probability_Description))] + public BindableNumber Probability { get; set; } = new BindableInt(70) + { + MinValue = 0, + MaxValue = 100, + Precision = 5 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Key_Label), nameof(EzManiaModStrings.Key_Description))] + public BindableNumber Key { get; set; } = new BindableInt(8) + { + MinValue = 2, + MaxValue = 10, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ApplyOrder_Label), nameof(EzManiaModStrings.ApplyOrder_Description), SettingControlType = typeof(SettingsNumberBox))] + public Bindable ApplyOrderSetting { get; } = new Bindable(0); + + public void ApplyToBeatmapConverter(IBeatmapConverter converter) + { + var mbc = (ManiaBeatmapConverter)converter; + + float keys = mbc.TotalColumns; + + if (keys > 9 || Key.Value <= keys) return; + + mbc.TargetColumns = Key.Value; + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + if (rng == null) throw new ArgumentNullException(nameof(rng)); + + var maniaBeatmap = (ManiaBeatmap)beatmap; + + int keys = (int)maniaBeatmap.Difficulty.CircleSize; + + if (keys > 9 || Key.Value <= keys) return; + + var newObjects = new List(); + + var newColumnObjects = new List(); + + var fixedColumnObjects = new List(); + + var locations = maniaBeatmap.HitObjects.OfType().Select(n => ( + startTime: n.StartTime, + samples: n.Samples, + column: n.Column, + endTime: n.StartTime, + duration: n.StartTime - n.StartTime + )) + .Concat(maniaBeatmap.HitObjects.OfType().Select(h => ( + startTime: h.StartTime, + samples: h.Samples, + column: h.Column, + endTime: h.EndTime, + duration: h.EndTime - h.StartTime + ))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList(); + + #region Null column + + int keyValue = keys + 1; + bool firstKeyFlag = true; + + int emptyColumn = rng.Next(-1, 1 + keyValue - 2); + + while (keyValue <= Key.Value) + { + var confirmNull = new List(); + for (int i = 0; i <= Key.Value; i++) confirmNull.Add(false); + var nullColumnList = new List(); + + if (firstKeyFlag) + { + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + int count = column.Count(); + if (!confirmNull[column.Key] && count != 0) confirmNull[column.Key] = true; + } + + for (int i = 0; i < Key.Value; i++) + { + if (!confirmNull[i]) + nullColumnList.Add(i); + } + + firstKeyFlag = false; + } + + int atLeast = 5; + double changeTime = 0; + + bool plus = true; + bool minus = false; + bool next = false; + + for (int i = 0; i < locations.Count; i++) + { + bool isLN = false; + var note = new Note(); + var hold = new HoldNote(); + int columnNum = locations[i].column; + int minusColumn = 0; + + foreach (int nul in nullColumnList) + { + if (columnNum > nul) + minusColumn++; + } + + columnNum -= minusColumn; + + #endregion + + atLeast--; + + if (locations[i].startTime == locations[i].endTime) + { + note.StartTime = locations[i].startTime; + note.Samples = locations[i].samples; + } + else + { + hold.StartTime = locations[i].startTime; + hold.Samples = locations[i].samples; + hold.EndTime = locations[i].endTime; + isLN = true; + } + + bool error = changeTime != locations[i].startTime; + + if (keys < 4) // why you are converting 1k 2k 3k into upper keys? + columnNum = rng.Next(keyValue); + else + { + if (error && rng.Next(100) < Probability.Value && atLeast < 0) + { + changeTime = locations[i].startTime; + atLeast = keys - 2; + next = true; + } + + if (next && plus) + { + next = false; + emptyColumn++; + + if (emptyColumn > keyValue - 2) + { + plus = !plus; + minus = !minus; + emptyColumn = keyValue - 2; + } + } + else if (next && minus) + { + next = false; + emptyColumn--; + + if (emptyColumn < -1) + { + plus = !plus; + minus = !minus; + emptyColumn = -1; + } + } + + if (columnNum > emptyColumn) columnNum++; + } + + bool overlap = ManiaModHelper.FindOverlapInList(newColumnObjects, columnNum, locations[i].startTime, locations[i].endTime); + + if (overlap) + { + for (int k = 0; k < keyValue; k++) + { + if (!ManiaModHelper.FindOverlapInList(newColumnObjects, columnNum - k, locations[i].startTime, locations[i].endTime) && columnNum - k >= 0) + columnNum -= k; + else if (!ManiaModHelper.FindOverlapInList(newColumnObjects, columnNum + k, locations[i].startTime, locations[i].endTime) && columnNum + k <= keyValue - 1) columnNum += k; + } + } + + if (isLN) + { + newColumnObjects.Add(new HoldNote + { + Column = Math.Clamp(columnNum, 0, Key.Value - 1), + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = Math.Clamp(columnNum, 0, Key.Value - 1), + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + } + + for (int i = 0; i < newColumnObjects.Count; i++) + { + bool overlap = false, outIndex = false; + + if (newColumnObjects[i].Column < 0 || newColumnObjects[i].Column > Key.Value - 1) + { + outIndex = true; + newColumnObjects[i].Column = rng.Next(Key.Value - 1); + } + + for (int j = i + 1; j < newColumnObjects.Count; j++) + { + if (newColumnObjects[i].Column == newColumnObjects[j].Column && newColumnObjects[i].StartTime >= newColumnObjects[j].StartTime - 2 + && newColumnObjects[i].StartTime <= newColumnObjects[j].StartTime + 2) overlap = true; + + if (newColumnObjects[j].StartTime != newColumnObjects[j].GetEndTime()) + { + if (newColumnObjects[i].Column == newColumnObjects[j].Column && newColumnObjects[i].StartTime >= newColumnObjects[j].StartTime - 2 + && newColumnObjects[i].StartTime <= newColumnObjects[j].GetEndTime() + 2) + overlap = true; + } + } + + if (outIndex) overlap = true; + + if (!overlap) + fixedColumnObjects.Add(newColumnObjects[i]); + else + { + for (int k = 0; k < keyValue; k++) + { + if (!ManiaModHelper.FindOverlapInList(newColumnObjects[i], newColumnObjects.Where(h => h.Column == newColumnObjects[i].Column - k).ToList()) + && newColumnObjects[i].Column - k >= 0) + newColumnObjects[i].Column -= k; + else if (!ManiaModHelper.FindOverlapInList(newColumnObjects[i], newColumnObjects.Where(h => h.Column == newColumnObjects[i].Column + k).ToList()) + && newColumnObjects[i].Column + k <= keyValue - 1) newColumnObjects[i].Column += k; + } + + fixedColumnObjects.Add(newColumnObjects[i]); + } + } + + if (keyValue < Key.Value) + { + keys++; + keyValue = keys + 1; + + locations = fixedColumnObjects.OfType().Select(n => ( + startTime: n.StartTime, + samples: n.Samples, + column: n.Column, + endTime: n.StartTime, + duration: n.StartTime - n.StartTime + )) + .Concat(fixedColumnObjects.OfType().Select(h => ( + startTime: h.StartTime, + samples: h.Samples, + column: h.Column, + endTime: h.EndTime, + duration: h.EndTime - h.StartTime + ))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList(); + + emptyColumn = -1; + fixedColumnObjects.Clear(); + newColumnObjects.Clear(); + } + else + break; + } + + newObjects.AddRange(fixedColumnObjects); + + maniaBeatmap.HitObjects = newObjects; + } + + public int ApplyOrder => ApplyOrderSetting.Value ?? 0; + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoMAnother.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoMAnother.cs new file mode 100644 index 0000000000..e261e0da35 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModNtoMAnother.cs @@ -0,0 +1,490 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModNtoMAnother : Mod, IApplicableToBeatmapConverter, IApplicableAfterBeatmapConversion, IHasSeed, IHasApplyOrder + { + public const double INTERVAL = 50; + + public const double LN_INTERVAL = 10; + + public const double ERROR = 1.5; + + public override string Name => "Nk to Mk Converter Another"; + + public override string Acronym => "NtMA"; + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => EzManiaModStrings.NtoMAnother_Description; + + public override IconUsage? Icon => FontAwesome.Solid.CloudRain; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Key", $"{Key.Value}"); + yield return ("Blank Column", $"{BlankColumn.Value}"); + yield return ("Gap", $"{Gap.Value}"); + + if (Clean.Value) + { + yield return ("Clean", Clean.Value ? "On" : "Off"); + yield return ("Clean Divide", $"1/{CleanDivide.Value}"); + } + + if (Adjust4Jack.Value) + { + yield return ("1/4 Jack", Adjust4Jack.Value ? "On" : "Off"); + } + + if (Adjust4Speed.Value) + { + yield return ("1/4 Speed", Adjust4Speed.Value ? "On" : "Off"); + } + + yield return ("Seed", $"Seed {(Seed.Value is null ? "Null" : Seed.Value)}"); + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Key_Label), nameof(EzManiaModStrings.Key_Description))] + public BindableNumber Key { get; set; } = new BindableInt(8) + { + MinValue = 2, + MaxValue = 10, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.BlankColumn_Label), nameof(EzManiaModStrings.BlankColumn_Description))] + public BindableNumber BlankColumn { get; set; } = new BindableInt(0) + { + MinValue = 0, + MaxValue = 10, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NtoMGap_Label), nameof(EzManiaModStrings.NtoMGap_Description))] + public BindableInt Gap { get; set; } = new BindableInt(10) + { + MinValue = 0, + MaxValue = 20, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Clean_Label), nameof(EzManiaModStrings.Clean_Description))] + public BindableBool Clean { get; set; } = new BindableBool(true); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CleanDivide_Label), nameof(EzManiaModStrings.CleanDivide_Description))] + public BindableInt CleanDivide { get; set; } = new BindableInt(4) + { + MinValue = 0, + MaxValue = 16, + Precision = 1 + }; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Adjust4Jack_Label), nameof(EzManiaModStrings.Adjust4Jack_Description))] + public BindableBool Adjust4Jack { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Adjust4Speed_Label), nameof(EzManiaModStrings.Adjust4Speed_Description))] + public BindableBool Adjust4Speed { get; set; } = new BindableBool(false); + + [SettingSource("Seed", "Use a custom seed instead of a random one.", SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable(); + + [SettingSource("Apply Order", "Order in which this mod is applied after beatmap conversion. Lower runs earlier.", SettingControlType = typeof(SettingsNumberBox))] + public Bindable ApplyOrderSetting { get; } = new Bindable(0); + + public void ApplyToBeatmapConverter(IBeatmapConverter converter) + { + var mbc = (ManiaBeatmapConverter)converter; + + float keys = mbc.TotalColumns; + + if (keys > 9 || Key.Value <= keys) + { + return; + } + + mbc.TargetColumns = Key.Value; + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + + var maniaBeatmap = (ManiaBeatmap)beatmap; + + int keys = (int)maniaBeatmap.Difficulty.CircleSize; + + int blank = BlankColumn.Value; + + if (blank > Key.Value - keys) + { + blank = Key.Value - keys; + } + + if (keys > 9 || Key.Value <= keys) + { + return; + } + + var newObjects = new List(); + + var locations = maniaBeatmap.HitObjects.OfType().Select(n => ( + column: n.Column, + startTime: n.StartTime, + endTime: n.StartTime, + samples: n.Samples + )) + .Concat(maniaBeatmap.HitObjects.OfType().Select(h => ( + column: h.Column, + startTime: h.StartTime, + endTime: h.EndTime, + samples: h.Samples + ))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList(); + + var confirmNull = new List(); + var nullColumnList = new List(); + + for (int i = 0; i <= Key.Value; i++) + { + confirmNull.Add(false); + } + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + int count = column.Count(); + + if (!confirmNull[column.Key] && count != 0) + { + confirmNull[column.Key] = true; + } + } + + for (int i = 0; i < Key.Value; i++) + { + if (!confirmNull[i]) + { + nullColumnList.Add(i); + } + } + + for (int i = 0; i < locations.Count; i++) + { + int minusColumn = 0; + + foreach (int nul in nullColumnList) + { + if (locations[i].column > nul) + { + minusColumn++; + } + } + + var thisLocations = locations[i]; + thisLocations.column -= minusColumn; + locations[i] = thisLocations; + } + + List<(int column, double startTime, double endTime, IList samples)> area = new List<(int column, double startTime, double endTime, IList samples)>(); + List checkList = new List(); + + var tempObjects = locations.OrderBy(h => h.startTime).ToList(); + + double sumTime = 0; + double lastTime = 0; + + foreach (var timingPoint in tempObjects.GroupBy(h => h.startTime)) + { + var newLocations = timingPoint.OfType<(int column, double startTime, double endTime, IList samples)>() + .Select(n => (Column: n.column, StartTime: n.startTime, EndTime: n.endTime, Samples: n.samples)).OrderBy(h => h.Column).ToList(); + + List<(int column, double startTime, double endTime, IList samples)> line = new List<(int column, double startTime, double endTime, IList samples)>(); + + foreach (var note in newLocations) + { + line.Add((note.Column, note.StartTime, note.EndTime, note.Samples)); + } + + //manyLine.Add(line); + int blankColumn = BlankColumn.Value; + + sumTime += timingPoint.Key - lastTime; + lastTime = timingPoint.Key; + + area.AddRange(line); + + double gap = 29998.8584 * Math.Pow(Math.E, -0.3176 * Gap.Value) + 347.7248; + + if (Gap.Value == 0) + { + gap = double.MaxValue; + } + + if (sumTime >= gap) + { + sumTime = 0; + // Process area + int cleanDivide = CleanDivide.Value; + + if (Adjust4Jack.Value) + { + cleanDivide *= 2; + } + + if (Adjust4Speed.Value) + { + cleanDivide /= 2; + } + + var processed = ProcessArea(maniaBeatmap, rng, area, keys, Key.Value, blank, cleanDivide, ERROR, checkList); + newObjects.AddRange(processed.result); + checkList = processed.checkList.ToList(); + area.Clear(); + } + } + + if (area.Count > 0) + { + int cleanDivide = CleanDivide.Value; + + if (Adjust4Jack.Value) + { + cleanDivide *= 2; + } + + if (Adjust4Speed.Value) + { + cleanDivide /= 2; + } + + var processed = ProcessArea(maniaBeatmap, rng, area, keys, Key.Value, blank, cleanDivide, ERROR, checkList); + newObjects.AddRange(processed.result); + } + + newObjects = newObjects.OrderBy(h => h.StartTime).ToList(); + + var cleanObjects = new List(); + + foreach (var column in newObjects.GroupBy(h => h.Column)) + { + var newColumnObjects = new List(); + + var cleanLocations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + })) + .OrderBy(h => h.startTime).ToList(); + + double lastStartTime = cleanLocations[0].startTime; + double lastEndTime = cleanLocations[0].endTime; + var lastSample = cleanLocations[0].samples; + + for (int i = 0; i < cleanLocations.Count; i++) + { + if (i == 0) + { + lastStartTime = cleanLocations[0].startTime; + lastEndTime = cleanLocations[0].endTime; + lastSample = cleanLocations[0].samples; + continue; + } + + if (cleanLocations[i].startTime >= lastStartTime && cleanLocations[i].startTime <= lastEndTime) + { + cleanLocations.RemoveAt(i); + i--; + continue; + } // if the note in a LN + + if (Math.Abs(cleanLocations[i].startTime - lastStartTime) <= INTERVAL) + { + lastStartTime = cleanLocations[i].startTime; + lastEndTime = cleanLocations[i].endTime; + lastSample = cleanLocations[i].samples; + cleanLocations.RemoveAt(i); + i--; + continue; + } // interval judgement + + if (Math.Abs(cleanLocations[i].startTime - lastEndTime) <= LN_INTERVAL) + { + lastStartTime = cleanLocations[i].startTime; + lastEndTime = cleanLocations[i].endTime; + lastSample = cleanLocations[i].samples; + cleanLocations.RemoveAt(i); + i--; + continue; + } // LN interval judgement + + newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime); + lastStartTime = cleanLocations[i].startTime; + lastEndTime = cleanLocations[i].endTime; + lastSample = cleanLocations[i].samples; + } + + newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime); + + cleanObjects.AddRange(newColumnObjects); + } + + if (Clean.Value) + { + maniaBeatmap.HitObjects = cleanObjects.OrderBy(h => h.StartTime).ToList(); + } + else + { + maniaBeatmap.HitObjects = newObjects.OrderBy(h => h.StartTime).ToList(); + } + } + + public (List result, List checkList) ProcessArea(ManiaBeatmap beatmap, + Random rng, + List<(int column, double startTime, double endTime, IList samples)> hitObjects, + int fromKeys, + int toKeys, + int blankNum = 0, + int clean = 0, + double error = 0, + List? checkList = null) + { + List newObjects = new List(); + List<(int column, bool isBlank)> copyColumn = []; + List insertColumn = []; + List checkColumn = []; + bool isFirst = true; + + int num = toKeys - fromKeys - blankNum; + + while (num > 0) + { + int copy = rng.Next(fromKeys); + + if (!copyColumn.Contains((copy, false))) + { + copyColumn.Add((copy, false)); + num--; + } + } + + num = blankNum; + + while (num > 0) + { + int copy = -1; + copyColumn.Add((copy, true)); + num--; + } + + num = toKeys - fromKeys; + + while (num > 0) + { + int insert = rng.Next(toKeys); + + if (!insertColumn.Contains(insert)) + { + insertColumn.Add(insert); + num--; + } + } + + insertColumn = insertColumn.OrderBy(c => c).ToList(); + + foreach (var timingPoint in hitObjects.GroupBy(h => h.startTime)) + { + var locations = timingPoint.OfType<(int column, double startTime, double endTime, IList samples)>().ToList(); + var tempObjects = new List(); + int length = copyColumn.Count; + + for (int i = 0; i < locations.Count; i++) + { + int column = locations[i].column; + + for (int j = 0; j < length; j++) + { + if (column == copyColumn[j].column && !copyColumn[j].isBlank) + { + int clampedColumn = Math.Clamp(insertColumn[j], 0, toKeys - 1); + tempObjects.AddNote(locations[i].samples, clampedColumn, locations[i].startTime, locations[i].endTime); + } + + if (locations[i].column >= insertColumn[j]) + { + locations[i] = (locations[i].column + 1, locations[i].startTime, locations[i].endTime, locations[i].samples); + } + } + + int finalColumn = Math.Clamp(locations[i].column, 0, toKeys - 1); + tempObjects.AddNote(locations[i].samples, finalColumn, locations[i].startTime, locations[i].endTime); + } + + if (isFirst && checkList is not null && checkList.Count > 0 && clean > 0) + { + var checkC = checkList.Select(h => h.Column).ToList(); + var checkS = checkList.Select(h => h.StartTime).ToList(); + + for (int i = 0; i < tempObjects.Count; i++) + { + if (checkC.Contains(tempObjects[i].Column)) + { + if (clean != 0) + { + double beatLength = beatmap.ControlPointInfo.TimingPointAt(tempObjects[i].StartTime).BeatLength; + double timeDivide = beatLength / clean; + int index = checkC.IndexOf(tempObjects[i].Column); + + if (tempObjects[i].StartTime - checkS[index] < timeDivide + error) + { + tempObjects.RemoveAt(i); + i--; + } + } + else + { + tempObjects.RemoveAt(i); + i--; + } + } + } + + isFirst = false; + } + + checkColumn.Clear(); + checkColumn.AddRange(tempObjects); + newObjects.AddRange(tempObjects); + } + + return (newObjects, checkColumn); + } + + public int ApplyOrder => ApplyOrderSetting.Value ?? 0; + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Health.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Health.cs new file mode 100644 index 0000000000..440ed4ab0c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Health.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ManiaModO2Health : ModFailCondition, IApplicableAfterBeatmapConversion + { + public const int MAX_HEALTH = 1000; + + public Bindable HP = new Bindable(1000); + + private readonly int[][] difficultySettings = + { + new[] { 3, 2, -10, -50 }, // Easy + new[] { 2, 1, -7, -40 }, // Normal + new[] { 1, 0, -5, -30 } // Hard + }; + + public double Health => (double)HP.Value / MAX_HEALTH; + + public override string Name => "O2JAM Health"; + + public override string Acronym => "OH"; + + public override LocalisableString Description => EzManiaModStrings.O2Health_Description; + + public override double ScoreMultiplier => 1.0; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + string difficultyName = Difficulty.Value switch + { + 1 => "Easy", + 2 => "Normal", + 3 => "Hard", + _ => "Unknown" + }; + yield return ("Difficulty", difficultyName); + } + } + + public override ModType Type => ModType.YuLiangSSS_Mod; + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Difficulty_Label), nameof(EzManiaModStrings.Difficulty_Description))] + public BindableInt Difficulty { get; set; } = new BindableInt(1) + { + MinValue = 1, + MaxValue = 3, + Precision = 1 + }; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + HP.Value = MAX_HEALTH; + } + + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + { + int difficultyIndex = Difficulty.Value - 1; + int healthChange = 0; + + switch (result.Type) + { + case HitResult.Perfect: + healthChange = difficultySettings[difficultyIndex][0]; + break; + + case HitResult.Good: + healthChange = difficultySettings[difficultyIndex][1]; + break; + + case HitResult.Meh: + healthChange = difficultySettings[difficultyIndex][2]; + break; + + case HitResult.Miss: + healthChange = difficultySettings[difficultyIndex][3]; + break; + } + + HP.Value += healthChange; + + if (HP.Value > MAX_HEALTH) + HP.Value = MAX_HEALTH; + + healthProcessor.Health.Value = Health; + + return HP.Value <= 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Judgement.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Judgement.cs new file mode 100644 index 0000000000..0c308295c1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModO2Judgement.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.Skinning.Ez2HUD; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public partial class ManiaModO2Judgement : Mod, IApplicableToDifficulty, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset, IApplicableToHUD + { + public static ManiaHitWindows HitWindows = new ManiaHitWindows(); + + public override string Name => "O2JAM Judgement"; + + public override string Acronym => "OJ"; + + public override LocalisableString Description => EzManiaModStrings.O2Judgement_Description; + + public override double ScoreMultiplier => 1.0; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (PillMode.Value) yield return ("Pill", "On"); + } + } + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.PillSwitch_Label), nameof(EzManiaModStrings.PillSwitch_Description))] + public BindableBool PillMode { get; set; } = new BindableBool(true); + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + + foreach (var stage in maniaRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + } + } + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + var hitObjects = maniaBeatmap.HitObjects.Select(obj => + { + if (obj is Note note) + return new O2Note(note); + + if (obj is HoldNote hold) + return new O2HoldNote(hold); + + return obj; + }).ToList(); + + maniaBeatmap.HitObjects = hitObjects; + + // Ensure global O2 BPM and this mod's hit windows are set so gameplay uses correct ranges. + double bpm = beatmap.BeatmapInfo.BPM; + O2HitModeExtension.SetOriginalBPM(bpm); + O2HitModeExtension.SetControlPoints(beatmap.ControlPointInfo); + O2HitModeExtension.PillActivated = true; + O2HitModeExtension.PILL_COUNT.Value = 0; + HitWindows.BPM = bpm; + HitWindows.ModifyManiaHitRange(new ManiaModifyHitRange( + O2HitModeExtension.BASE_COOL / bpm, + O2HitModeExtension.BASE_COOL / bpm, + O2HitModeExtension.BASE_GOOD / bpm, + O2HitModeExtension.BASE_GOOD / bpm, + O2HitModeExtension.BASE_BAD / bpm, + O2HitModeExtension.BASE_BAD / bpm + )); + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + O2HitModeExtension.PILL_COUNT.Value = 0; + O2HitModeExtension.PillActivated = PillMode.Value; + } + + public override void ResetSettingsToDefaults() + { + base.ResetSettingsToDefaults(); + HitWindows.ResetRange(); + } + + public void ApplyToHUD(HUDOverlay overlay) + { + if (!PillMode.Value) + return; + + var pillUI = new EzComO2JamPillUI + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }; + pillUI.BoxElementAlpha.Value = 0.7f; + overlay.Add(pillUI); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModPlayfieldTransformation.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModPlayfieldTransformation.cs new file mode 100644 index 0000000000..a3bee3e326 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModPlayfieldTransformation.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public partial class ManiaModPlayfieldTransformation : Mod, IApplicableToPlayer, IUpdatableByPlayfield, IApplicableToScoreProcessor, IApplicableToDrawableRuleset + { + public override string Name => "Playfield Scale"; + + public override string Acronym => "PS"; + + public override LocalisableString Description => EzManiaModStrings.PlayfieldTransformation_Description; + + public override double ScoreMultiplier => 1.0; + + public override ModType Type => ModType.YuLiangSSS_Mod; + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MinimumScale_Label), nameof(EzManiaModStrings.MinimumScale_Description))] + public BindableFloat MinScale { get; } = new BindableFloat(0.3f) + { + MinValue = 0.3f, + MaxValue = 1.0f, + Precision = 0.01f + }; + + private readonly BindableInt combo = new BindableInt(); + private readonly IBindable isBreakTime = new Bindable(); + + private const int max_combo_for_min_scale = 300; // Combo value at which min scale is reached + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + combo.UnbindAll(); + combo.BindTo(scoreProcessor.Combo); + } + + public void ApplyToPlayer(Player player) + { + isBreakTime.UnbindAll(); + isBreakTime.BindTo(player.IsBreakTime); + } + + public void ApplyToManiaPlayfield(ManiaPlayfield playfield) + { + // No-op + } + + public void Update(Playfield playfield) + { + var maniaPlayfield = (ManiaPlayfield)playfield; + + float targetScale; + + if (isBreakTime.Value) + { + targetScale = 1f; + } + else + { + // Calculate scale based on combo, interpolating between 1f and MinScale + // The scale reaches MinScale at max_combo_for_min_scale + float comboRatio = Math.Min(1f, (float)combo.Value / max_combo_for_min_scale); + targetScale = 1f - comboRatio * (1f - MinScale.Value); + } + + foreach (var stage in maniaPlayfield.Stages) + { + stage.ScaleTo(new Vector2(targetScale, 1f), 1000, Easing.OutQuint); + } + } + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) + { + switch (rank) + { + case ScoreRank.X: + return ScoreRank.XH; + + case ScoreRank.S: + return ScoreRank.SH; + + default: + return rank; + } + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModReleaseAdjust.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModReleaseAdjust.cs new file mode 100644 index 0000000000..c10eb36545 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModReleaseAdjust.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public partial class ManiaModReleaseAdjust : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset + { + public override string Name => "Release Adjust"; + + public override string Acronym => "RA"; + + public override LocalisableString Description => EzManiaModStrings.ReleaseAdjust_Description; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override Type[] IncompatibleMods => new[] { typeof(ManiaModHoldOff) }; + + [SettingSource("Offset")] + public BindableInt ReleaseOffset { get; set; } = new BindableInt(50) + { + MinValue = 0, + MaxValue = 250, + Precision = 10, + }; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var hitObjects = maniaBeatmap.HitObjects.Select(obj => + { + if (obj is HoldNote hold) + return new NoReleaseHoldNote(hold); + + return obj; + }).ToList(); + + maniaBeatmap.HitObjects = hitObjects; + + NoReleaseDrawableHoldNoteTail.ReleaseOffset = ReleaseOffset.Value; + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + + foreach (var stage in maniaRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) + { + column.RegisterPool(10, 50); + } + } + } + + public partial class NoReleaseDrawableHoldNoteTail : DrawableHoldNoteTail + { + public static int ReleaseOffset = 50; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (HoldNote.IsHolding.Value && Math.Abs(timeOffset) <= ReleaseOffset) + ApplyResult(GetCappedResult(HitResult.Perfect)); + else + base.CheckForResult(userTriggered, timeOffset); + } + } + + private class NoReleaseTailNote : TailNote + { + } + + private class NoReleaseHoldNote : HoldNote + { + public NoReleaseHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new HeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0), + }); + + AddNested(Tail = new NoReleaseTailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1), + }); + + AddNested(Body = new HoldNoteBody + { + StartTime = StartTime, + Column = Column + }); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModRemedy.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModRemedy.cs new file mode 100644 index 0000000000..d897f4b04f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ManiaModRemedy.cs @@ -0,0 +1,401 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public partial class ManiaModRemedy : Mod, IApplicableToDifficulty, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset + { + public override string Name => "Remedy"; + + public override string Acronym => "RY"; + + public override LocalisableString Description => EzManiaModStrings.Remedy_Description; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + yield return ("Perfect", $"{PerfectCount.Value}"); + yield return ("Great", $"{GreatCount.Value}"); + yield return ("Good", $"{GoodCount.Value}"); + yield return ("Ok", $"{OkCount.Value}"); + yield return ("Meh", $"{MehCount.Value}"); + } + } + + public static int RemedyGreat; + public static int RemedyGood; + public static int RemedyOk; + public static int RemedyMeh; + public static int RemedyMiss; + + public HitWindows HitWindows { get; set; } = new ManiaHitWindows(); + + [SettingSource("Perfect Count", SettingControlType = typeof(SettingsNumberBox))] + public Bindable PerfectCount { get; } = new Bindable(); + + [SettingSource("Great Count", SettingControlType = typeof(SettingsNumberBox))] + public Bindable GreatCount { get; } = new Bindable(); + + [SettingSource("Good Count", SettingControlType = typeof(SettingsNumberBox))] + public Bindable GoodCount { get; } = new Bindable(); + + [SettingSource("Ok Count", SettingControlType = typeof(SettingsNumberBox))] + public Bindable OkCount { get; } = new Bindable(); + + [SettingSource("Meh Count", SettingControlType = typeof(SettingsNumberBox))] + public Bindable MehCount { get; } = new Bindable(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var hitObjects = maniaBeatmap.HitObjects.Select(obj => + { + if (obj is Note note) + return new RemedyNote(note); + + if (obj is HoldNote hold) + return new RemedyHoldNote(hold); + + return obj; + }).ToList(); + + maniaBeatmap.HitObjects = hitObjects; + RemedyGreat = PerfectCount.Value ?? 0; + RemedyGood = GreatCount.Value ?? 0; + RemedyOk = GoodCount.Value ?? 0; + RemedyMeh = OkCount.Value ?? 0; + RemedyMiss = MehCount.Value ?? 0; + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + + foreach (var stage in maniaRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) + { + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + column.RegisterPool(10, 50); + } + } + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + HitWindows = new ManiaHitWindows(); + HitWindows.SetDifficulty(difficulty.OverallDifficulty); + + RemedyDrawableNote.HitWindows = HitWindows; + RemedyDrawableHoldNoteHead.HitWindows = HitWindows; + RemedyDrawableHoldNoteTail.HitWindows = HitWindows; + } + + public partial class RemedyDrawableNote : DrawableNote + { + public static HitWindows HitWindows = new ManiaHitWindows(); + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (userTriggered && RemedyGreat > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Perfect))) + { + RemedyGreat--; + ApplyResult(GetCappedResult(HitResult.Perfect)); + } + else if (userTriggered && RemedyGood > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Great))) + { + RemedyGood--; + ApplyResult(GetCappedResult(HitResult.Great)); + } + else if (userTriggered && RemedyOk > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Good))) + { + RemedyOk--; + ApplyResult(GetCappedResult(HitResult.Good)); + } + else if (userTriggered && RemedyMeh > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Ok))) + { + RemedyMeh--; + ApplyResult(GetCappedResult(HitResult.Ok)); + } + else if (userTriggered && RemedyMiss > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Meh)) + && Math.Abs(timeOffset) <= Math.Abs(HitWindows.WindowFor(HitResult.Miss))) + { + RemedyMiss--; + ApplyResult(GetCappedResult(HitResult.Meh)); + } + else if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(timeOffset)) + { + if (RemedyGreat > 0) + { + RemedyGreat--; + ApplyResult(GetCappedResult(HitResult.Perfect)); + } + else if (RemedyGood > 0) + { + RemedyGood--; + ApplyResult(GetCappedResult(HitResult.Great)); + } + else if (RemedyOk > 0) + { + RemedyOk--; + ApplyResult(GetCappedResult(HitResult.Good)); + } + else if (RemedyMeh > 0) + { + RemedyMeh--; + ApplyResult(GetCappedResult(HitResult.Ok)); + } + else if (RemedyMiss > 0) + { + RemedyMiss--; + ApplyResult(GetCappedResult(HitResult.Meh)); + } + else + { + ApplyMinResult(); + } + } + } + else + { + base.CheckForResult(userTriggered, timeOffset); + } + } + } + + public partial class RemedyDrawableHoldNoteHead : DrawableHoldNoteHead + { + public static HitWindows HitWindows = new ManiaHitWindows(); + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (userTriggered && RemedyGreat > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Perfect))) + { + RemedyGreat--; + ApplyResult(GetCappedResult(HitResult.Perfect)); + } + else if (userTriggered && RemedyGood > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Great))) + { + RemedyGood--; + ApplyResult(GetCappedResult(HitResult.Great)); + } + else if (userTriggered && RemedyOk > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Good))) + { + RemedyOk--; + ApplyResult(GetCappedResult(HitResult.Good)); + } + else if (userTriggered && RemedyMeh > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Ok))) + { + RemedyMeh--; + ApplyResult(GetCappedResult(HitResult.Ok)); + } + else if (userTriggered && RemedyMiss > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Meh)) + && Math.Abs(timeOffset) <= Math.Abs(HitWindows.WindowFor(HitResult.Miss))) + { + RemedyMiss--; + ApplyResult(GetCappedResult(HitResult.Meh)); + } + else if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(timeOffset)) + { + if (RemedyGreat > 0) + { + RemedyGreat--; + ApplyResult(GetCappedResult(HitResult.Perfect)); + } + else if (RemedyGood > 0) + { + RemedyGood--; + ApplyResult(GetCappedResult(HitResult.Great)); + } + else if (RemedyOk > 0) + { + RemedyOk--; + ApplyResult(GetCappedResult(HitResult.Good)); + } + else if (RemedyMeh > 0) + { + RemedyMeh--; + ApplyResult(GetCappedResult(HitResult.Ok)); + } + else if (RemedyMiss > 0) + { + RemedyMiss--; + ApplyResult(GetCappedResult(HitResult.Meh)); + } + else + { + ApplyMinResult(); + } + } + } + else + { + base.CheckForResult(userTriggered, timeOffset); + } + } + } + + public partial class RemedyDrawableHoldNoteTail : DrawableHoldNoteTail + { + public static HitWindows HitWindows = new ManiaHitWindows(); + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (HoldNote.IsHolding.Value && userTriggered && RemedyGreat > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Perfect))) + { + RemedyGreat--; + ApplyResult(GetCappedResult(HitResult.Perfect)); + } + else if (HoldNote.IsHolding.Value && userTriggered && RemedyGood > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Great))) + { + RemedyGood--; + ApplyResult(GetCappedResult(HitResult.Great)); + } + else if (HoldNote.IsHolding.Value && userTriggered && RemedyOk > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Good))) + { + RemedyOk--; + ApplyResult(GetCappedResult(HitResult.Good)); + } + else if (HoldNote.IsHolding.Value && userTriggered && RemedyMeh > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Ok))) + { + RemedyMeh--; + ApplyResult(GetCappedResult(HitResult.Ok)); + } + else if (HoldNote.IsHolding.Value && userTriggered && RemedyMiss > 0 && Math.Abs(timeOffset) > Math.Abs(HitWindows.WindowFor(HitResult.Meh)) + && Math.Abs(timeOffset) <= Math.Abs(HitWindows.WindowFor(HitResult.Miss))) + { + RemedyMiss--; + ApplyResult(GetCappedResult(HitResult.Meh)); + } + else if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(timeOffset)) + { + if (RemedyGreat > 0) + { + RemedyGreat--; + ApplyResult(GetCappedResult(HitResult.Perfect)); + } + else if (RemedyGood > 0) + { + RemedyGood--; + ApplyResult(GetCappedResult(HitResult.Great)); + } + else if (RemedyOk > 0) + { + RemedyOk--; + ApplyResult(GetCappedResult(HitResult.Good)); + } + else if (RemedyMeh > 0) + { + RemedyMeh--; + ApplyResult(GetCappedResult(HitResult.Ok)); + } + else if (RemedyMiss > 0) + { + RemedyMiss--; + ApplyResult(GetCappedResult(HitResult.Meh)); + } + else + { + ApplyMinResult(); + } + } + } + else + { + base.CheckForResult(userTriggered, timeOffset); + } + } + } + + private class RemedyNote : Note + { + public RemedyNote(Note note) + { + StartTime = note.StartTime; + Column = note.Column; + Samples = note.Samples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + } + } + + private class RemedyHeadNote : HeadNote + { + } + + private class RemedyTailNote : TailNote + { + } + + private class RemedyHoldNote : HoldNote + { + public RemedyHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new RemedyHeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0), + }); + + AddNested(Tail = new RemedyTailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1), + }); + + AddNested(Body = new HoldNoteBody + { + StartTime = StartTime, + Column = Column + }); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ModStarRatingRebirth.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ModStarRatingRebirth.cs new file mode 100644 index 0000000000..e18a50e85e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/ModStarRatingRebirth.cs @@ -0,0 +1,1668 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class ModStarRatingRebirth : Mod, IApplicableAfterBeatmapConversion + { + public override string Name => "Star Rating Rebirth"; + + public override string Acronym => "SR"; + + public override LocalisableString Description => EzManiaModStrings.StarRatingRebirth_Description; + + public override double ScoreMultiplier => 1; + + public override bool Ranked => false; + public override bool ValidForMultiplayer => true; + public override bool ValidForFreestyleAsRequiredMod => false; + + public override ModType Type => ModType.YuLiangSSS_Mod; + + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription + { + get + { + if (Original.Value) yield return ("Original OD", "On"); + + if (!Original.Value && Custom.Value) + { + yield return ("Custom OD", "On"); + yield return ("OD", $"{OD.Value}"); + } + } + } + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.UseOriginalOD_Label), nameof(EzManiaModStrings.UseOriginalOD_Description))] + public BindableBool Original { get; set; } = new BindableBool(false); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.UseCustomOD_Label), nameof(EzManiaModStrings.UseCustomOD_Description))] + public BindableBool Custom { get; set; } = new BindableBool(); + + [SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.OD_Label), nameof(EzManiaModStrings.OD_Description))] + public BindableDouble OD { get; set; } = new BindableDouble(0) + { + Precision = 0.1, + MinValue = 0, + MaxValue = 15 + }; + + public void ApplyToBeatmap(IBeatmap beatmap) + { } + + public class EasyObject + { + public int Head; + public int Tail; + public int Key; + + public bool IsLong => Head != Tail; + + public EasyObject(double startTime, double endTime, int column) + { + Head = (int)startTime; + Tail = (int)endTime; + Key = column; + } + + public static EasyObject[] FromManiaObjects(List objects) + { + var easyObjects = new EasyObject[objects.Count]; + for (int i = 0; i < objects.Count; i++) easyObjects[i] = new EasyObject(objects[i].StartTime, objects[i].GetEndTime(), objects[i].Column); + + return easyObjects; + } + + public static EasyObject[] FromRate(EasyObject[] objects, double rate) + { + for (int i = 0; i < objects.Length; i++) + { + objects[i].Head = (int)(objects[i].Head / rate); + objects[i].Tail = (int)(objects[i].Tail / rate); + } + + return objects; + } + } + + public static double CalculateStarRating(List objects, double od, int keys, double rate) + { + // return StarRatingRebirthCalculateForLazer.CalculateStarRatingForLazer(objects, od, keys, rate); + + var hit = EasyObject.FromManiaObjects(objects); + if (rate != 1) hit = EasyObject.FromRate(hit, rate); + + PreProcess(hit, keys, od, out double x, out int key, out int time, + out var noteSeq, out var noteSeqByCol, + out var lnSeq, out var tailSeq, out var lnSeqByColumn); + + GetCorners(noteSeq, time, out int[] baseCorners, out int[] aCorners, out int[] allCorners); + + // 对于每一列,存储其使用情况(在150ms内是否非空)。例如:keyUsage[k, i] + bool[,] keyUsage = GetKeyUsage(noteSeq, key, time, baseCorners); + + // 在base_corners的每个时间点,构建活跃列的列表 + int[][] activeColumns = Enumerable.Range(0, baseCorners.Length) + .Select(i => Enumerable.Range(0, key) + .Where(k => keyUsage[k, i]) + .ToArray()) + .ToArray(); + + double[,] keyUsage400 = GetKeyUsage400(noteSeq, key, time, baseCorners); + + double[] anchor = ComputeAnchor(key, keyUsage400, baseCorners); + + ComputeJbar(key, time, x, noteSeqByCol, baseCorners, out double[,] deltaKs, out double[] jBar); + double[] jBarInterp = Utils.InterpValues(allCorners, baseCorners, jBar); + + double[] xBar = ComputeXbar(key, time, x, noteSeqByCol, activeColumns, baseCorners); + double[] xBarInterp = Utils.InterpValues(allCorners, baseCorners, xBar); + + // 构建LN主体的稀疏表示 + LNBodiesCountSparseRepresentation(lnSeq, time, out int[] points, out double[] cumsum, out double[] values); + + double[] pBar = ComputePbar(key, time, x, noteSeq, points, cumsum, values, anchor, baseCorners); + double[] pBarInterp = Utils.InterpValues(allCorners, baseCorners, pBar); + + double[] aBar = ComputeAbar(key, time, x, noteSeqByCol, activeColumns, deltaKs, aCorners, baseCorners); + double[] aBarInterp = Utils.InterpValues(allCorners, aCorners, aBar); + + double[] rBar = ComputeRbar(key, time, x, noteSeqByCol, tailSeq, baseCorners); + double[] rBarInterp = Utils.InterpValues(allCorners, baseCorners, rBar); + + ComputeCAndKs(key, time, noteSeq, keyUsage, baseCorners, out double[] cStep, out double[] ksStep); + double[] cArr = Utils.StepInterp(allCorners, baseCorners, cStep); + double[] ksArr = Utils.StepInterp(allCorners, baseCorners, ksStep); + + // === 最终计算 === + // 在all_corners上计算难度D + double[] sAll = new double[allCorners.Length]; + double[] tAll = new double[allCorners.Length]; + double[] dAll = new double[allCorners.Length]; + + for (int i = 0; i < allCorners.Length; i++) + { + double term1 = Math.Pow(aBarInterp[i], 3 / ksArr[i]) * Math.Min(jBarInterp[i], 8 + 0.85 * jBarInterp[i]); + double term2 = Math.Pow(aBarInterp[i], 2.0 / 3.0) * (0.8 * pBarInterp[i] + rBarInterp[i] * 35 / (cArr[i] + 8)); + + sAll[i] = Math.Pow(0.4 * Math.Pow(term1, 1.5) + (1 - 0.4) * Math.Pow(term2, 1.5), 2.0 / 3.0); + tAll[i] = Math.Pow(aBarInterp[i], 3 / ksArr[i]) * xBarInterp[i] / (xBarInterp[i] + sAll[i] + 1); + dAll[i] = 2.7 * Math.Pow(sAll[i], 0.5) * Math.Pow(tAll[i], 1.5) + sAll[i] * 0.27; + } + + // 计算连续时间之间的间隔 + double[] gaps = new double[allCorners.Length]; + gaps[0] = (allCorners[1] - allCorners[0]) / 2.0; + gaps[^1] = (allCorners[^1] - allCorners[^2]) / 2.0; + for (int i = 1; i < allCorners.Length - 1; i++) gaps[i] = (allCorners[i + 1] - allCorners[i - 1]) / 2.0; + + // 每个拐角的有效权重是其密度和间隔的乘积 + double[] effectiveWeights = new double[allCorners.Length]; + for (int i = 0; i < allCorners.Length; i++) effectiveWeights[i] = cArr[i] * gaps[i]; + + // 按难度D排序 + int[] sortedIndices = Enumerable.Range(0, allCorners.Length) + .OrderBy(i => dAll[i]) + .ToArray(); + + double[] dSorted = sortedIndices.Select(i => dAll[i]).ToArray(); + double[] wSorted = sortedIndices.Select(i => effectiveWeights[i]).ToArray(); + + // 计算有效权重的累积和 + double[] cumWeights = new double[wSorted.Length]; + cumWeights[0] = wSorted[0]; + for (int i = 1; i < wSorted.Length; i++) cumWeights[i] = cumWeights[i - 1] + wSorted[i]; + double totalWeight = cumWeights[^1]; + + double[] normCumWeights = cumWeights.Select(w => w / totalWeight).ToArray(); + + // 计算目标百分位 + double[] targetPercentiles = { 0.945, 0.935, 0.925, 0.915, 0.845, 0.835, 0.825, 0.815 }; + int[] indices = new int[targetPercentiles.Length]; + + for (int i = 0; i < targetPercentiles.Length; i++) + { + indices[i] = Array.FindIndex(normCumWeights, w => w >= targetPercentiles[i]); + if (indices[i] < 0) indices[i] = normCumWeights.Length - 1; + } + + double percentile93 = (dSorted[indices[0]] + dSorted[indices[1]] + dSorted[indices[2]] + dSorted[indices[3]]) / 4.0; + double percentile83 = (dSorted[indices[4]] + dSorted[indices[5]] + dSorted[indices[6]] + dSorted[indices[7]]) / 4.0; + + // 计算加权平均值 + double weightedSum = 0; + double weightSum = 0; + + for (int i = 0; i < dSorted.Length; i++) + { + weightedSum += Math.Pow(dSorted[i], 5) * wSorted[i]; + weightSum += wSorted[i]; + } + + double weightedMean = Math.Pow(weightedSum / weightSum, 1.0 / 5.0); + + // 最终SR计算 + double sr = 0.88 * percentile93 * 0.25 + 0.94 * percentile83 * 0.2 + weightedMean * 0.55; + sr = Math.Pow(sr, 1.0) / Math.Pow(8, 1.0) * 8; + + double totalNotes = noteSeq.Length + 0.5 * lnSeq.Sum(ln => Math.Min(ln.Tail - ln.Head, 1000) / 200.0); + sr *= totalNotes / (totalNotes + 60); + + sr = Utils.RescaleHigh(sr); + sr *= 0.975; + + return sr; + } + + internal static void ComputeCAndKs(int K, + int T, + EasyObject[] noteSeq, + bool[,] keyUsage, + int[] baseCorners, + out double[] cStep, + out double[] ksStep) + { + int cornersLength = baseCorners.Length; + + // 提取所有音符的命中时间并排序 + int[] noteHitTimes = noteSeq.Select(n => n.Head).OrderBy(t => t).ToArray(); + + // 初始化 C_step 数组 + cStep = new double[cornersLength]; + + // 计算每个基准点的 C(s):500ms 内的音符数量 + for (int i = 0; i < cornersLength; i++) + { + int s = baseCorners[i]; + int low = s - 500; + int high = s + 500; + + // 使用二分查找计算区间内的音符数量 + int highIdx = Utils.SearchSortedLeft(noteHitTimes, high); + int lowIdx = Utils.SearchSortedLeft(noteHitTimes, low); + + cStep[i] = highIdx - lowIdx; + } + + // 计算 Ks:本地按键使用计数(最小为1) + ksStep = new double[cornersLength]; + + for (int i = 0; i < cornersLength; i++) + { + int count = 0; + + for (int k = 0; k < K; k++) + { + if (keyUsage[k, i]) + count++; + } + + ksStep[i] = Math.Max(count, 1); + } + } + + internal static double[] ComputePbar(int K, int T, double x, EasyObject[] noteSeq, int[] points, double[] cumsum, double[] values, double[] anchor, int[] baseCorners) + { + int cornersLength = baseCorners.Length; + double[] pStep = new double[cornersLength]; + + // stream_booster 函数的C#实现 + static double streamBooster(double delta) + { + double ratio = 7.5 / delta; + return 160 < ratio && ratio < 360 ? 1 + 1.7e-7 * (ratio - 160) * Math.Pow(ratio - 360, 2) : 1; + } + + for (int i = 0; i < noteSeq.Length - 1; i++) + { + int hL = noteSeq[i].Head; + int hR = noteSeq[i + 1].Head; + int deltaTime = hR - hL; + + if (deltaTime < 1e-9) + { + // 狄拉克增量情况:当音符同时出现时 + // 在基准网格中的音符头部精确位置添加尖峰 + double spike = 1000 * Math.Pow(0.02 * (4 / x - 24), 1.0 / 4); + int leftIdx = Utils.SearchSortedLeft(baseCorners, hL); + int rightIdx = Utils.SearchSortedRight(baseCorners, hL); + + for (int idx = leftIdx; idx < rightIdx; idx++) pStep[idx] += spike; + } + else + { + // 对于delta_time > 0的常规情况,找出[h_l, h_r)范围内的基准网格索引 + int leftIdx = Utils.SearchSortedLeft(baseCorners, hL); + int rightIdx = Utils.SearchSortedLeft(baseCorners, hR); + + if (leftIdx >= rightIdx) continue; + + double delta = 0.001 * deltaTime; + double v = 1 + 6 * 0.001 * LNSum(hL, hR, points, cumsum, values); + double bVal = streamBooster(delta); + + double inc; + if (delta < 2 * x / 3) + inc = 1 / delta * Math.Pow(0.08 / x * (1 - 24 / x * Math.Pow(delta - x / 2, 2)), 1.0 / 4) * Math.Max(bVal, v); + else + inc = 1 / delta * Math.Pow(0.08 / x * (1 - 24 / x * Math.Pow(x / 6, 2)), 1.0 / 4) * Math.Max(bVal, v); + + for (int idx = leftIdx; idx < rightIdx; idx++) pStep[idx] += Math.Min(inc * anchor[idx], Math.Max(inc, inc * 2 - 10)); + } + } + + double[] pBar = Utils.SmoothOnCorners(baseCorners, pStep, 500, 0.001, "sum"); + return pBar; + } + + internal static double[] ComputeAbar(int K, int T, double x, EasyObject[][] noteSeqByColumn, int[][] activeColumns, double[,] deltaKs, int[] aCorners, int[] baseCorners) + { + int cornersLength = baseCorners.Length; + + // 初始化dks数据结构,对应Python中的字典 + double[,] dks = new double[K - 1, cornersLength]; + + // 填充dks数组 + for (int i = 0; i < cornersLength; i++) + { + int[] cols = activeColumns[i]; + + for (int j = 0; j < cols.Length - 1; j++) + { + int k0 = cols[j]; + int k1 = cols[j + 1]; + + // 使用之前在base_corners上计算的delta_ks + dks[k0, i] = Math.Abs(deltaKs[k0, i] - deltaKs[k1, i]) + 0.4 * Math.Max(0, Math.Max(deltaKs[k0, i], deltaKs[k1, i]) - 0.11); + } + } + + // 初始化A_step数组,全部填充为1 + double[] aStep = new double[aCorners.Length]; + for (int i = 0; i < aCorners.Length; i++) aStep[i] = 1.0; + + // 修改A_step + for (int i = 0; i < aCorners.Length; i++) + { + int s = aCorners[i]; + int idx = Utils.SearchSortedLeft(baseCorners, s); + + // 确保idx在有效范围内 + if (idx >= baseCorners.Length) idx = baseCorners.Length - 1; + + int[] cols = activeColumns[idx]; + + for (int j = 0; j < cols.Length - 1; j++) + { + int k0 = cols[j]; + int k1 = cols[j + 1]; + double dVal = dks[k0, idx]; + + if (dVal < 0.02) + aStep[i] *= Math.Min(0.75 + 0.5 * Math.Max(deltaKs[k0, idx], deltaKs[k1, idx]), 1); + else if (dVal < 0.07) aStep[i] *= Math.Min(0.65 + 5 * dVal + 0.5 * Math.Max(deltaKs[k0, idx], deltaKs[k1, idx]), 1); + // 否则保持A_step[i]不变 + } + } + + // 对A_step应用平滑操作得到Abar + double[] aBar = Utils.SmoothOnCorners(aCorners, aStep, 250, mode: "avg"); + return aBar; + } + + internal static double[] ComputeRbar(int K, int T, double x, EasyObject[][] noteSeqByColumn, EasyObject[] tailSeq, int[] baseCorners) + { + int cornersLength = baseCorners.Length; + double[] iArr = new double[cornersLength]; + double[] rStep = new double[cornersLength]; + + int[][] timesByColumn = new int[noteSeqByColumn.Length][]; + for (int i = 0; i < noteSeqByColumn.Length; i++) timesByColumn[i] = noteSeqByColumn[i].Select(note => note.Head).ToArray(); + + // 计算释放指数(Release Index) + var iList = new List(); + + for (int i = 0; i < tailSeq.Length; i++) + { + int k = tailSeq[i].Key; + int hI = tailSeq[i].Head; + int tI = tailSeq[i].Tail; + + // 找到同一列中的下一个音符 + var nextNote = Utils.FindNextNoteInColumn(tailSeq[i], timesByColumn[k], noteSeqByColumn); + int hJ = nextNote.Head; + + double iH = 0.001 * Math.Abs(tI - hI - 80) / x; + double iT = 0.001 * Math.Abs(hJ - tI - 80) / x; + + iList.Add(2 / (2 + Math.Exp(-5 * (iH - 0.75)) + Math.Exp(-5 * (iT - 0.75)))); + } + + // 对相邻尾音时间之间的每个区间,分配 I 和 R + for (int i = 0; i < tailSeq.Length - 1; i++) + { + int tStart = tailSeq[i].Tail; + int tEnd = tailSeq[i + 1].Tail; + + int leftIdx = Utils.SearchSortedLeft(baseCorners, tStart); + int rightIdx = Utils.SearchSortedLeft(baseCorners, tEnd); + + if (leftIdx >= rightIdx) continue; + + // 设置这个区间内所有索引的值 + for (int idx = leftIdx; idx < rightIdx; idx++) + { + iArr[idx] = 1 + iList[i]; + double deltaR = 0.001 * (tailSeq[i + 1].Tail - tailSeq[i].Tail); + rStep[idx] = 0.08 * Math.Pow(deltaR, -0.5) * (1 / x) * (1 + 0.8 * (iList[i] + iList[i + 1])); + } + } + + // 应用平滑操作得到Rbar + double[] rBar = Utils.SmoothOnCorners(baseCorners, rStep, 500, 0.001, "sum"); + return rBar; + } + + internal static void LNBodiesCountSparseRepresentation(EasyObject[] lnSeq, + int T, + out int[] points, + out double[] cumsum, + out double[] values) + { + // 字典:索引 -> 长音符体变化量(转换前) + var diff = new Dictionary(); + + foreach (var ln in lnSeq) + { + int t0 = Math.Min(ln.Head + 60, ln.Tail); + int t1 = Math.Min(ln.Head + 120, ln.Tail); + + diff[t0] = diff.GetValueOrDefault(t0, 0) + 1.3; + diff[t1] = diff.GetValueOrDefault(t1, 0) + (-1.3 + 1); // t1处的净变化:从第一部分的-1.3,然后+1 + diff[ln.Tail] = diff.GetValueOrDefault(ln.Tail, 0) - 1; + } + + // 断点是变化发生的时间点 + points = new[] { 0, T }.Concat(diff.Keys).Distinct().OrderBy(p => p).ToArray(); + + // 构建分段常量值(转换后)和累积和 + values = new double[points.Length - 1]; + cumsum = new double[points.Length]; + cumsum[0] = 0; // 断点处的累积和 + double curr = 0.0; + + for (int i = 0; i < points.Length - 1; i++) + { + int t = points[i]; + // 如果在t处有变化,更新运行值 + if (diff.TryGetValue(t, out double value)) curr += value; + + double v = Math.Min(curr, 2.5 + 0.5 * curr); + values[i] = v; + // 计算区间[points[i], points[i+1])上的累积和 + int segLength = points[i + 1] - points[i]; + cumsum[i + 1] = cumsum[i] + segLength * v; + } + } + + private static bool contains(int[] array, int value) + { + if (array == null) return false; + + foreach (int item in array) + { + if (item == value) + return true; + } + + return false; + } + + internal static double[] ComputeXbar(int K, int T, double x, EasyObject[][] noteSeqByColumn, int[][] activeColumns, int[] baseCorners) + { + // 创建交叉矩阵 + double[][] crossMatrix = + [ + [-1], + [0.075, 0.075], + [0.125, 0.05, 0.125], + [0.125, 0.125, 0.125, 0.125], + [0.175, 0.25, 0.05, 0.25, 0.175], + [0.175, 0.25, 0.175, 0.175, 0.25, 0.175], + [0.225, 0.35, 0.25, 0.05, 0.25, 0.35, 0.225], + [0.225, 0.35, 0.25, 0.225, 0.225, 0.25, 0.35, 0.225], + [0.275, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.275], + [0.275, 0.45, 0.35, 0.25, 0.275, 0.275, 0.25, 0.35, 0.45, 0.275], + [0.325, 0.55, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.55, 0.325] + ]; + + int cornersLength = baseCorners.Length; + + // 初始化 X_ks 和 fast_cross + double[,] xKs = new double[K + 1, cornersLength]; + double[,] fastCross = new double[K + 1, cornersLength]; + + // 获取交叉系数 + double[] crossCoeff = crossMatrix[K]; + + // 计算每列的 X_ks 和 fast_cross 值 + for (int k = 0; k <= K; k++) + { + // 根据不同情况选择要处理的音符 + EasyObject[] notesInPair; + + if (k == 0) + notesInPair = noteSeqByColumn[0]; + else if (k == K) + notesInPair = noteSeqByColumn[K - 1]; + else + { + // 合并两列的音符并按时间排序 + var mergedNotes = new List(); + mergedNotes.AddRange(noteSeqByColumn[k - 1]); + mergedNotes.AddRange(noteSeqByColumn[k]); + notesInPair = mergedNotes.OrderBy(n => n.Head).ToArray(); + } + + // 处理相邻音符对 + for (int i = 1; i < notesInPair.Length; i++) + { + int start = notesInPair[i - 1].Head; + int end = notesInPair[i].Head; + + int idxStart = Utils.SearchSortedLeft(baseCorners, start); + int idxEnd = Utils.SearchSortedLeft(baseCorners, end); + + if (idxStart >= idxEnd) continue; + + double delta = 0.001 * (end - start); + double val = 0.16 * Math.Pow(Math.Max(x, delta), -2); + + // 检查活跃列条件 + bool condition1 = !contains(activeColumns[idxStart], k - 1) && !contains(activeColumns[idxEnd], k - 1); + bool condition2 = !contains(activeColumns[idxStart], k) && !contains(activeColumns[idxEnd], k); + + if (condition1 || condition2) val *= 1 - crossCoeff[k]; + + // 设置值 + for (int idx = idxStart; idx < idxEnd; idx++) + { + xKs[k, idx] = val; + fastCross[k, idx] = Math.Max(0, 0.4 * Math.Pow(Math.Max(delta, Math.Max(0.06, 0.75 * x)), -2) - 80); + } + } + } + + // 计算 X_base + double[] xBase = new double[cornersLength]; + + for (int i = 0; i < cornersLength; i++) + { + // 第一部分:xKs 的加权和 + double sum1 = 0; + for (int k = 0; k <= K; k++) sum1 += xKs[k, i] * crossCoeff[k]; + + // 第二部分:fastCross 的平方根乘积和 + double sum2 = 0; + for (int k = 0; k < K; k++) sum2 += Math.Sqrt(fastCross[k, i] * crossCoeff[k] * fastCross[k + 1, i] * crossCoeff[k + 1]); + + xBase[i] = sum1 + sum2; + } + + // 应用平滑操作得到 Xbar + double[] xBar = Utils.SmoothOnCorners(baseCorners, xBase, 500, 0.001, "sum"); + return xBar; + } + + internal static void ComputeJbar(int K, + int T, + double x, + EasyObject[][] noteSeqByColumn, + int[] baseCorners, + out double[,] deltaKs, + out double[] jBar) + { + int cornersLength = baseCorners.Length; + + double[,] jKs = new double[K, cornersLength]; + deltaKs = new double[K, cornersLength]; + + for (int k = 0; k < K; k++) + { + for (int i = 0; i < cornersLength; i++) deltaKs[k, i] = 1e9; + } + + static double jackNerfer(double delta) => 1 - 7e-5 * Math.Pow(0.15 + Math.Abs(delta - 0.08), -4); + + for (int k = 0; k < K; k++) + { + var notes = noteSeqByColumn[k]; + + for (int i = 0; i < notes.Length - 1; i++) + { + int start = notes[i].Head; + int end = notes[i + 1].Head; + + int leftIdx = Utils.SearchSortedLeft(baseCorners, start); + int rightIdx = Utils.SearchSortedLeft(baseCorners, end); + + if (leftIdx >= rightIdx) continue; + + double delta = 0.001 * (end - start); + double val = 1 / delta * (1 / (delta + 0.11 * Math.Pow(x, 1.0 / 4))); + double jVal = val * jackNerfer(delta); + + for (int idx = leftIdx; idx < rightIdx; idx++) + { + jKs[k, idx] = jVal; + deltaKs[k, idx] = delta; + } + } + } + + // 对每列的J_ks进行平滑处理 + double[,] jBarKs = new double[K, cornersLength]; + + for (int k = 0; k < K; k++) + { + double[] rowData = new double[cornersLength]; + for (int i = 0; i < cornersLength; i++) rowData[i] = jKs[k, i]; + + double[] smoothed = Utils.SmoothOnCorners(baseCorners, rowData, 500, 0.001, "sum"); + + for (int i = 0; i < cornersLength; i++) jBarKs[k, i] = smoothed[i]; + } + + // 使用加权平均聚合各列 + jBar = new double[cornersLength]; + + for (int i = 0; i < cornersLength; i++) + { + double num = 0.0; + double den = 0.0; + + for (int k = 0; k < K; k++) + { + double v = jBarKs[k, i]; + double w = 1 / deltaKs[k, i]; + + num += Math.Pow(Math.Max(v, 0), 5) * w; + den += w; + } + + jBar[i] = num / Math.Max(1e-9, den); + jBar[i] = Math.Pow(jBar[i], 1.0 / 5); + } + } + + internal static double[] ComputeAnchor(int K, double[,] keyUsage400, int[] baseCorners) + { + double[] anchor = new double[baseCorners.Length]; + + for (int idx = 0; idx < baseCorners.Length; idx++) + { + var nonzeroCounts = Enumerable.Range(0, K) + .Select(k => keyUsage400[k, idx]) + .OrderByDescending(c => c) + .Where(c => c != 0) + .ToList(); + + if (nonzeroCounts.Count > 1) + { + double walk = 0; + double maxWalk = 0; + + for (int i = 0; i < nonzeroCounts.Count - 1; i++) + { + double ratio = nonzeroCounts[i + 1] / nonzeroCounts[i]; + walk += nonzeroCounts[i] * (1 - 4 * Math.Pow(0.5 - ratio, 2)); + maxWalk += nonzeroCounts[i]; + } + + anchor[idx] = walk / maxWalk; + } + else + anchor[idx] = 0; + } + + for (int i = 0; i < anchor.Length; i++) anchor[i] = 1 + Math.Min(anchor[i] - 0.18, 5 * Math.Pow(anchor[i] - 0.22, 3)); + return anchor; + } + + internal static double[,] GetKeyUsage400(EasyObject[] noteSeq, int K, int T, int[] baseCorners) + { + double[,] keyUsage400 = new double[K, baseCorners.Length]; + + foreach (var note in noteSeq) + { + int startTime = Math.Max(note.Head, 0); + int endTime = !note.IsLong ? note.Head : Math.Min(note.Tail, T - 1); + int left400Idx = Utils.SearchSortedLeft(baseCorners, startTime - 400); + int leftIdx = Utils.SearchSortedLeft(baseCorners, startTime); + int rightIdx = Utils.SearchSortedLeft(baseCorners, endTime); + int right400Idx = Utils.SearchSortedLeft(baseCorners, endTime + 400); + for (int i = leftIdx; i < rightIdx; i++) keyUsage400[note.Key, i] += 3.75 + Math.Min(endTime - startTime, 1500) / 150.0; + + for (int i = left400Idx; i < leftIdx; i++) + { + double diff = baseCorners[i] - startTime; + keyUsage400[note.Key, i] += 3.75 - 3.75 / (400.0 * 400.0) * diff * diff; + } + + for (int i = rightIdx; i < right400Idx; i++) + { + double diff = baseCorners[i] - endTime; + keyUsage400[note.Key, i] += 3.75 - 3.75 / (400.0 * 400.0) * diff * diff; + } + } + + return keyUsage400; + } + + internal static double LNSum(int a, int b, int[] points, double[] cumsum, double[] values) + { + // 定位包含 a 和 b 的分段 + int i = Utils.SearchSortedRight(points, a) - 1; + int j = Utils.SearchSortedRight(points, b) - 1; + + double total = 0.0; + + if (i == j) + { + // a 和 b 在同一分段内 + total = (b - a) * values[i]; + } + else + { + // 第一个分段:从 a 到第 i 个分段的末尾 + total += (points[i + 1] - a) * values[i]; + // i+1 到 j-1 之间的完整分段 + total += cumsum[j] - cumsum[i + 1]; + // 最后一个分段:从第 j 个分段的开始到 b + total += (b - points[j]) * values[j]; + } + + return total; + } + + internal static bool[,] GetKeyUsage(EasyObject[] noteSeq, int K, int T, int[] baseCorners) + { + bool[,] keyUsage = new bool[K, baseCorners.Length]; + + foreach (var note in noteSeq) + { + int startTime = Math.Max(note.Head - 150, 0); + int endTime = !note.IsLong ? note.Head + 150 : Math.Min(note.Tail + 150, T - 1); + int leftIdx = Utils.SearchSortedLeft(baseCorners, startTime); + int rightIdx = Utils.SearchSortedLeft(baseCorners, endTime); + for (int i = leftIdx; i < rightIdx; i++) keyUsage[note.Key, i] = true; + } + + return keyUsage; + } + + internal static void GetCorners(EasyObject[] noteSeq, int T, out int[] baseCorners, out int[] aCorners, out int[] allCorners) + { + var baseSet = new HashSet(); + + foreach (var note in noteSeq) + { + baseSet.Add(note.Head); + if (note.IsLong) baseSet.Add(note.Tail); + } + + foreach (int s in baseSet.ToArray()) + { + baseSet.Add(s + 501); + baseSet.Add(s - 499); + baseSet.Add(s + 1); // 在音符确切位置解决狄拉克增量问题 + } + + baseSet.Add(0); + baseSet.Add(T); + baseCorners = baseSet + .Where(s => 0 <= s && s <= T) + .OrderBy(s => s) + .ToArray(); + + var aSet = new HashSet(); + + foreach (var note in noteSeq) + { + aSet.Add(note.Head); + if (note.IsLong) aSet.Add(note.Tail); + } + + foreach (int s in aSet.ToArray()) + { + aSet.Add(s + 1000); + aSet.Add(s - 1000); + } + + aSet.Add(0); + aSet.Add(T); + aCorners = aSet + .Where(s => 0 <= s && s <= T) + .OrderBy(s => s) + .ToArray(); + + allCorners = baseCorners.Union(aCorners) + .OrderBy(s => s) + .ToArray(); + } + + internal static void PreProcess(EasyObject[] objects, + int keys, + double od, + out double x, + out int K, + out int T, + out EasyObject[] noteSeq, + out EasyObject[][] noteSeqByCol, + out EasyObject[] lnSeq, + out EasyObject[] tailSeq, + out EasyObject[][] lnSeqByCol) + { + K = keys; + x = 0.3 * Math.Pow((64.5 - Math.Ceiling(od * 3)) / 500, 0.5); + x = Math.Min(x, 0.6 * (x - 0.09) + 0.09); + + // 排序一次,后续复用这个排序结果 + noteSeq = objects.OrderBy(n => n.Head).ThenBy(n => n.Key).ToArray(); + + // 创建包含 K 个空列表的数组 + var noteColumns = new List[K]; + var lnColumns = new List[K]; + var lnNotes = new List(); + + // 初始化所有列表 + for (int k = 0; k < K; k++) + { + noteColumns[k] = new List(); + lnColumns[k] = new List(); + } + + // 单次遍历,同时填充 noteColumns 和 lnColumns + foreach (var note in noteSeq) + { + int key = note.Key; + + if (key >= 0 && key < K) // 确保 key 在有效范围内 + { + noteColumns[key].Add(note); + + if (note.IsLong) + { + lnNotes.Add(note); + lnColumns[key].Add(note); + } + } + } + + // 将列表转换为数组 + noteSeqByCol = new EasyObject[K][]; + lnSeqByCol = new EasyObject[K][]; + + for (int k = 0; k < K; k++) + { + noteSeqByCol[k] = noteColumns[k].ToArray(); + lnSeqByCol[k] = lnColumns[k].ToArray(); + } + + // 获取长音符数组和按尾部时间排序的长音符数组 + lnSeq = lnNotes.ToArray(); + tailSeq = lnSeq.OrderBy(n => n.Tail).ToArray(); + + // 计算总时长 T + if (tailSeq.Length > 0) + T = Math.Max(noteSeq[^1].Head, tailSeq[^1].Tail) + 1; + else + T = noteSeq[^1].Head + 1; + } + + /// + /// Return null if style is wrong. + /// + /// + /// + /// + /// + public static List? NTM(List objects, int keys, int cs) + { + var Rng = new Random(); + + var newObjects = new List(); + + var newColumnObjects = new List(); + + var fixedColumnObjects = new List(); + + var locations = objects.OfType().Select(n => ( + startTime: n.StartTime, + samples: n.Samples, + column: n.Column, + endTime: n.StartTime, + duration: n.StartTime - n.StartTime + )) + .Concat(objects.OfType().Select(h => ( + startTime: h.StartTime, + samples: h.Samples, + column: h.Column, + endTime: h.EndTime, + duration: h.EndTime - h.StartTime + ))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList(); + + int keyvalue = keys + 1; + bool firstKeyFlag = true; + + int nullColumn = Rng.Next(-1, 1 + keyvalue - 2); + + while (keyvalue <= keys) + { + var confirmnull = new List(); + for (int i = 0; i <= keys; i++) confirmnull.Add(false); + var nullcolumnlist = new List(); + + if (firstKeyFlag) + { + foreach (var column in objects.GroupBy(h => h.Column)) + { + int count = column.Count(); + if (!confirmnull[column.Key] && count != 0) confirmnull[column.Key] = true; + } + + for (int i = 0; i < keys; i++) + { + if (!confirmnull[i]) + nullcolumnlist.Add(i); + } + + firstKeyFlag = false; + } + + int atLeast = 5; + + double changetime = 0; + + bool plus = true; + bool minus = false; + bool next = false; + + for (int i = 0; i < locations.Count; i++) + { + bool isLN = false; + var note = new Note(); + var hold = new HoldNote(); + int columnnum = locations[i].column; + int minuscolumn = 0; + + foreach (int nul in nullcolumnlist) + { + if (columnnum > nul) + minuscolumn++; + } + + columnnum -= minuscolumn; + int testcolumn = columnnum; + atLeast--; + + if (locations[i].startTime == locations[i].endTime) + { + note.StartTime = locations[i].startTime; + note.Samples = locations[i].samples; + } + else + { + hold.StartTime = locations[i].startTime; + hold.Samples = locations[i].samples; + hold.EndTime = locations[i].endTime; + isLN = true; + } + + bool error = changetime != locations[i].startTime; + + if (keys < 4) + columnnum = Rng.Next(keyvalue); + else + { + if (error && Rng.Next(100) < 70 /*Probability.Value*/ && atLeast < 0) + { + changetime = locations[i].startTime; + atLeast = keys - 2; + next = true; + } + + if (next && plus) + { + next = false; + nullColumn++; + + if (nullColumn > keyvalue - 2) + { + plus = !plus; + minus = !minus; + nullColumn = keyvalue - 2; + } + } + else if (next && minus) + { + next = false; + nullColumn--; + + if (nullColumn < -1) + { + plus = !plus; + minus = !minus; + nullColumn = -1; + } + } + + if (columnnum > nullColumn) columnnum++; + } + + bool overlap = FindOverlap(newColumnObjects, columnnum, locations[i].startTime, locations[i].endTime); + + if (overlap) + { + for (int k = 0; k < keyvalue; k++) + { + if (!FindOverlap(newColumnObjects, columnnum - k, locations[i].startTime, locations[i].endTime) && columnnum - k >= 0) + columnnum -= k; + else if (!FindOverlap(newColumnObjects, columnnum + k, locations[i].startTime, locations[i].endTime) && columnnum + k <= keyvalue - 1) columnnum += k; + } + } + + if (isLN) + { + newColumnObjects.Add(new HoldNote + { + Column = columnnum, + StartTime = locations[i].startTime, + Duration = locations[i].endTime - locations[i].startTime, + NodeSamples = [locations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = columnnum, + StartTime = locations[i].startTime, + Samples = locations[i].samples + }); + } + } + + for (int i = 0; i < newColumnObjects.Count; i++) + { + bool overlap = false, outindex = false; + + if (newColumnObjects[i].Column < 0 || newColumnObjects[i].Column > keys - 1) + { + outindex = true; + newColumnObjects[i].Column = Rng.Next(keys - 1); + } + + for (int j = i + 1; j < newColumnObjects.Count; j++) + { + if (newColumnObjects[i].Column == newColumnObjects[j].Column && newColumnObjects[i].StartTime >= newColumnObjects[j].StartTime - 2 + && newColumnObjects[i].StartTime <= newColumnObjects[j].StartTime + 2) overlap = true; + + if (newColumnObjects[j].StartTime != newColumnObjects[j].GetEndTime()) + { + if (newColumnObjects[i].Column == newColumnObjects[j].Column && newColumnObjects[i].StartTime >= newColumnObjects[j].StartTime - 2 + && newColumnObjects[i].StartTime <= newColumnObjects[j].GetEndTime() + 2) + overlap = true; + } + } + + if (outindex) overlap = true; + + if (!overlap) + fixedColumnObjects.Add(newColumnObjects[i]); + else + { + for (int k = 0; k < keyvalue; k++) + { + if (!FindOverlap(newColumnObjects[i], newColumnObjects.Where(h => h.Column == newColumnObjects[i].Column - k).ToList()) && newColumnObjects[i].Column - k >= 0) + newColumnObjects[i].Column -= k; + else if (!FindOverlap(newColumnObjects[i], newColumnObjects.Where(h => h.Column == newColumnObjects[i].Column + k).ToList()) + && newColumnObjects[i].Column + k <= keyvalue - 1) newColumnObjects[i].Column += k; + } + + fixedColumnObjects.Add(newColumnObjects[i]); + } + } + + if (keyvalue < keys) + { + keys++; + keyvalue = keys + 1; + + locations = fixedColumnObjects.OfType().Select(n => ( + startTime: n.StartTime, + samples: n.Samples, + column: n.Column, + endTime: n.StartTime, + duration: n.StartTime - n.StartTime + )) + .Concat(fixedColumnObjects.OfType().Select(h => ( + startTime: h.StartTime, + samples: h.Samples, + column: h.Column, + endTime: h.EndTime, + duration: h.EndTime - h.StartTime + ))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList(); + + nullColumn = -1; + fixedColumnObjects.Clear(); + newColumnObjects.Clear(); + } + else + break; + } + + newObjects.AddRange(fixedColumnObjects); + + return newObjects; + } + + public (int Left, int Right) FindConsecutive(List othercolumn, int number, int keys) + { + int left = -1, right = keys - 1; + + foreach (int consecutive in othercolumn) + { + if (consecutive > number) // right + right = Math.Min(right, consecutive); + + if (consecutive < number) // left + left = Math.Max(left, consecutive); + } + + return (left, right); + } + + public static bool FindOverlap(List hitobj, int column, double starttime, double endtime) + { + foreach (var obj in hitobj) + { + if (obj.Column == column && starttime <= obj.StartTime && starttime >= obj.StartTime) return true; + + if (obj.StartTime != obj.GetEndTime()) + { + if (obj.Column == column && starttime >= obj.StartTime && starttime <= obj.GetEndTime()) + { + if (endtime != starttime) + { + if (endtime >= obj.StartTime && endtime <= obj.GetEndTime()) + return true; + } + + return true; + } + } + } + + return false; + } + + public static bool FindOverlap(ManiaHitObject hitobj, List objs) + { + return FindOverlap(objs, hitobj.Column, hitobj.StartTime, hitobj.GetEndTime()); + } + + public static bool FindOverlap(ManiaHitObject hitobj, int column, double starttime, double endtime) + { + List onenote = [hitobj]; + return FindOverlap(onenote, column, starttime, endtime); + } + + public static bool FindOverlap(List hitobj) + { + for (int i = 0; i < hitobj.Count; i++) + { + for (int j = i + 1; j < hitobj.Count; j++) + { + if (hitobj[i].Column == hitobj[j].Column && hitobj[i].StartTime == hitobj[j].StartTime) return true; + + if (hitobj[j].StartTime != hitobj[j].GetEndTime()) + { + if (hitobj[i].Column == hitobj[j].Column && hitobj[i].StartTime >= hitobj[j].StartTime - 2 && hitobj[i].StartTime <= hitobj[j].GetEndTime() + 2) + { + if (hitobj[i].GetEndTime() != hitobj[j].StartTime) + { + if (hitobj[i].GetEndTime() >= hitobj[j].StartTime - 2 && hitobj[i].GetEndTime() <= hitobj[j].GetEndTime() + 2) + return true; + } + + return true; + } + } + } + } + + return false; + } + + public static List DP(List objects, int style) + { + var newObjects = new List(); + + var newColumnObjects = new List(); + + var locations = objects.OfType().Select(n => ( + startTime: n.StartTime, + samples: n.Samples, + column: n.Column, + endTime: n.StartTime + )) + .Concat(objects.OfType().Select(h => ( + startTime: h.StartTime, + samples: h.Samples, + column: h.Column, + endTime: h.EndTime + ))).OrderBy(h => h.startTime).ToList(); + + for (int i = 0; i < locations.Count; i++) + { + var note = new Note(); + var hold = new HoldNote(); + int columnnum = locations[i].column; + + switch (columnnum) + { + case 1: + { + columnnum = 0; + } + break; + + case 3: + { + columnnum = 1; + } + break; + + case 5: + { + columnnum = 2; + if (style >= 5 && style <= 8) columnnum = 4; + } + break; + + case 7: + { + columnnum = 3; + if (style >= 5 && style <= 8) columnnum = 5; + } + break; + } + + if (locations[i].startTime == locations[i].endTime) + { + note.StartTime = locations[i].startTime; + note.Samples = locations[i].samples; + } + else + { + hold.StartTime = locations[i].startTime; + hold.Samples = locations[i].samples; + hold.EndTime = locations[i].endTime; + } + + switch (style) + { + case 1: + { + newColumnObjects.AddNote(locations[i].samples, columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, 4 + columnnum, locations[i].startTime, locations[i].endTime); + } + break; + + case 2: + { + newColumnObjects.AddNote(locations[i].samples, 3 - columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, 7 - columnnum, locations[i].startTime, locations[i].endTime); + } + break; + + case 3: + { + newColumnObjects.AddNote(locations[i].samples, columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, 7 - columnnum, locations[i].startTime, locations[i].endTime); + } + break; + + case 4: + { + newColumnObjects.AddNote(locations[i].samples, 3 - columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, 4 + columnnum, locations[i].startTime, locations[i].endTime); + } + break; + + case 5: + { + newColumnObjects.AddNote(locations[i].samples, columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, 2 + columnnum, locations[i].startTime, locations[i].endTime); + } + break; + + case 6: + { + if (columnnum <= 1) columnnum = 3 - columnnum; + + if (columnnum >= 4) columnnum = 7 - columnnum + 4; + newColumnObjects.AddNote(locations[i].samples, columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, columnnum - 2, locations[i].startTime, locations[i].endTime); + } + break; + + case 7: + case 8: + { + if (style == 8) + { + if (columnnum == 0 || columnnum == 4) + columnnum++; + else if (columnnum == 1 || columnnum == 5) columnnum--; + } + + if (columnnum < 4) + { + newColumnObjects.AddNote(locations[i].samples, columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, 3 - columnnum, locations[i].startTime, locations[i].endTime); + } + + if (columnnum > 3) + { + newColumnObjects.AddNote(locations[i].samples, columnnum, locations[i].startTime, locations[i].endTime); + newColumnObjects.AddNote(locations[i].samples, 7 - (columnnum - 4), locations[i].startTime, locations[i].endTime); + } + } + break; + } + } + + newObjects.AddRange(newColumnObjects); + return newObjects; + } + + public static List NTMA(IBeatmap beatmap, int blank, int toKey, int gapValue, int cleanDivide) + { + var Rng = new Random(); + const double error = 1.5; + const double interval = 50; + const double ln_interval = 10; + + var maniaBeatmap = (ManiaBeatmap)beatmap; + + int keys = (int)maniaBeatmap.Difficulty.CircleSize; + + if (blank > toKey - keys) blank = toKey - keys; + + if (keys > 9 || toKey <= keys) return new List(); + + var newObjects = new List(); + + var locations = maniaBeatmap.HitObjects.OfType().Select(n => ( + column: n.Column, + startTime: n.StartTime, + endTime: n.StartTime, + samples: n.Samples + )) + .Concat(maniaBeatmap.HitObjects.OfType().Select(h => ( + column: h.Column, + startTime: h.StartTime, + endTime: h.EndTime, + samples: h.Samples + ))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList(); + + var confirmNull = new List(); + var nullColumnList = new List(); + + for (int i = 0; i <= toKey; i++) confirmNull.Add(false); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + int count = column.Count(); + if (!confirmNull[column.Key] && count != 0) confirmNull[column.Key] = true; + } + + for (int i = 0; i < toKey; i++) + { + if (!confirmNull[i]) + nullColumnList.Add(i); + } + + for (int i = 0; i < locations.Count; i++) + { + int minusColumn = 0; + + foreach (int nul in nullColumnList) + { + if (locations[i].column > nul) + minusColumn++; + } + + var thisLocations = locations[i]; + thisLocations.column -= minusColumn; + locations[i] = thisLocations; + } + + var area = new List<(int column, double startTime, double endTime, IList samples)>(); + var checkList = new List(); + + var tempObjects = locations.OrderBy(h => h.startTime).ToList(); + + double sumTime = 0; + double lastTime = 0; + + foreach (var timingPoint in tempObjects.GroupBy(h => h.startTime)) + { + var newLocations = timingPoint.OfType<(int column, double startTime, double endTime, IList samples)>() + .Select(n => (Column: n.column, StartTime: n.startTime, EndTime: n.endTime, Samples: n.samples)).OrderBy(h => h.Column).ToList(); + + var line = new List<(int column, double startTime, double endTime, IList samples)>(); + + foreach (var note in newLocations) line.Add((note.Column, note.StartTime, note.EndTime, note.Samples)); + + //manyLine.Add(line); + int blankColumn = blank; + + sumTime += timingPoint.Key - lastTime; + lastTime = timingPoint.Key; + + area.AddRange(line); + + double gap = 29998.8584 * Math.Pow(Math.E, -0.3176 * gapValue) + 347.7248; + + if (gapValue == 0) gap = double.MaxValue; + + if (sumTime >= gap) + { + sumTime = 0; + // Process area + var processed = ProcessArea(maniaBeatmap, Rng, area, keys, toKey, blank, cleanDivide, error, checkList); + newObjects.AddRange(processed.result); + checkList = processed.checkList.ToList(); + area.Clear(); + } + } + + if (area.Count > 0) + { + var processed = ProcessArea(maniaBeatmap, Rng, area, keys, toKey, blank, cleanDivide, error, checkList); + newObjects.AddRange(processed.result); + } + + newObjects = newObjects.OrderBy(h => h.StartTime).ToList(); + + var cleanObjects = new List(); + + foreach (var column in newObjects.GroupBy(h => h.Column)) + { + var newColumnObjects = new List(); + + var cleanLocations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime) + })) + .OrderBy(h => h.startTime).ToList(); + + double lastStartTime = cleanLocations[0].startTime; + double lastEndTime = cleanLocations[0].endTime; + + for (int i = 0; i < cleanLocations.Count; i++) + { + if (i == 0) + { + lastStartTime = cleanLocations[0].startTime; + lastEndTime = cleanLocations[0].endTime; + continue; + } + + if (cleanLocations[i].startTime >= lastStartTime && cleanLocations[i].startTime <= lastEndTime) + { + cleanLocations.RemoveAt(i); + i--; + continue; + } // if the note in a LN + + if (Math.Abs(cleanLocations[i].startTime - lastStartTime) <= interval) + { + lastStartTime = cleanLocations[i].startTime; + lastEndTime = cleanLocations[i].endTime; + cleanLocations.RemoveAt(i); + i--; + continue; + } // interval judgement + + if (Math.Abs(cleanLocations[i].startTime - lastEndTime) <= ln_interval) + { + lastStartTime = cleanLocations[i].startTime; + lastEndTime = cleanLocations[i].endTime; + cleanLocations.RemoveAt(i); + i--; + continue; + } // LN interval judgement + + if (lastStartTime != lastEndTime) + { + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = lastStartTime, + Duration = lastEndTime - lastStartTime, + NodeSamples = [cleanLocations[i].samples, Array.Empty()] + }); + } + else + { + newColumnObjects.Add(new Note + { + Column = column.Key, + StartTime = lastStartTime, + Samples = cleanLocations[i].samples + }); + } + + lastStartTime = cleanLocations[i].startTime; + lastEndTime = cleanLocations[i].endTime; + } + + cleanObjects.AddRange(newColumnObjects); + } + + return cleanObjects.OrderBy(h => h.StartTime).ToList(); + } + + public static (List result, List checkList) ProcessArea(ManiaBeatmap beatmap, + Random Rng, + List<(int column, double startTime, double endTime, IList samples)> hitObjects, + int fromKeys, + int toKeys, + int blankNum = 0, + int clean = 0, + double error = 0, + List? checkList = null) + { + var newObjects = new List(); + List<(int column, bool isBlank)> copyColumn = []; + List insertColumn = []; + List checkColumn = []; + bool isFirst = true; + + int num = toKeys - fromKeys - blankNum; + + while (num > 0) + { + int copy = Rng.Next(fromKeys); + + if (!copyColumn.Contains((copy, false))) + { + copyColumn.Add((copy, false)); + num--; + } + } + + num = blankNum; + + while (num > 0) + { + int copy = -1; + copyColumn.Add((copy, true)); + num--; + } + + num = toKeys - fromKeys; + + while (num > 0) + { + int insert = Rng.Next(toKeys); + + if (!insertColumn.Contains(insert)) + { + insertColumn.Add(insert); + num--; + } + } + + insertColumn = insertColumn.OrderBy(c => c).ToList(); + + foreach (var timingPoint in hitObjects.GroupBy(h => h.startTime)) + { + var locations = timingPoint.OfType<(int column, double startTime, double endTime, IList samples)>().ToList(); + var tempObjects = new List(); + int length = copyColumn.Count; + + for (int i = 0; i < locations.Count; i++) + { + int column = locations[i].column; + + for (int j = 0; j < length; j++) + { + if (column == copyColumn[j].column && !copyColumn[j].isBlank) tempObjects.AddNote(locations[i].samples, insertColumn[j], locations[i].startTime, locations[i].endTime); + + if (locations[i].column >= insertColumn[j]) locations[i] = (locations[i].column + 1, locations[i].startTime, locations[i].endTime, locations[i].samples); + } + + tempObjects.AddNote(locations[i].samples, locations[i].column, locations[i].startTime, locations[i].endTime); + } + + if (isFirst && checkList is not null && checkList.Count > 0 && clean > 0) + { + var checkC = checkList.Select(h => h.Column).ToList(); + var checkS = checkList.Select(h => h.StartTime).ToList(); + + for (int i = 0; i < tempObjects.Count; i++) + { + if (checkC.Contains(tempObjects[i].Column)) + { + if (clean != 0) + { + double beatLength = beatmap.ControlPointInfo.TimingPointAt(tempObjects[i].StartTime).BeatLength; + double timeDivide = beatLength / clean; + int index = checkC.IndexOf(tempObjects[i].Column); + + if (tempObjects[i].StartTime - checkS[index] < timeDivide + error) + { + tempObjects.RemoveAt(i); + i--; + } + } + else + { + tempObjects.RemoveAt(i); + i--; + } + } + } + + isFirst = false; + } + + checkColumn.Clear(); + checkColumn.AddRange(tempObjects); + newObjects.AddRange(tempObjects); + } + + return (newObjects, checkColumn); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/Utils.cs b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/Utils.cs new file mode 100644 index 0000000000..6a3da82c86 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/YuLiangSSSMods/Utils.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using static osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods.ModStarRatingRebirth; + +namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods +{ + public class Utils + { + public static int SearchSortedLeft(int[] array, int value) + { + int index = Array.BinarySearch(array, value); + return index >= 0 ? index : ~index; + } + + public static int SearchSortedRight(int[] array, int value) + { + int index = Array.BinarySearch(array, value); + + if (index >= 0) + { + while (index < array.Length - 1 && array[index + 1] == value) index++; + return index + 1; + } + + return ~index; + } + + public static double[] CumulativeSum(int[] x, double[] f) + { + double[] F = new double[x.Length]; + for (int i = 1; i < x.Length; i++) F[i] = F[i - 1] + f[i - 1] * (x[i] - x[i - 1]); + return F; + } + + public static double QueryCumsum(int q, int[] x, double[] F, double[] f) + { + if (q <= x[0]) return 0.0; + if (q >= x[^1]) return F[^1]; + + int i = SearchSortedLeft(x, q) - 1; + return F[i] + f[i] * (q - x[i]); + } + + public static double[] SmoothOnCorners(int[] x, double[] f, int window, double scale = 1.0, string mode = "sum") + { + double[] F = CumulativeSum(x, f); + double[] g = new double[f.Length]; + + for (int i = 0; i < x.Length; i++) + { + int s = x[i]; + int a = Math.Max(s - window, x[0]); + int b = Math.Min(s + window, x[^1]); + double val = QueryCumsum(b, x, F, f) - QueryCumsum(a, x, F, f); + + if (mode == "avg") + g[i] = b - a > 0 ? val / (b - a) : 0.0; + else + g[i] = scale * val; + } + + return g; + } + + public static double[] InterpValues(int[] newX, int[] oldX, double[] oldVals) + { + double[] newVals = new double[newX.Length]; + + for (int i = 0; i < newX.Length; i++) + { + int idx = SearchSortedLeft(oldX, newX[i]); + + if (idx == 0) + newVals[i] = oldVals[0]; + else if (idx >= oldX.Length) + newVals[i] = oldVals[^1]; + else + { + double t = (double)(newX[i] - oldX[idx - 1]) / (oldX[idx] - oldX[idx - 1]); + newVals[i] = oldVals[idx - 1] + t * (oldVals[idx] - oldVals[idx - 1]); + } + } + + return newVals; + } + + public static double[] StepInterp(int[] newX, int[] oldX, double[] oldVals) + { + double[] newVals = new double[newX.Length]; + + for (int i = 0; i < newX.Length; i++) + { + int idx = SearchSortedRight(oldX, newX[i]) - 1; + idx = Math.Clamp(idx, 0, oldVals.Length - 1); + newVals[i] = oldVals[idx]; + } + + return newVals; + } + + public static double RescaleHigh(double sr) + { + if (sr <= 9) return sr; + + return 9 + (sr - 9) * (1 / 1.2); + } + + public static EasyObject FindNextNoteInColumn(EasyObject note, int[] times, EasyObject[][] noteSeqByColumn) + { + int idx = Array.BinarySearch(times, note.Head); + if (idx < 0) idx = ~idx; + + return idx + 1 < noteSeqByColumn[note.Key].Length ? noteSeqByColumn[note.Key][idx + 1] : new EasyObject(0, int.MaxValue, int.MaxValue); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteBody.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteBody.cs index 6259033235..784ac96230 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteBody.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteBody.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { } - internal void TriggerResult(bool hit) + internal virtual void TriggerResult(bool hit) { if (AllJudged) return; diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/DrawableLNTailForNoRelease.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/DrawableLNTailForNoRelease.cs new file mode 100644 index 0000000000..45bfdd7124 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/DrawableLNTailForNoRelease.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public partial class DrawableLNTailForNoRelease : DrawableHoldNoteTail + { + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (HoldNote.Head.IsHit && timeOffset >= 0) + ApplyResult(GetCappedResult(HitResult.Perfect)); + else + base.CheckForResult(userTriggered, timeOffset); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcDrawableLNTail.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcDrawableLNTail.cs new file mode 100644 index 0000000000..44753196d3 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcDrawableLNTail.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public partial class Ez2AcDrawableLNTail : DrawableHoldNoteTail + { + public override bool DisplayResult => false; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (!HoldNote.Head.IsHit) + { + return; + } + + if (timeOffset >= 0 && HoldNote.IsHolding.Value) + { + ApplyMaxResult(); + return; + } + else if (timeOffset > 0) + { + ApplyMinResult(); + return; + } + } + } + + public partial class Ez2AcDrawableNote : DrawableNote + { + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + // if (userTriggered && HitObject.HitWindows is ManiaHitWindows ezWindows) + // { + // double missWindow = double.Abs(ezWindows.WindowFor(HitResult.Miss)); + // double poolEarlyWindow = missWindow + 500; + // double poolLateWindow = missWindow + 150; + // + // // 提前按下(timeOffset < 0)且在提前 Pool 窗口内 + // if ((timeOffset < 0 && missWindow <= poolEarlyWindow) || + // (timeOffset > 0 && timeOffset <= poolLateWindow)) + // ApplyResult(HitResult.Pool); + // } + + if (userTriggered && (timeOffset < -500 || timeOffset > 200)) + { + ApplyResult(HitResult.Pool); + } + + base.CheckForResult(userTriggered, timeOffset); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcHoldNote.cs new file mode 100644 index 0000000000..4b9e886d14 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/Ez2AcHoldNote.cs @@ -0,0 +1,126 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + // 注意注册顺序必须是:头 尾 身体 + public class Ez2AcHoldNote : HoldNote + { + public Ez2AcHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new HeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0), + }); + + AddNested(Tail = new Ez2AcLNTail + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), + }); + + AddNested(Body = new HoldNoteBody + { + StartTime = StartTime, + Column = Column + }); + // double interval = new BeatInterval().GetCurrentQuarterBeatInterval(); + // + // // 按1/4节拍添加身体判定节点 + // for (double time = StartTime + interval; time < EndTime; time += interval) + // { + // AddNested(new NoMissLNBody + // { + // StartTime = time, + // Column = Column + // }); + // } + } + } + + public class Ez2AcLNHead : HeadNote + { + } + + public class Ez2AcLNTail : TailNote + { + public override Judgement CreateJudgement() => new NoComboBreakTailJudgement(); + + public class NoComboBreakTailJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + public override HitResult MinResult => HitResult.ComboBreak; + } + } + + public class Ez2AcNote : Note + { + public Ez2AcNote(Note note) + { + StartTime = note.StartTime; + Column = note.Column; + Samples = note.Samples; + } + + // public override Judgement CreateJudgement() => new Ez2AcJudgement(); + // protected override HitWindows CreateHitWindows() => new Ez2AcHitWindows(); + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + } + } + + public class Ez2AcJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.Perfect; + public override HitResult MinResult => HitResult.Pool; + + protected override double HealthIncreaseFor(HitResult result) + { + switch (result) + { + case HitResult.Pool: + // Pool 判定应用严格扣血 + return -DEFAULT_MAX_HEALTH_INCREASE * 5; + + case HitResult.Miss: + return -DEFAULT_MAX_HEALTH_INCREASE * 3; + + case HitResult.Meh: + return -DEFAULT_MAX_HEALTH_INCREASE * 2; + + case HitResult.Ok: + return -DEFAULT_MAX_HEALTH_INCREASE * 1; + + case HitResult.Good: + return DEFAULT_MAX_HEALTH_INCREASE * 0.1; + + case HitResult.Great: + return DEFAULT_MAX_HEALTH_INCREASE * 0.8; + + case HitResult.Perfect: + return DEFAULT_MAX_HEALTH_INCREASE; + + default: + return base.HealthIncreaseFor(result); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/MalodyDrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/MalodyDrawableNote.cs new file mode 100644 index 0000000000..0ba9a3b385 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/MalodyDrawableNote.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public partial class MalodyDrawableLNBody : DrawableHoldNoteBody + { + public new bool HasHoldBreak => false; + + internal new void TriggerResult(bool hit) + { + if (AllJudged) return; + + ApplyMaxResult(); + } + } + + public partial class MalodyDrawableLNTail : DrawableHoldNoteTail + { + public static HitWindows HitWindows = new ManiaHitWindows(); + + public override bool DisplayResult => false; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (!HoldNote.Head.IsHit) + { + return; + } + + if (timeOffset > 0 && HoldNote.Head.IsHit) + { + ApplyMaxResult(); + return; + } + else if (timeOffset > 0) + { + ApplyMinResult(); + return; + } + + if (HoldNote.IsHolding.Value) + { + return; + } + + if (HoldNote.Head.IsHit && Math.Abs(timeOffset) < Math.Abs(HitWindows.WindowFor(HitResult.Meh) * TailNote.RELEASE_WINDOW_LENIENCE)) + { + ApplyMaxResult(); + } + else + { + ApplyMinResult(); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoComboBreakLNTail.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoComboBreakLNTail.cs new file mode 100644 index 0000000000..2bfe84e980 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoComboBreakLNTail.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public class NoComboBreakLNTail : TailNote + { + public override Judgement CreateJudgement() => new NoComboBreakTailJudgement(); + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public class NoComboBreakTailJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + public override HitResult MinResult => HitResult.ComboBreak; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgementNote.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgementNote.cs new file mode 100644 index 0000000000..4492e53f3a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgementNote.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public class NoJudgementNote : Note + { + public NoJudgementNote(Note note) + { + StartTime = note.StartTime; + Column = note.Column; + Samples = note.Samples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgmentHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgmentHoldNote.cs new file mode 100644 index 0000000000..8a0b0df1f0 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoJudgmentHoldNote.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public class NoJudgmentHoldNote : HoldNote + { + public NoJudgmentHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new HeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0), + }); + AddNested(Tail = new NoComboBreakLNTail + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), + }); + AddNested(Body = new NoMissLNBody + { + StartTime = StartTime, + Column = Column, + }); + } + } +} + diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoMissLNBody.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoMissLNBody.cs new file mode 100644 index 0000000000..d46c768183 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/NoMissLNBody.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public class NoMissLNBody : HoldNoteBody + { + public override Judgement CreateJudgement() => new NoMissBodyJudgement(); + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public class NoMissBodyJudgement : ManiaJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + public override HitResult MinResult => HitResult.IgnoreMiss; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HitModeExtension.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HitModeExtension.cs new file mode 100644 index 0000000000..d4fa16ffb8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HitModeExtension.cs @@ -0,0 +1,356 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + // 代码改编自YuLiangSSS提供的ManiaModO2Judgement + public static partial class O2HitModeExtension + { + // 💊数量(可绑定) + // 上限为 5,在达到一定 Cool 连击后会增加,发生较大偏移时会减少。 + public static readonly Bindable PILL_COUNT = new Bindable(0); + + public const double BASE_COOL = 7500.0; + public const double BASE_GOOD = 22500.0; + public const double BASE_BAD = 31250.0; + + // 启用 Pill 模式的特殊判定逻辑(如累积/消耗 Pill、使用 CoolCombo 逻辑等)。 + // 注意:初始值和持久化逻辑取决于外部设置/开关,这里仅作为全局运行时状态使用。 + public static bool PillActivated; // = ManiaModO2Judgement.PillMode.Value; + + // Cool 连击计数(用于追踪在 Cool 判定内的连续命中次数) + // 语义:每次命中判断在 Cool 范围内时递增;当计数达到 15 时会重置(减去 15)并使 `Pill` 增加(最多至 5)。 + // 若在 Good 范围内则重置为 0;若落入 Bad 范围且拥有 Pill 会消耗 1 个 Pill 并替换判定为 Perfect(见使用处)。 + public static int CoolCombo; + + // 当前时间戳的控制点信息,用于动态计算 BPM 相关范围 + private static ControlPointInfo? currentControlPoints; + + // 保存原始 BPM 值 + private static double originalBPM = 120.0; + public static bool IsPlaying = true; + + /// + /// 设置当前谱面的控制点信息,用于动态 BPM 计算 + /// + /// 谱面的控制点信息 + public static void SetControlPoints(ControlPointInfo? controlPoints) + { + currentControlPoints = controlPoints; + } + + /// + /// 设置原始 BPM 值 + /// + /// 原始 BPM 值 + public static void SetOriginalBPM(double bpm) + { + originalBPM = bpm; + } + + /// + /// 根据当前时间获取动态 BPM + /// + /// 当前时间 + /// 对应时间的 BPM,最低为 120 + public static double GetBPMAtTime(double time) + { + if (currentControlPoints != null && IsPlaying) + { + var timingPoint = currentControlPoints.TimingPointAt(time); + // 确保 BPM 不低于 120 + return Math.Max(timingPoint.BPM, 75.0); + } + + // 如果没有控制点信息,则使用原始 BPM 值,同样确保不低于 120 + return Math.Max(originalBPM, 120); + } + + /// + /// 根据当前时间获取 Cool 判定范围 + /// + /// 当前时间 + /// Cool 判定范围 + public static double GetCoolRangeAtTime(double time) => BASE_COOL / GetBPMAtTime(time); + + /// + /// 根据当前时间获取 Good 判定范围 + /// + /// 当前时间 + /// Good 判定范围 + public static double GetGoodRangeAtTime(double time) => BASE_GOOD / GetBPMAtTime(time); + + /// + /// 根据当前时间获取 Bad 判定范围 + /// + /// 当前时间 + /// Bad 判定范围 + public static double GetBadRangeAtTime(double time) => BASE_BAD / GetBPMAtTime(time); + + /// + /// 更新 CoolCombo 值,自动处理溢出逻辑 + /// + public static void IncrementCoolCombo() + { + if (++CoolCombo >= 15) + { + CoolCombo = 0; + // 使用 Clamp 统一约束范围,确保在 [0, 5] 范围内 + PILL_COUNT.Value = Math.Clamp(PILL_COUNT.Value + 1, 0, 5); + } + } + + /// + /// 统一的 Pill 判定逻辑:将原本分散在各 Drawable 的重复实现合并到这里。 + /// 返回值:true 表示继续执行后续判定逻辑;false 表示应中断后续判定(保留以便未来扩展)。 + /// out 参数: + /// - :当命中落入 Bad 范围且没有可用 Pill 时为 true。 + /// - :当命中落入 Bad 范围且消耗了 Pill 时为 true(调用者应将该次判定提升为 )。 + /// + /// 时间偏移 + /// 当前游戏时间 + /// 当命中落入 Bad 范围且没有可用 Pill 时为 true + /// 当命中落入 Bad 范围且消耗了 Pill 时为 true + public static bool PillCheck(double timeOffset, double currentTime, out bool applyComboBreak, out bool upgradeToPerfect) + { + applyComboBreak = false; + upgradeToPerfect = false; + + if (!PillActivated) + return true; + + double absOffset = Math.Abs(timeOffset); + + // 根据当前时间获取动态范围 + double coolRange = GetCoolRangeAtTime(currentTime); + double goodRange = GetGoodRangeAtTime(currentTime); + double badRange = GetBadRangeAtTime(currentTime); + + Logger.Log("[O2HitModeExtension] Ranges at time " + currentTime + ": Cool=" + coolRange + ", Good=" + goodRange + ", Bad=" + badRange); + + if (absOffset <= coolRange) + { + IncrementCoolCombo(); + } + else if (absOffset <= goodRange) + { + CoolCombo = 0; + } + else if (absOffset <= badRange) + { + CoolCombo = 0; + + if (PILL_COUNT.Value > 0) + { + // 使用 Clamp 统一约束范围,确保在 [0, 5] 范围内 + PILL_COUNT.Value = Math.Clamp(PILL_COUNT.Value - 1, 0, 5); + upgradeToPerfect = true; // 升级为 Perfect 判定 + } + else + { + applyComboBreak = true; // 无法挽救,断连 + } + } + + return true; + } + } + + public partial class O2DrawableNote : DrawableNote + { + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + bool upgradeToPerfect = false; + + if (userTriggered) + { + // 使用当前时间进行动态 BPM 计算 + bool cont = O2HitModeExtension.PillCheck(timeOffset, Time.Current, out bool _, out upgradeToPerfect); + if (!cont) return; + } + + // 此处有潜在的崩溃风险,与播放动画有关,待调查。 + // Replicate base implementation to allow attaching combo semantics overrides. + if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(timeOffset)) + ApplyMinResult(); + + return; + } + + var result = HitObject.HitWindows.ResultFor(timeOffset); + + if (result == HitResult.None) + return; + + result = GetCappedResult(result); + + if (upgradeToPerfect) + result = HitResult.Perfect; + + ApplyResult(static (r, state) => + { + r.Type = state; + + // In O2Jam hit mode, Meh should break combo. + if (state == HitResult.Meh) + r.IsComboHit = false; + }, result); + } + } + + public partial class O2DrawableHoldNoteHead : DrawableHoldNoteHead + { + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + bool upgradeToPerfect = false; + + if (userTriggered) + { + // 使用当前时间进行动态 BPM 计算 + bool cont = O2HitModeExtension.PillCheck(timeOffset, Time.Current, out bool _, out upgradeToPerfect); + if (!cont) return; + } + + // Replicate base implementation to allow attaching combo semantics overrides. + if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(timeOffset)) + ApplyMinResult(); + + return; + } + + var result = HitObject.HitWindows.ResultFor(timeOffset); + + if (result == HitResult.None) + return; + + result = GetCappedResult(result); + + if (upgradeToPerfect) + result = HitResult.Perfect; + + ApplyResult(static (r, state) => + { + r.Type = state; + + // In O2Jam hit mode, Meh should break combo. + if (state == HitResult.Meh) + r.IsComboHit = false; + }, result); + } + } + + public partial class O2DrawableHoldNoteTail : DrawableHoldNoteTail + { + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + bool upgradeToPerfect = false; + + if (userTriggered) + { + // 使用当前时间进行动态 BPM 计算 + bool cont = O2HitModeExtension.PillCheck(timeOffset, Time.Current, out bool _, out upgradeToPerfect); + if (!cont) return; + } + + // Behaviour parity with previous implementation: + // Previously we forwarded `timeOffset * RELEASE_WINDOW_LENIENCE` to base, which then divided by RELEASE_WINDOW_LENIENCE, + // resulting in `timeOffset` being used for hit windows. + double adjustedOffset = timeOffset; + + if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(adjustedOffset)) + ApplyMinResult(); + + return; + } + + var result = HitObject.HitWindows.ResultFor(adjustedOffset); + + if (result == HitResult.None) + return; + + result = GetCappedResult(result); + + if (upgradeToPerfect) + result = HitResult.Perfect; + + ApplyResult(static (r, state) => + { + r.Type = state; + + // In O2Jam hit mode, Meh should break combo. + if (state == HitResult.Meh) + r.IsComboHit = false; + }, result); + } + } + + public partial class O2DrawableHoldNote : DrawableHoldNote + { + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (Tail.AllJudged) + { + if (Tail.IsHit) + { + bool breakComboFromTailMeh = Tail.Result.Type == HitResult.Meh; + + ApplyResult(static (r, breakCombo) => + { + r.Type = r.Judgement.MaxResult; + + // In O2Jam hit mode, a Meh on the tail should terminally break combo. + // Prevent the parent hold note result from immediately re-increasing combo afterwards. + if (breakCombo) + r.IsComboHit = false; + }, breakComboFromTailMeh); + } + else + MissForcefully(); + + // Make sure that the hold note is fully judged by giving the body a judgement. + if (!Body.AllJudged) + Body.TriggerResult(Tail.IsHit); + + // Important that this is always called when a result is applied. + Result.ReportHoldState(Time.Current, false); + } + } + } + + public class O2Note : Note + { + public O2Note(Note note) + { + StartTime = note.StartTime; + Column = note.Column; + Samples = note.Samples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + } + } + + public class O2LNHead : HeadNote + { + } + + public class O2LNTail : TailNote + { + public override double MaximumJudgementOffset => base.MaximumJudgementOffset / RELEASE_WINDOW_LENIENCE; + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HoldNote.cs new file mode 100644 index 0000000000..656964b4d3 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/EzCurrentHitObject/O2HoldNote.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; + +namespace osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject +{ + public class O2HoldNote : HoldNote + { + public O2HoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new O2LNHead + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0) + }); + + AddNested(Tail = new O2LNTail + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1) + }); + + AddNested(Body = new HoldNoteBody + { + StartTime = StartTime, + Column = Column + }); + } + + public override double MaximumJudgementOffset => base.MaximumJudgementOffset / TailNote.RELEASE_WINDOW_LENIENCE; + } +} diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs index a33eac83c2..e60a41752c 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs @@ -1,7 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using osu.Framework.Logging; +using osu.Game.LAsEzExtensions.Background; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -10,16 +14,41 @@ namespace osu.Game.Rulesets.Mania.Scoring { public partial class ManiaHealthProcessor : LegacyDrainingHealthProcessor { + private static readonly double[,] health_settings = + { + // 305 300 200 100 50 Miss Poor + { 0.004, 0.003, 0.001, 0.000, -0.010, -0.065, -0.000 }, // Lazer + { 0.003, 0.000, 0.002, 0.000, -0.010, -0.050, -0.060 }, // O2 Easy + { 0.002, 0.000, 0.001, 0.000, -0.070, -0.040, -0.050 }, // O2 Normal + { 0.001, 0.000, 0.000, 0.000, -0.050, -0.030, -0.040 }, // O2 Hard + { 0.004, 0.003, 0.001, 0.000, -0.010, -0.050, -0.050 }, // Ez2Ac + { 0.016, 0.016, 0.000, 0.000, -0.050, -0.090, -0.050 }, // IIDX Hard + { 0.010, 0.010, 0.005, 0.000, -0.060, -0.100, -0.020 }, // LR2 Hard + { 0.012, 0.012, 0.006, 0.000, -0.030, -0.060, -0.020 }, // raja normal + }; + + private static EnumHealthMode mode = EnumHealthMode.Lazer; + private static int row; + + private HitResult lastResult = HitResult.None; + private int streak; + public ManiaHealthProcessor(double drainStartTime) : base(drainStartTime) { + if (GlobalConfigStore.EzConfig != null) + mode = GlobalConfigStore.EzConfig.Get(Ez2Setting.CustomHealthMode); + + row = switchHealthMode(mode); } protected override double ComputeDrainRate() { // Base call is run only to compute HP recovery (namely, `HpMultiplierNormal`). // This closely mirrors (broken) behaviour of stable and as such is preserved unchanged. - base.ComputeDrainRate(); + // 只有Lazer模式下,会调用此方法。从基类中计算HP作用。非Lazer模式禁止使用,否则会出现无限计算。 + if (mode == EnumHealthMode.Lazer) + base.ComputeDrainRate(); return 0; } @@ -28,43 +57,131 @@ namespace osu.Game.Rulesets.Mania.Scoring protected override IEnumerable EnumerateNestedHitObjects(HitObject hitObject) => hitObject.NestedHitObjects; + // 特别强调:血量机制异常时会导致无法进入Gameplay,无限加载。 protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result) { + if (result == lastResult) + streak++; + else + { + streak = 1; + lastResult = result; + } + double increase = 0; + if (mode == EnumHealthMode.Lazer) + { + switch (result) + { + case HitResult.Pool: + return -getPoorHealthDelta(streak); + + case HitResult.Miss: + switch (hitObject) + { + case HeadNote: + case TailNote: + return -(Beatmap.Difficulty.DrainRate + 1) * 0.00375; + + default: + return -(Beatmap.Difficulty.DrainRate + 1) * 0.0075; + } + + case HitResult.Meh: + return -(Beatmap.Difficulty.DrainRate + 1) * 0.0016; + + case HitResult.Ok: + return 0; + + case HitResult.Good: + increase = 0.004 - Beatmap.Difficulty.DrainRate * 0.0004; + break; + + case HitResult.Great: + increase = 0.0051 - Beatmap.Difficulty.DrainRate * 0.0005; + break; + + case HitResult.Perfect: + increase = 0.0053 - Beatmap.Difficulty.DrainRate * 0.0005; + break; + } + + if (increase > 0) + increase *= streak; + // Logger.Log($"ManiaHealthProcessor: raw health change {HpMultiplierNormal * increase} for mode {mode}"); + return HpMultiplierNormal * increase; + } + switch (result) { + case HitResult.Pool: + return -getPoorHealthDelta(streak); + case HitResult.Miss: switch (hitObject) { case HeadNote: case TailNote: - return -(Beatmap.Difficulty.DrainRate + 1) * 0.00375; + return health_settings[row, 5] / 5; default: - return -(Beatmap.Difficulty.DrainRate + 1) * 0.0075; + return health_settings[row, 5]; } case HitResult.Meh: - return -(Beatmap.Difficulty.DrainRate + 1) * 0.0016; + return health_settings[row, 4]; case HitResult.Ok: - return 0; + return health_settings[row, 3]; case HitResult.Good: - increase = 0.004 - Beatmap.Difficulty.DrainRate * 0.0004; + increase = health_settings[row, 2]; break; case HitResult.Great: - increase = 0.005 - Beatmap.Difficulty.DrainRate * 0.0005; + increase = health_settings[row, 1]; break; case HitResult.Perfect: - increase = 0.0055 - Beatmap.Difficulty.DrainRate * 0.0005; + increase = health_settings[row, 0]; break; } - return HpMultiplierNormal * increase; + // Non-default health modes use integer table values where the final value + double scaled = Math.Clamp(increase, -0.00001, 0.01); + + // Suppress extremely small floating-point changes which are noise + // and can cause issues (e.g. infinite loading) when treated as non-zero. + const double EPSILON = 1e-6; + + if (Math.Abs(scaled) < EPSILON) + { + scaled = 0; + } + else + { + // #if DEBUG + // Logger.Log($"ManiaHealthProcessor: raw health change {scaled} for mode {mode}"); + // #endif + } + + return scaled; + } + + private int switchHealthMode(EnumHealthMode mode) + { + int idx = (int)mode; + + if (idx < 0 || idx >= health_settings.GetLength(0)) + idx = 0; + + return idx; + } + + private static double getPoorHealthDelta(int streak) + { + return 0.075 + Math.Min(streak - 1, 4) * 0.0125; } } } diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs index abff91926a..3d72e71524 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs @@ -2,11 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Background; using osu.Game.Rulesets.Scoring; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; +using osu.Game.Rulesets.Mania.LAsEZMania.Helper; namespace osu.Game.Rulesets.Mania.Scoring { + public readonly record struct ManiaModifyHitRange(double Perfect, + double Great, + double Good, + double Ok, + double Meh, + double Miss); + public class ManiaHitWindows : HitWindows { public static readonly DifficultyRange PERFECT_WINDOW_RANGE = new DifficultyRange(22.4D, 19.4D, 13.9D); @@ -18,6 +30,13 @@ namespace osu.Game.Rulesets.Mania.Scoring private double speedMultiplier = 1; + public static double PerfectRange; + public static double GreatRange; + public static double GoodRange; + public static double OkRange; + public static double MehRange; + public static double MissRange; + /// /// Multiplier used to compensate for the playback speed of the track speeding up or slowing down. /// The goal of this multiplier is to keep hit windows independent of track speed. @@ -95,12 +114,36 @@ namespace osu.Game.Rulesets.Mania.Scoring } } + private static bool modifyHitWindows; + + public bool ModifyHitWindows + { + get => modifyHitWindows; + set => modifyHitWindows = value; + } + private double perfect; private double great; private double good; private double ok; private double meh; private double miss; + private double pool; + + private static double bpm = 200; + + public double BPM + { + get => bpm; + set + { + bpm = value; + setHitMode(); + updateWindows(); + } + } + + public override bool AllowPoolEnabled => GlobalConfigStore.EzConfig?.Get(Ez2Setting.CustomPoorHitResultBool) ?? false; public override bool IsHitResultAllowed(HitResult result) { @@ -113,9 +156,13 @@ namespace osu.Game.Rulesets.Mania.Scoring case HitResult.Meh: case HitResult.Miss: return true; - } - return false; + case HitResult.Pool: + return AllowPoolEnabled; + + default: + return false; + } } public override void SetDifficulty(double difficulty) @@ -124,9 +171,84 @@ namespace osu.Game.Rulesets.Mania.Scoring updateWindows(); } + private void modifyManiaHitRange(double[] difficultyRangeArray) + { + ModifyHitWindows = true; + + PerfectRange = difficultyRangeArray[0]; + GreatRange = difficultyRangeArray[1]; + GoodRange = difficultyRangeArray[2]; + OkRange = difficultyRangeArray[3]; + MehRange = difficultyRangeArray[4]; + MissRange = difficultyRangeArray[5]; + updateWindows(); + } + + public void ModifyManiaHitRange(ManiaModifyHitRange range) + { + ModifyHitWindows = true; + + PerfectRange = range.Perfect; + GreatRange = range.Great; + GoodRange = range.Good; + OkRange = range.Ok; + MehRange = range.Meh; + MissRange = range.Miss; + updateWindows(); + } + + public void ResetRange() + { + ModifyHitWindows = false; + setHitMode(); + updateWindows(); + } + + private static readonly CustomHitWindowsHelper custom_helper = new CustomHitWindowsHelper(); + + private void setHitMode() + { + EzMUGHitMode HitMode = GlobalConfigStore.EzConfig?.Get(Ez2Setting.HitMode) ?? EzMUGHitMode.Lazer; + + if (HitMode == EzMUGHitMode.Lazer) + { + return; + } + + switch (HitMode) + { + case EzMUGHitMode.O2Jam: + modifyManiaHitRange(custom_helper.GetHitWindowsO2Jam(BPM)); + break; + + case EzMUGHitMode.EZ2AC: + modifyManiaHitRange(custom_helper.GetHitWindowsEZ2AC()); + break; + + case EzMUGHitMode.IIDX_HD: + case EzMUGHitMode.LR2_HD: + case EzMUGHitMode.Raja_NM: + modifyManiaHitRange(custom_helper.GetHitWindowsIIDX(HitMode)); + break; + + case EzMUGHitMode.Malody: + modifyManiaHitRange(custom_helper.GetHitWindowsMelody()); + break; + } + } + private void updateWindows() { - if (ClassicModActive && !ScoreV2Active) + if (ModifyHitWindows) + { + perfect = PerfectRange; + great = GreatRange; + good = GoodRange; + ok = OkRange; + meh = MehRange; + miss = MissRange; + } + else if (ClassicModActive && !ScoreV2Active) { if (IsConvert) { @@ -158,6 +280,9 @@ namespace osu.Game.Rulesets.Mania.Scoring meh = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, meh_window_range) * totalMultiplier) + 0.5; miss = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(overallDifficulty, miss_window_range) * totalMultiplier) + 0.5; } + + // 这里的Pool区间只是用于显示,并不会影响实际判定;实际判定请见 HitWindows.ResultFor 方法 + pool = miss + 150; } public override double WindowFor(HitResult result) @@ -179,6 +304,9 @@ namespace osu.Game.Rulesets.Mania.Scoring case HitResult.Meh: return meh; + case HitResult.Pool: + return pool; + case HitResult.Miss: return miss; diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index dfd6ed6dd2..88431f344e 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -5,7 +5,10 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania.Helper; +using osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -23,7 +26,12 @@ namespace osu.Game.Rulesets.Mania.Scoring } protected override IEnumerable EnumerateHitObjects(IBeatmap beatmap) - => base.EnumerateHitObjects(beatmap).Order(JudgementOrderComparer.DEFAULT); + { + od = Mods.Value.OfType().FirstOrDefault(m => m.OverallDifficulty.Value is not null && m.CustomOD.Value) + ?.OverallDifficulty.Value ?? beatmap.Difficulty.OverallDifficulty; + + return base.EnumerateHitObjects(beatmap).Order(JudgementOrderComparer.DEFAULT); + } protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { @@ -42,6 +50,9 @@ namespace osu.Game.Rulesets.Mania.Scoring switch (result) { case HitResult.Perfect: + if (IsLegacyScore) + return 300; + return 305; } @@ -100,5 +111,85 @@ namespace osu.Game.Rulesets.Mania.Scoring : 0; } } + + private double od = 8; + + protected override void ApplyScoreChange(JudgementResult judgement) + { + // if (!IsLegacyScore) return; + + var hitWindows = new CustomHitWindowsHelper(EzMUGHitMode.Classic) + { + OverallDifficulty = od + }; + double offset = Math.Abs(judgement.TimeOffset); + var result = hitWindows.ResultFor(offset); + var hitObject = (ManiaHitObject)judgement.HitObject; + + if (hitObject is HeadNote) + { + headOffsets[hitObject.Column] = offset; + } + else if (hitObject is TailNote) + { + ClassicMaxBaseScore += 300; + ClassicBaseScore += hitWindows.GetLNScore(headOffsets[hitObject.Column], offset); + headOffsets[hitObject.Column] = 0; + } + else if (hitObject is Note) + { + ClassicMaxBaseScore += 300; + ClassicBaseScore += result switch + { + HitResult.Perfect => 300, + HitResult.Great => 300, + HitResult.Good => 200, + HitResult.Ok => 100, + HitResult.Meh => 50, + _ => 0 + }; + } + + UpdateScoreClassic(); + } + + private readonly double[] headOffsets = new double[18]; + + protected override void RemoveScoreChange(JudgementResult judgement) + { + var hitWindows = new CustomHitWindowsHelper(EzMUGHitMode.Classic) + { + OverallDifficulty = od + }; + double offset = Math.Abs(judgement.TimeOffset); + var result = hitWindows.ResultFor(offset); + var hitObject = (ManiaHitObject)judgement.HitObject; + + if (hitObject is HeadNote) + { + headOffsets[hitObject.Column] = offset; + } + else if (hitObject is TailNote) + { + ClassicMaxBaseScore -= 300; + ClassicBaseScore -= hitWindows.GetLNScore(headOffsets[hitObject.Column], offset); + headOffsets[hitObject.Column] = 0; + } + else if (hitObject is Note) + { + ClassicMaxBaseScore -= 300; + ClassicBaseScore -= result switch + { + HitResult.Perfect => 300, + HitResult.Great => 300, + HitResult.Good => 200, + HitResult.Ok => 100, + HitResult.Meh => 50, + _ => 0 + }; + } + + UpdateScoreClassic(); + } } } diff --git a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs index c642da6dc4..0ad2270a68 100644 --- a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs +++ b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs @@ -19,14 +19,34 @@ namespace osu.Game.Rulesets.Mania // 10K is special because it expands towards the centre of the keyboard (V/N), rather than towards the edges of the keyboard. if (variant == 10) { - leftKeys = new[] { InputKey.A, InputKey.S, InputKey.D, InputKey.F, InputKey.V }; - rightKeys = new[] { InputKey.N, InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + leftKeys = new[] { InputKey.LControl, InputKey.A, InputKey.S, InputKey.D, InputKey.Space }; + rightKeys = new[] { InputKey.Slash, InputKey.L, InputKey.Semicolon, InputKey.Quote, InputKey.Enter }; } - else + else if (variant == 12) { - leftKeys = new[] { InputKey.A, InputKey.S, InputKey.D, InputKey.F }; - rightKeys = new[] { InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + leftKeys = new[] { InputKey.Tab, InputKey.LControl, InputKey.A, InputKey.S, InputKey.D, InputKey.Space }; + rightKeys = new[] { InputKey.Slash, InputKey.L, InputKey.Semicolon, InputKey.Quote, InputKey.Enter, InputKey.BackSlash }; } + else if (variant == 14) + { + leftKeys = new[] { InputKey.Tab, InputKey.LControl, InputKey.A, InputKey.S, InputKey.D, InputKey.Space, InputKey.G }; + rightKeys = new[] { InputKey.Slash, InputKey.L, InputKey.Semicolon, InputKey.Quote, InputKey.Enter, InputKey.BackSlash, InputKey.P }; + } + else if (variant == 16) + { + leftKeys = new[] { InputKey.Tab, InputKey.LControl, InputKey.A, InputKey.S, InputKey.D, InputKey.Space, InputKey.Number5, InputKey.Number6 }; + rightKeys = new[] { InputKey.Number7, InputKey.Number8, InputKey.Slash, InputKey.L, InputKey.Semicolon, InputKey.Quote, InputKey.Enter, InputKey.BackSlash }; + } + else // if (variant == 18) + { + leftKeys = new[] { InputKey.Q, InputKey.W, InputKey.E, InputKey.R, InputKey.A, InputKey.S, InputKey.D, InputKey.F, InputKey.Space }; + rightKeys = new[] { InputKey.Alt, InputKey.L, InputKey.Semicolon, InputKey.Quote, InputKey.Enter, InputKey.O, InputKey.P, InputKey.BracketLeft, InputKey.BracketRight }; + } + // else + // { + // leftKeys = new[] { InputKey.A, InputKey.S, InputKey.D, InputKey.F }; + // rightKeys = new[] { InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + // } } public IEnumerable GenerateMappings() => new VariantMappingGenerator diff --git a/osu.Game.Rulesets.Mania/Skinning/Editor/ManiaEzProSkinEditorVirtualProvider.cs b/osu.Game.Rulesets.Mania/Skinning/Editor/ManiaEzProSkinEditorVirtualProvider.cs new file mode 100644 index 0000000000..7c0963511c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Editor/ManiaEzProSkinEditorVirtualProvider.cs @@ -0,0 +1,352 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Rulesets.UI.Scrolling.Algorithms; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Editor +{ + public partial class ManiaEzProSkinEditorVirtualProvider : ISkinEditorVirtualProvider + { + public Drawable CreateVirtualPlayfield(ISkin skin, IBeatmap beatmap) + { + var transformedSkin = createTransformedSkin(skin); + + float columnWidth0 = getColumnWidth(transformedSkin, 0); + float columnWidth1 = getColumnWidth(transformedSkin, 1); + + // 轻量级 2K:不创建 Stage/Column/ManiaPlayfield(它们会引入 Player/背景等依赖)。 + // 这里只用两列容器(等价于一个简单的 ColumnFlow),每列放一个循环 HoldNote。 + return new SkinProvidingContainer(transformedSkin) + { + RelativeSizeAxes = Axes.Both, + Child = createTwoKeyLayout(new Drawable[] + { + createHoldPreview(looping: true, column: 0, maniaAction: ManiaAction.Key1, width: columnWidth0), + createHoldPreview(looping: true, column: 1, maniaAction: ManiaAction.Key2, width: columnWidth1), + }) + }; + } + + public Drawable CreateCurrentSkinNoteDisplay(ISkin skin) + { + return createSinglePreview(skin, label: "Current"); + } + + public Drawable CreateEditedNoteDisplay(ISkin skin) + { + return createSinglePreview(skin, label: "Edited"); + } + + private static Drawable createSinglePreview(ISkin skin, string label) + { + // 中间对比区目前只做“绘制一致、标题不同”,后续编辑态再替换 skin/transformer 即可。 + var transformedSkin = createTransformedSkin(skin); + + float columnWidth0 = getColumnWidth(transformedSkin, 0); + + return new SkinProvidingContainer(transformedSkin) + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = label, + Colour = Colour4.White, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 220, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.3f), + }, + createHoldPreview(looping: false, column: 0, maniaAction: ManiaAction.Key1, width: columnWidth0) + } + } + } + } + }; + } + + private static float getColumnWidth(ISkin skin, int columnIndex) + { + // For EzPro, column width is provided via ManiaSkinConfigurationLookup. + // Fall back to the default mania column width if not present. + return skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, columnIndex)) + ?.Value ?? Column.COLUMN_WIDTH; + } + + private static ISkin createTransformedSkin(ISkin skin) + { + var ruleset = new ManiaRuleset(); + + // EzPro transformer 要求传入 ManiaBeatmap(会强转)。 + // 该 provider 固定服务 2K 预览,因此直接在 ruleset 内部使用固定 2K beatmap。 + var beatmap = new ManiaBeatmap(new StageDefinition(2)); + return ruleset.CreateSkinTransformer(skin, beatmap) ?? skin; + } + + private static FillFlowContainer createTwoKeyLayout(Drawable[] columns) + { + return new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(6), + Padding = new MarginPadding(10), + Children = columns + }; + } + + private static Container createHoldPreview(bool looping, int column, ManiaAction maniaAction) + { + return new Container + { + RelativeSizeAxes = Axes.Y, + Height = 1, + Width = 0.5f, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.3f), + }, + new HoldNotePreview(looping, column, maniaAction) + { + RelativeSizeAxes = Axes.Both, + } + } + }; + } + + private static Container createHoldPreview(bool looping, int column, ManiaAction maniaAction, float width) + { + return new Container + { + RelativeSizeAxes = Axes.Y, + Height = 1, + Width = width, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.3f), + }, + new HoldNotePreview(looping, column, maniaAction) + { + RelativeSizeAxes = Axes.Both, + } + } + }; + } + + private sealed partial class HoldNotePreview : CompositeDrawable + { + private const double time_range = 2000; + private const double cycle_length = time_range; + + private readonly bool looping; + private readonly int column; + private readonly Bindable action; + private readonly PreviewScrollingInfo scrollingInfo = new PreviewScrollingInfo(); + private readonly Column columnDependency; + private readonly StageDefinition stageDefinition = new StageDefinition(2); + + private readonly StopwatchClock playbackClock = new StopwatchClock(true); + private readonly ManualClock manualClock = new ManualClock(); + + private DrawableHoldNote drawableHoldNote = null!; + + private Container judgementArea = null!; + + private double staticPreviewTime; + + public HoldNotePreview(bool looping, int column, ManiaAction maniaAction) + { + this.looping = looping; + this.column = column; + action = new Bindable(maniaAction); + + // EzPro 的 note 组件在加载时需要解析 Column / StageDefinition。 + // 这里提供最小的 2K column/stage 依赖(不进入场景树,仅用于依赖注入)。 + columnDependency = new Column(column, isSpecial: false); + columnDependency.Action.Value = maniaAction; + + RelativeSizeAxes = Axes.Both; + + // Drive time manually to: + // - loop previews without lifetime expiry + // - keep centre previews completely static + Clock = new FramedClock(manualClock); + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(scrollingInfo); + + dependencies.Cache(stageDefinition); + dependencies.Cache(columnDependency); + dependencies.CacheAs>(columnDependency.Action); + return dependencies; + } + + [BackgroundDependencyLoader] + private void load() + { + scrollingInfo.Set(ScrollingDirection.Down, time_range, new ConstantScrollAlgorithm()); + + var hold = new HoldNote + { + StartTime = time_range, + Duration = 1200, + Column = column, + }; + hold.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + // Centre preview should be static and always visible. + // Freeze time at the hitobject start time and do not animate. + staticPreviewTime = hold.StartTime; + manualClock.CurrentTime = looping ? 0 : staticPreviewTime; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 20, Vertical = 10 }, + Children = new Drawable[] + { + judgementArea = new Container + { + RelativeSizeAxes = Axes.None, + BorderThickness = 2, + BorderColour = Colour4.Green.Opacity(0.6f), + Masking = true, + Depth = 1, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Green.Opacity(0.18f), + } + }, + drawableHoldNote = new DrawableHoldNote(hold) + { + RelativeSizeAxes = Axes.Both, + } + } + } + }; + } + + protected override void Update() + { + base.Update(); + + if (drawableHoldNote == null) + return; + + if (!looping) + { + // Static centre preview: no movement, no alpha gating, no time progression. + manualClock.CurrentTime = staticPreviewTime; + drawableHoldNote.Y = 0; + drawableHoldNote.Alpha = 1; + updateJudgementArea(); + return; + } + + double currentTime; + + if (looping) + currentTime = playbackClock.CurrentTime % cycle_length; + else + currentTime = staticPreviewTime; + + manualClock.CurrentTime = currentTime; + + // Scroll the whole hold note. Nested hitobjects are positioned by DrawableHoldNote itself based on IScrollingInfo. + double finalPosition = (drawableHoldNote.HitObject.StartTime - currentTime) / scrollingInfo.TimeRange.Value; + float scrollLength = DrawHeight; + // Map [0..1] to [0..H] so motion is visible across full height. + drawableHoldNote.Y = (float)((1 - finalPosition) * scrollLength); + drawableHoldNote.Alpha = finalPosition >= 0 && finalPosition <= 1 ? 1 : 0; + + updateJudgementArea(); + } + + private void updateJudgementArea() + { + // 用户要求:底色 box 对齐到 holdnote head 底部与 tail 底部,宽度略大,且在 holdnote 下方。 + var headRect = drawableHoldNote.Head.DrawRectangle; + var tailRect = drawableHoldNote.Tail.DrawRectangle; + + var holdRect = drawableHoldNote.DrawRectangle; + + float startY = headRect.Height > 0 ? headRect.Bottom : holdRect.Top; + float endY = tailRect.Height > 0 ? tailRect.Bottom : holdRect.Bottom; + + if (endY < startY) + (startY, endY) = (endY, startY); + + const float extra_width = 12; + + judgementArea.X = -extra_width / 2; + judgementArea.Y = startY; + judgementArea.Width = drawableHoldNote.DrawWidth + extra_width; + judgementArea.Height = Math.Max(0, endY - startY); + } + + private sealed class PreviewScrollingInfo : IScrollingInfo + { + private readonly Bindable direction = new Bindable(); + private readonly Bindable timeRange = new Bindable(); + private readonly Bindable algorithm = new Bindable(); + + public IBindable Direction => direction; + public IBindable TimeRange => timeRange; + public IBindable Algorithm => algorithm; + + public void Set(ScrollingDirection direction, double timeRange, IScrollAlgorithm algorithm) + { + this.direction.Value = direction; + this.timeRange.Value = timeRange; + this.algorithm.Value = algorithm; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2ColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2ColumnBackground.cs new file mode 100644 index 0000000000..50ad1cf766 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2ColumnBackground.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Screens; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public partial class Ez2ColumnBackground : CompositeDrawable, IKeyBindingHandler + { + private readonly Bindable overlayHeight = new Bindable(); + private Bindable hitPosition = new Bindable(); + private Color4 brightColour; + private Color4 dimColour; + + private Box background = null!; + private Box backgroundOverlay = null!; + private Box separator = new Box(); + private Bindable accentColour = null!; + + [Resolved] + private Column column { get; set; } = null!; + + [Resolved] + private StageDefinition stageDefinition { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + public Ez2ColumnBackground() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + RelativeSizeAxes = Axes.Both; + // Masking = true; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + Name = "Background", + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.8f), + }, + backgroundOverlay = new Box + { + Name = "Background Gradient Overlay", + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Blending = BlendingParameters.Additive, + Alpha = 0 + }, + } + }; + + separator = new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopCentre, + Width = 2, + Colour = Color4.White.Opacity(0.5f), + Alpha = 0, + }; + column.TopLevelContainer.Add(separator); + + overlayHeight.BindValueChanged(height => backgroundOverlay.Height = height.NewValue, true); + + accentColour = new Bindable(DrawColoursForColumns(column.Index, stageDefinition)); + accentColour.BindValueChanged(colour => + { + var newColour = colour.NewValue.Darken(3); + + if (newColour.A != 0) + { + newColour = newColour.Opacity(0.8f); + } + + backgroundOverlay.Colour = newColour; + background.Colour = colour.NewValue.Opacity(0.8f).Darken(3); + brightColour = colour.NewValue.Opacity(0.6f); + dimColour = colour.NewValue.Opacity(0); + }, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + hitPosition = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + hitPosition.BindValueChanged(_ => OnConfigChanged(), true); + } + + private void OnConfigChanged() + { + separator.Height = DrawHeight - (float)hitPosition.Value; + + if (drawSeparator(column.Index, stageDefinition)) + { + separator.Alpha = 0.2f; + } + else + { + separator.Alpha = 0; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == column.Action.Value) + { + var noteColour = column.AccentColour.Value; + brightColour = noteColour.Opacity(0.9f); + dimColour = noteColour.Opacity(0); + + backgroundOverlay.Colour = ColourInfo.GradientVertical(dimColour, brightColour); + + overlayHeight.Value = 0.5f; + + backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == column.Action.Value) + backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); + } + + public static Color4 DrawColoursForColumns(int columnIndex, StageDefinition stage) + { + return stage.EzGetColumnColor(columnIndex); + } + + //TODO: 这里的逻辑可以优化,避免重复计算 + private bool drawSeparator(int columnIndex, StageDefinition stage) + { + return stage.Columns switch + { + 12 => columnIndex is 0 or 10, + 14 => columnIndex is 0 or 5 or 6 or 11, + 16 => columnIndex is 0 or 5 or 9 or 14, + _ => false + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitExplosion.cs new file mode 100644 index 0000000000..4cd86f81df --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitExplosion.cs @@ -0,0 +1,101 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + internal partial class Ez2HitExplosion : Ez2HitTarget, IHitExplosion + { + public override bool RemoveWhenNotAlive => true; + + [Resolved] + private Column column { get; set; } = null!; + + private readonly IBindable direction = new Bindable(); + + private Container largeFaint = null!; + + private Bindable accentColour = null!; + + public Ez2HitExplosion() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + // Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + Size = new Vector2(2); + Alpha = 1; + + InternalChildren = new Drawable[] + { + largeFaint = new Container + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Masking = true, + Scale = new Vector2(0.5f), + Child = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + // Scale = new Vector2(0.2f), + Alpha = 0.8f, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(1f, 1f, 1f, 0.5f), + Radius = 2f, // 调整光晕半径 + Roundness = 0f, + } + }, + }, + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + largeFaint.Colour = Interpolation.ValueAt(0.8f, colour.NewValue, Color4.White, 0, 1); + + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour.NewValue, + Roundness = NoteHeight, + Radius = 50, + }; + }, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Anchor = Origin = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + } + + public void Animate(JudgementResult result) + { + this.FadeOutFromOne(PoolableHitExplosion.DURATION, Easing.Out); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitTarget.cs new file mode 100644 index 0000000000..5069ffac2b --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HitTarget.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Play; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + internal partial class Ez2HitTarget : Ez2NotePiece + { + private readonly IBindable direction = new Bindable(); + + private double bpm; + + [Resolved] + private IBeatmap beatmap { get; set; } = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + RelativeSizeAxes = Axes.X; + // Masking = true; + Height = NoteHeight * NOTE_ACCENT_RATIO; + CornerRadius = NoteHeight; + Alpha = 0.3f; + Blending = BlendingParameters.Mixture; + Colour = Color4.Gray; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + bpm = beatmap.ControlPointInfo.TimingPointAt(gameplayClock.CurrentTime).BPM * gameplayClock.GetTrueGameplayRate(); + } + + protected override void Update() + { + base.Update(); + Height = DrawWidth; + + double interval = 60000 / bpm; + const double amplitude = 6.0; + double progress = (gameplayClock.CurrentTime % interval) / interval; + + double smoothValue = smoothSineWave(progress); + Y = (float)(smoothValue * amplitude); + } + + private double smoothSineWave(double t) + { + const double frequency = 1; + const double amplitude = 0.3; + return amplitude * Math.Sin(frequency * t * 2 * Math.PI); + } + // double elasticValue = elasticEaseOut(progress); + // Y = (float)(elasticValue * amplitude); + // } + // + // private double elasticEaseOut(double t) //弹性缓动函数 + // { + // double p = 0.3; + // return Math.Pow(2, -10 * t) * Math.Sin((t - p / 4) * (2 * Math.PI) / p) + 1; + // } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Anchor = Origin = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldBodyPiece.cs new file mode 100644 index 0000000000..08565f5534 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldBodyPiece.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public partial class Ez2HoldBodyPiece : CompositeDrawable, IHoldNoteBody + { + protected readonly Bindable AccentColour = new Bindable(); + + private Drawable background = null!; + private Container tailContainer = null!; + + private Ez2HoldNoteHittingLayer hittingLayer = null!; + + public Ez2HoldBodyPiece() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Masking = true; + Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.35f), Color4.White.Opacity(1.0f)); + } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject? drawableObject) + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = 1f, + Alpha = 1, + }, + tailContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + CornerRadius = 0, + Height = CornerRadius, + Masking = true, + // Colour = ColourInfo.GradientVertical(Color4.White.Opacity(1f), Color4.White.Opacity(0f)), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + // Colour = ColourInfo.GradientVertical(Color4.White.Opacity(1f), Color4.White.Opacity(1f)), + } + } + } + }, + hittingLayer = new Ez2HoldNoteHittingLayer(this) + }; + + if (drawableObject != null) + { + var holdNote = (DrawableHoldNote)drawableObject; + + AccentColour.BindTo(holdNote.AccentColour); + hittingLayer.AccentColour.BindTo(holdNote.AccentColour); + ((IBindable)hittingLayer.IsHitting).BindTo(holdNote.IsHolding); + } + + AccentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.0f).Opacity(1f); + tailContainer.Colour = ColourInfo.GradientVertical(colour.NewValue.Opacity(1f), colour.NewValue.Opacity(1f)); + // background.Colour = new ColourInfo + // { + // TopLeft = colour.NewValue.Opacity(1.0f), + // TopRight = colour.NewValue.Opacity(1.0f), + // BottomLeft = colour.NewValue.Opacity(1.0f), + // BottomRight = colour.NewValue.Opacity(0.05f) + // }; + }, true); + } + + protected override void Update() + { + base.Update(); + background.Height = 1f - DrawWidth / 2; + tailContainer.CornerRadius = DrawWidth / 2; + tailContainer.Height = DrawWidth / 2; + } + + public void UpdateAppearance(Color4 startColour, Color4 endColour, float alpha) + { + this.FadeColour(ColourInfo.GradientVertical(startColour, endColour), 200, Easing.OutQuint); + this.FadeTo(alpha, 200, Easing.OutQuint); + } + + public void Recycle() + { + hittingLayer.Recycle(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHeadPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHeadPiece.cs new file mode 100644 index 0000000000..12acd0f092 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHeadPiece.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + internal partial class Ez2HoldNoteHeadPiece : Ez2NotePiece + { + protected override Drawable CreateIcon() => new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(20, 5), + Y = 0, + Rotation = 0, + }; + + protected override void Update() + { + base.Update(); + Height = DrawWidth; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHittingLayer.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHittingLayer.cs new file mode 100644 index 0000000000..ac32ed8d65 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteHittingLayer.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public partial class Ez2HoldNoteHittingLayer : CompositeDrawable + { + public readonly Bindable AccentColour = new Bindable(); + public readonly Bindable IsHitting = new Bindable(); + + private readonly Ez2HoldBodyPiece bodyPiece; + + public Ez2HoldNoteHittingLayer(Ez2HoldBodyPiece bodyPiece) + { + this.bodyPiece = bodyPiece; + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + Blending = BlendingParameters.Mixture; + Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // AccentColour.BindValueChanged(colour => + // { + // Colour = colour.NewValue.Lighten(0.2f).Opacity(0.3f); + // }, true); + + IsHitting.BindValueChanged(hitting => + { + const float animation_length = 80; + + ClearTransforms(); + + if (hitting.NewValue) + { + double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); + + using (BeginDelayedSequence(synchronisedOffset)) + { + this.FadeTo(1f, animation_length, Easing.OutSine).Then() + .FadeTo(0.6f, animation_length, Easing.InSine) + .Loop(); + // Colour = ColourInfo.GradientVertical(AccentColour.Value.Opacity(1f), Color4.Gray.Opacity(0f)); + } + + this.FadeIn(animation_length); + bodyPiece.UpdateAppearance(Color4.White.Opacity(0.8f), Color4.Gray.Darken(0.3f).Opacity(0.5f), 0.9f); + } + else + { + this.FadeOut(animation_length); + bodyPiece.UpdateAppearance(AccentColour.Value.Opacity(1f), AccentColour.Value.Opacity(1f), 1f); + } + }, true); + } + + public void Recycle() + { + ClearTransforms(); + Alpha = 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteTailPiece.cs new file mode 100644 index 0000000000..77bde4cced --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2HoldNoteTailPiece.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + internal partial class Ez2HoldNoteTailPiece : CompositeDrawable + { + [Resolved] + private DrawableHitObject? drawableObject { get; set; } + + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box foreground; + + // private readonly Ez2HoldNoteHittingLayer hittingLayer; + private readonly Box foregroundAdditive; + + public Ez2HoldNoteTailPiece() + { + RelativeSizeAxes = Axes.X; + Height = 0f; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = 0, + CornerRadius = Ez2NotePiece.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Colour4.Black), + // Avoid ugly single pixel overlap. + Height = 0.9f, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = 0, + CornerRadius = Ez2NotePiece.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] + { + foreground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + // hittingLayer = new Ez2HoldNoteHittingLayer(), + foregroundAdditive = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Height = 0.5f, + }, + }, + }, + } + }, + }; + } + + [BackgroundDependencyLoader(true)] + private void load(IScrollingInfo scrollingInfo) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + + drawableObject.HitObjectApplied += hitObjectApplied; + } + } + + private void hitObjectApplied(DrawableHitObject drawableHitObject) + { + // var holdNoteTail = (DrawableHoldNoteTail)drawableHitObject; + + // hittingLayer.Recycle(); + // + // hittingLayer.AccentColour.UnbindBindings(); + // hittingLayer.AccentColour.BindTo(holdNoteTail.HoldNote.AccentColour); + // + // hittingLayer.IsHitting.UnbindBindings(); + // ((IBindable)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHitting); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Up ? -1 : 1); + } + + private void onAccentChanged(ValueChangedEvent accent) + { + foreground.Colour = accent.NewValue.Darken(0.6f); // matches body + + foregroundAdditive.Colour = ColourInfo.GradientVertical( + accent.NewValue.Opacity(0.4f), + accent.NewValue.Opacity(0) + ); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.HitObjectApplied -= hitObjectApplied; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2JudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2JudgementPiece.cs new file mode 100644 index 0000000000..6e9135448d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2JudgementPiece.cs @@ -0,0 +1,326 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public partial class Ez2JudgementPiece : EzJudgementText, IAnimatableJudgement + { + internal const float JUDGEMENT_Y_POSITION = 140; + + private RingExplosion? ringExplosion; + + // [Resolved] + // public double TimeOffset { get; set; } + + public Ez2JudgementPiece(HitResult result) + : base(result) + { + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + Y = JUDGEMENT_Y_POSITION; + } + + public Ez2JudgementPiece() + { + } + + [BackgroundDependencyLoader] + private void load() + { + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = Color4.White, + }); + } + // updateOffsetText(Result); + } + + protected override SpriteText CreateJudgementText() => + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.White, + Blending = BlendingParameters.Additive, + Spacing = new Vector2(2, 0), + Font = OsuFont.Default.With(size: 22, weight: FontWeight.Regular), + AllowMultiline = true, + }; + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + const float flash_speed = 60f; // 定义颜色闪烁速度变量 + + switch (Result) + { + case HitResult.Miss: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + this.MoveToY(JUDGEMENT_Y_POSITION); + + applyFadeEffect(this, new[] { Color4.Red, Color4.IndianRed }, flash_speed); + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 300, new Vector2(1.5f, 0.1f), 300, 300); + break; + + case HitResult.Meh: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + this.MoveToY(JUDGEMENT_Y_POSITION); + + applyFadeEffect(this, new[] { Color4.Purple, Color4.MediumPurple }, flash_speed); + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 300, new Vector2(1.5f, 0.1f), 300, 300); + break; + + case HitResult.Ok: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + this.MoveToY(JUDGEMENT_Y_POSITION); + + applyFadeEffect(this, new[] { Color4.ForestGreen, Color4.SeaGreen }, flash_speed); + applyScaleAndFadeOutEffect(this, new Vector2(1.3f), 200, new Vector2(1.3f, 0.1f), 400, 400); + break; + + case HitResult.Good: + this.MoveToY(JUDGEMENT_Y_POSITION); + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + + applyFadeEffect(this, new[] { Color4.Green, Color4.LightGreen }, flash_speed); + applyScaleAndFadeOutEffect(this, new Vector2(1.3f), 200, new Vector2(1.3f, 0.1f), 400, 400); + break; + + case HitResult.Great: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.5f), 1800, Easing.OutQuint); + + applyFadeEffect(this, new[] { Color4.AliceBlue, Color4.LightSkyBlue }, flash_speed); + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 100, new Vector2(1.5f, 0.1f), 500, 500); + break; + + case HitResult.Perfect: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.5f), 1800, Easing.OutQuint); + + applyFadeEffect(this, new[] { Color4.LightBlue, Color4.LightGreen }, flash_speed); + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 100, new Vector2(1.5f, 0.1f), 500, 500); + break; + } + + ringExplosion?.PlayAnimation(flash_speed); + } + + private void applyFadeEffect(Drawable drawable, Color4[] colors, double flashSpeed) + { + var sequence = drawable.FadeColour(colors[0], flashSpeed, Easing.OutQuint); + + for (int i = 1; i < colors.Length; i++) + { + sequence = sequence.Then().FadeColour(colors[i], flashSpeed, Easing.OutQuint); + } + + sequence.Loop(); + } + + private void applyScaleAndFadeOutEffect(Drawable drawable, Vector2 scaleUp, double scaleUpDuration, Vector2 scaleDown, double scaleDownDuration, double fadeOutDuration) + { + drawable.ScaleTo(scaleUp, scaleUpDuration, Easing.OutQuint).Then() + .ScaleTo(scaleDown, scaleDownDuration, Easing.InQuint) + .FadeOut(fadeOutDuration, Easing.InQuint); + } + + public class GlowEffect : IEffect + { + public float Strength = 1f; + public Vector2 BlurSigma = Vector2.One; + public ColourInfo Colour = Color4.White; + public bool PadExtent; + + public BufferedContainer ApplyTo(Drawable drawable) + { + return new BufferedContainer + { + RelativeSizeAxes = Axes.Both, Child = drawable.WithEffect(new BlurEffect + { + Strength = Strength, Sigma = BlurSigma, Colour = Colour, PadExtent = PadExtent, DrawOriginal = true, + }) + }; + } + } + + public BufferedContainer ApplyGlowEffect(Drawable drawable, Color4 glowColor) + { + var glowEffect = new GlowEffect { Colour = glowColor }; + return glowEffect.ApplyTo(drawable); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => null; + + private partial class RingExplosion : CompositeDrawable + { + private readonly float travel = 52; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 6; + const float large_size = 10; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 1; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 2; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 2; + countLarge = 3; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation(float flashSpeed) + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.3f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + + public partial class RingPiece : CircularContainer + { + public RingPiece(float thickness = 9) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderThickness = thickness; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + } + } + } +} + +// private ScoreProcessor processor => null!; +// +// protected override void LoadComplete() +// { +// base.LoadComplete(); +// processor.NewJudgement += processorNewJudgement; +// } +// +// protected override void Dispose(bool isDisposing) +// { +// base.Dispose(isDisposing); +// +// if (true) +// processor.NewJudgement -= processorNewJudgement; +// } +// +// private void processorNewJudgement(JudgementResult j) => Schedule(() => OnNewJudgement(j)); +// +// private void updateOffsetText(HitResult result) +// { +// if (result != HitResult.Perfect) +// { +// SpriteText offsetText = new OsuSpriteText +// { +// Anchor = TimeOffset < 0 ? Anchor.BottomLeft : Anchor.BottomRight, +// Origin = Anchor.TopCentre, +// Blending = BlendingParameters.Additive, +// Font = OsuFont.Default.With(size: 10, weight: FontWeight.Regular), +// Colour = Color4.White, +// Text = TimeOffset.ToString("F1"), +// }; +// AddInternal(offsetText); +// } +// } +// +// protected void OnNewJudgement(JudgementResult judgement) +// { +// if (!judgement.IsHit || judgement.HitObject.HitWindows?.WindowFor(HitResult.Miss) == 0) +// return; +// +// if (!judgement.Type.IsScorable() || judgement.Type.IsBonus()) +// return; +// +// TimeOffset = judgement.TimeOffset; +// } diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyArea.cs new file mode 100644 index 0000000000..fdb4f98751 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyArea.cs @@ -0,0 +1,252 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens; +using osu.Game.Screens.Play; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public partial class Ez2KeyArea : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + private readonly Bindable hitPosition = new Bindable(); + private Container directionContainer = null!; + private Drawable background = null!; + + private Circle hitTargetLine = null!; + + private CircularContainer? topIcon; + private Bindable accentColour = null!; + + [Resolved] + private Column column { get; set; } = null!; + + [Resolved] + private IBeatmap beatmap { get; set; } = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + public Ez2KeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + hitPosition.Value = (float)ezSkinConfig.GetBindable(Ez2Setting.HitPosition).Value; + + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = hitPosition.Value, + Children = new Drawable[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + CornerRadius = Ez2NotePiece.CORNER_RADIUS, + Child = background = new Box + { + Name = "Key gradient", + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + hitTargetLine = new Circle + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = OsuColour.Gray(196 / 255f), + Height = Ez2NotePiece.CORNER_RADIUS * 2, + Masking = true, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Container + { + Name = "Icons", + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + topIcon = new CircularContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = 60, + Size = new Vector2(22, 14), + Masking = true, + BorderThickness = 4, + BorderColour = Color4.White, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + }, + }, + }, + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.2f); + topIcon.Colour = colour.NewValue; + }, + true); + + column.TopLevelContainer.Add(CreateProxy()); + } + + private double beatInterval; + + protected override void LoadComplete() + { + base.LoadComplete(); + + double bpm = beatmap.BeatmapInfo.BPM * gameplayClock.GetTrueGameplayRate(); + beatInterval = 60000 / bpm; + } + + protected override void Update() + { + base.Update(); + + if (topIcon == null || !topIcon.Children.Any()) + return; + + double progress = (gameplayClock.CurrentTime % beatInterval) / beatInterval; + + if (progress < gameplayClock.ElapsedFrameTime / beatInterval) + { + double fadeTime = Math.Max(1, beatInterval / 2); + var box = topIcon.Children.OfType().FirstOrDefault(); + + box?.FadeTo(1, fadeTime) + .Then() + .FadeTo(0, fadeTime); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + switch (direction.NewValue) + { + case ScrollingDirection.Up: + directionContainer.Scale = new Vector2(1, -1); + directionContainer.Anchor = Anchor.TopCentre; + directionContainer.Origin = Anchor.BottomCentre; + break; + + case ScrollingDirection.Down: + directionContainer.Scale = new Vector2(1, 1); + directionContainer.Anchor = Anchor.BottomCentre; + directionContainer.Origin = Anchor.BottomCentre; + break; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action != column.Action.Value) return false; + + const double lighting_fade_in_duration = 70; + Color4 lightingColour = getLightingColour(); + + background + .FlashColour(accentColour.Value.Lighten(0.8f), 200, Easing.OutQuint) + .FadeTo(1, lighting_fade_in_duration, Easing.OutQuint) + .Then() + .FadeTo(0.8f, 500); + + hitTargetLine.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.4f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + topIcon.ScaleTo(0.9f, lighting_fade_in_duration, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.1f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + topIcon.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action != column.Action.Value) return; + + const double lighting_fade_out_duration = 800; + + Color4 lightingColour = getLightingColour().Opacity(0); + + // background fades out faster than lighting elements to give better definition to the player. + background.FadeTo(0.3f, 50, Easing.OutQuint) + .Then() + .FadeOut(lighting_fade_out_duration, Easing.OutQuint); + + topIcon.ScaleTo(1f, 200, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 20, + }, lighting_fade_out_duration, Easing.OutQuint); + + hitTargetLine.FadeColour(OsuColour.Gray(196 / 255f), lighting_fade_out_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 25, + }, lighting_fade_out_duration, Easing.OutQuint); + + topIcon.FadeColour(accentColour.Value, lighting_fade_out_duration, Easing.OutQuint); + } + + private Color4 getLightingColour() => Interpolation.ValueAt(0.2f, accentColour.Value, Color4.White, 0, 1); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyAreaPlus.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyAreaPlus.cs new file mode 100644 index 0000000000..a92c71f9e1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2KeyAreaPlus.cs @@ -0,0 +1,297 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Play; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public partial class Ez2KeyAreaPlus : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer = null!; + private Drawable background = null!; + + private Circle hitTargetLine = null!; + + private Container bottomIcon = null!; + private CircularContainer topIcon = null!; + private Bindable accentColour = null!; + + [Resolved] + private Column column { get; set; } = null!; + + [Resolved] + private IBeatmap beatmap { get; set; } = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + public Ez2KeyAreaPlus() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + const float icon_circle_size = 8; + const float icon_spacing = 7; + const float icon_vertical_offset = -30; + + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = Stage.HIT_TARGET_POSITION + Ez2NotePiece.CORNER_RADIUS * 2, + Children = new Drawable[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + CornerRadius = Ez2NotePiece.CORNER_RADIUS, + Child = background = new Box + { + Name = "Key gradient", + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + new EzKeyCounterPro(column.Action.Value) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.TopCentre, + Y = 10, // 调整计数器位置 + }, + hitTargetLine = new Circle + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = OsuColour.Gray(196 / 255f), + Height = Ez2NotePiece.CORNER_RADIUS * 2, + Masking = true, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Container + { + Name = "Icons", + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + bottomIcon = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Y = icon_vertical_offset + 5, + Children = new[] + { + new Circle + { + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = -icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + } + }, + topIcon = new CircularContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = -icon_vertical_offset + 35, + Size = new Vector2(22, 14), + Masking = true, + BorderThickness = 4, + BorderColour = Color4.White, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + }, + }, + }, + } + }; + + // double bpm = beatmap.BeatmapInfo.BPM; + double bpm = beatmap.ControlPointInfo.TimingPointAt(gameplayClock.CurrentTime).BPM * gameplayClock.GetTrueGameplayRate(); + applyBlinkingEffect(topIcon, bpm); + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.2f); + bottomIcon.Colour = colour.NewValue; + }, + true); + + column.TopLevelContainer.Add(CreateProxy()); + } + + private void applyBlinkingEffect(CircularContainer container, double bpm) + { + double interval = 60000 / bpm; + + Scheduler.AddDelayed(() => + { + container.Children.OfType().First().FadeTo(1, interval / 2).Then().FadeTo(0, interval / 2); + }, interval, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + switch (direction.NewValue) + { + case ScrollingDirection.Up: + directionContainer.Scale = new Vector2(1, -1); + directionContainer.Anchor = Anchor.TopCentre; + directionContainer.Origin = Anchor.BottomCentre; + break; + + case ScrollingDirection.Down: + directionContainer.Scale = new Vector2(1, 1); + directionContainer.Anchor = Anchor.BottomCentre; + directionContainer.Origin = Anchor.BottomCentre; + break; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action != column.Action.Value) return false; + + const double lighting_fade_in_duration = 70; + Color4 lightingColour = getLightingColour(); + + background + .FlashColour(accentColour.Value.Lighten(0.8f), 200, Easing.OutQuint) + .FadeTo(1, lighting_fade_in_duration, Easing.OutQuint) + .Then() + .FadeTo(0.8f, 500); + + hitTargetLine.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.4f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + topIcon.ScaleTo(0.9f, lighting_fade_in_duration, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.1f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + bottomIcon.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.2f), + Radius = 60, + }, lighting_fade_in_duration, Easing.OutQuint); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action != column.Action.Value) return; + + const double lighting_fade_out_duration = 800; + + Color4 lightingColour = getLightingColour().Opacity(0); + + // background fades out faster than lighting elements to give better definition to the player. + background.FadeTo(0.3f, 50, Easing.OutQuint) + .Then() + .FadeOut(lighting_fade_out_duration, Easing.OutQuint); + + topIcon.ScaleTo(1f, 200, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 20, + }, lighting_fade_out_duration, Easing.OutQuint); + + hitTargetLine.FadeColour(OsuColour.Gray(196 / 255f), lighting_fade_out_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 25, + }, lighting_fade_out_duration, Easing.OutQuint); + + bottomIcon.FadeColour(accentColour.Value, lighting_fade_out_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 30, + }, lighting_fade_out_duration, Easing.OutQuint); + } + } + + private Color4 getLightingColour() => Interpolation.ValueAt(0.2f, accentColour.Value, Color4.White, 0, 1); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2NotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2NotePiece.cs new file mode 100644 index 0000000000..2c17a4dda9 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2NotePiece.cs @@ -0,0 +1,169 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + internal partial class Ez2NotePiece : CompositeDrawable + { + public static float NoteHeight = 45; + public const float NOTE_ACCENT_RATIO = 1f; + public const float CORNER_RADIUS = 0; + + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Circle colouredBox; + + public Ez2NotePiece() + { + RelativeSizeAxes = Axes.X; + + CornerRadius = CORNER_RADIUS; + // Masking = true; + + InternalChildren = new[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new Circle + { + Masking = true, + RelativeSizeAxes = Axes.Both, + BorderThickness = 4, + // BorderColour = Color4.White.Opacity(1f), + // BorderColour = ColourInfo.GradientVertical(Color4.White.Opacity(0), Colour4.Black), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Colour4.Black.Opacity(1f), + Radius = 1, // 调整描边的宽度 + Roundness = 0 // 调整描边的圆角程度 + } + } + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + // Masking = true, + // CornerRadius = CORNER_RADIUS, + Children = new Drawable[] + { + colouredBox = new Circle + { + RelativeSizeAxes = Axes.Both, + BorderThickness = 4, + BorderColour = Color4.White.Opacity(0.7f), + // BorderThickness = 2, + // Alpha = 0.5f, + //Blending = BlendingParameters.Additive, + } + } + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 0, + }, + CreateIcon(), + }; + } + + // private readonly ManiaRulesetConfigManager config; + // private float columnWidth; + // private float specialFactor; + + protected override void Update() + { + base.Update(); + Height = DrawWidth; + // NoteHeight = columnWidth; + // NoteHeight = (float)config.Get(ManiaRulesetSetting.ColumnWidth); + // specialFactor = (float)config.Get(ManiaRulesetSetting.SpecialFactor); + + CreateIcon().Size = new Vector2(DrawWidth / NoteHeight * 0.7f); + } + + protected virtual Drawable CreateIcon() => new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(80), + Y = 0, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.3f), + Masking = true, + Child = new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.5f), + EdgeEffect = new EdgeEffectParameters() + } + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Circle, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.225f), + Colour = Colour4.White.Opacity(0.8f), + } + } + }; + + [BackgroundDependencyLoader(true)] + private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + colouredBox.Anchor = colouredBox.Origin = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopCentre + : Anchor.BottomCentre; + + Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Up ? -1 : 1); + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = ColourInfo.GradientVertical( + accent.NewValue.Lighten(0.1f), + accent.NewValue + ); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2StageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2StageBackground.cs new file mode 100644 index 0000000000..404f750279 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/Ez2StageBackground.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public partial class Ez2StageBackground : CompositeDrawable + { + public Ez2StageBackground() + { + RelativeSizeAxes = Axes.Both; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2/ManiaEz2SkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2/ManiaEz2SkinTransformer.cs new file mode 100644 index 0000000000..76709dac8a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2/ManiaEz2SkinTransformer.cs @@ -0,0 +1,267 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Skinning.Ez2HUD; +using osu.Game.Rulesets.Mania.Skinning.EzStylePro; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2 +{ + public class ManiaEz2SkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + private readonly IBindable columnWidthBindable; + private readonly IBindable specialFactorBindable; + private readonly IBindable hitPosition; + + //EzSkinSettings即使不用也不能删,否则特殊列计算会出错 + public ManiaEz2SkinTransformer(ISkin skin, IBeatmap beatmap, Ez2ConfigManager ezSkinConfig) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + + // this.config = config ?? throw new ArgumentNullException(nameof(config)); + // this.ezSkinSettings = ezSkinSettings ?? throw new ArgumentNullException(nameof(ezSkinSettings)); + + columnWidthBindable = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + specialFactorBindable = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + hitPosition = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + case GlobalSkinnableContainerLookup containerLookup: + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var hitTiming = container.ChildrenOfType().ToArray(); + + if (hitTiming.Length >= 2) + { + var hitTiming1 = hitTiming[0]; + var hitTiming2 = hitTiming[1]; + const float mirror_x = 500; + + hitTiming1.Anchor = Anchor.Centre; + hitTiming1.Origin = Anchor.Centre; + hitTiming1.X = -mirror_x; + // hitTiming1.Scale = new Vector2(2); + hitTiming1.AloneShow.Value = AloneShowMenu.Early; + + hitTiming2.Anchor = Anchor.Centre; + hitTiming2.Origin = Anchor.Centre; + hitTiming2.X = mirror_x; + // hitTiming2.Scale = new Vector2(2); + hitTiming2.AloneShow.Value = AloneShowMenu.Late; + } + + var comboSprite = container.ChildrenOfType().FirstOrDefault(); + + if (comboSprite != null) + { + comboSprite.Anchor = Anchor.TopCentre; + comboSprite.Origin = Anchor.Centre; + comboSprite.Y = 190; + } + + var combos = container.ChildrenOfType().ToArray(); + + if (combos.Length >= 2) + { + var combo1 = combos[0]; + var combo2 = combos[1]; + + combo1.Anchor = Anchor.TopCentre; + combo1.Origin = Anchor.TopCentre; + combo1.Y = 200; + combo1.BoxAlpha.Value = 0.8f; + combo1.EffectStartFactor.Value = 1.5f; + combo1.EffectEndFactor.Value = 1f; + combo1.EffectStartTime.Value = 10; + combo1.EffectEndDuration.Value = 500; + + combo2.Anchor = Anchor.TopCentre; + combo2.Origin = Anchor.TopCentre; + combo2.Y = 200; + combo2.BoxAlpha.Value = 0.4f; + combo2.EffectStartFactor.Value = 3f; + combo2.EffectEndFactor.Value = 1f; + combo2.EffectStartTime.Value = 10; + combo2.EffectEndDuration.Value = 300; + } + + var keyCounter = container.ChildrenOfType().FirstOrDefault(); + var columnHitErrorMeter = container.OfType().FirstOrDefault(); + + if (keyCounter != null) + { + keyCounter.Anchor = Anchor.BottomCentre; + keyCounter.Origin = Anchor.TopCentre; + keyCounter.Position = new Vector2(0, -(float)hitPosition.Value - stage_padding_bottom); + } + + if (columnHitErrorMeter != null) + { + columnHitErrorMeter.Anchor = Anchor.BottomCentre; + columnHitErrorMeter.Origin = Anchor.Centre; + columnHitErrorMeter.Position = new Vector2(0, -(float)hitPosition.Value - stage_padding_bottom); + } + + var hitErrorMeter = container.OfType().FirstOrDefault(); + + if (hitErrorMeter != null) + { + hitErrorMeter.Anchor = Anchor.Centre; + hitErrorMeter.Origin = Anchor.Centre; + hitErrorMeter.Rotation = -90f; + hitErrorMeter.Position = new Vector2(0, -15); + hitErrorMeter.Scale = new Vector2(1.4f, 1.4f); + hitErrorMeter.JudgementLineThickness.Value = 2; + hitErrorMeter.ShowMovingAverage.Value = true; + hitErrorMeter.ColourBarVisibility.Value = false; + hitErrorMeter.CentreMarkerStyle.Value = BarHitErrorMeter.CentreMarkerStyles.Circle; + hitErrorMeter.LabelStyle.Value = BarHitErrorMeter.LabelStyles.None; + } + + var judgementPiece = container.OfType().FirstOrDefault(); + + if (judgementPiece != null) + { + judgementPiece.Anchor = Anchor.Centre; + judgementPiece.Origin = Anchor.Centre; + judgementPiece.Y = 50; + } + }) + { + new EzComComboSprite(), + new EzComComboCounter(), + new EzComComboCounter(), + new EzComKeyCounterDisplay(), + new EzComHitTimingColumns(), + new BarHitErrorMeter(), + new EzComHitResultScore(), + new EzComHitTiming(), + new EzComHitTiming(), + }; + } + + return null; + + case SkinComponentLookup: + // if (Skin is Ez2Skin && resultComponent.Component > HitResult.Great) + // return Drawable.Empty(); + + // return new Ez2JudgementPiece(resultComponent.Component); + return Drawable.Empty(); + + case ManiaSkinComponentLookup maniaComponent: + switch (maniaComponent.Component) + { + case ManiaSkinComponents.StageBackground: + return new Ez2StageBackground(); + + case ManiaSkinComponents.ColumnBackground: + // if (Skin is Ez2Skin && resultComponent.Component >= HitResult.Perfect) + // return Drawable.Empty(); + + return new EzColumnBackground(); + + case ManiaSkinComponents.KeyArea: + return new Ez2KeyArea(); + + case ManiaSkinComponents.HitTarget: + return new Ez2HitTarget(); + + case ManiaSkinComponents.Note: + return new Ez2NotePiece(); + + case ManiaSkinComponents.HitExplosion: + return new Ez2HitExplosion(); + + case ManiaSkinComponents.HoldNoteTail: + return new Ez2HoldNoteTailPiece(); + + case ManiaSkinComponents.HoldNoteBody: + return new Ez2HoldBodyPiece(); + + case ManiaSkinComponents.HoldNoteHead: + return new Ez2HoldNoteHeadPiece(); + } + + break; + } + + return base.GetDrawableComponent(lookup); + } + + private const int stage_padding_bottom = 0; + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + int columnIndex = maniaLookup.ColumnIndex ?? 0; + var stage = beatmap.GetStageForColumnIndex(columnIndex); + bool isSpecialColumn = stage.EzIsSpecialColumn(columnIndex); + + float width = (float)columnWidthBindable.Value * (isSpecialColumn ? (float)specialFactorBindable.Value : 1f); + // float hitPositionValue = (float)hitPosition.Value; // + (float)virtualHitPosition.Value - 110f; + + if (stage.Columns == 14 && columnIndex == 13) + width = 0f; + + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + return SkinUtils.As(new Bindable(width)); + + // case LegacyManiaSkinConfigurationLookups.HitPosition: + // return SkinUtils.As(new Bindable(hitPositionValue)); + + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + + var colour = stage.GetColourForLayout(columnIndex); + + return SkinUtils.As(new Bindable(colour)); + + case LegacyManiaSkinConfigurationLookups.BarLineHeight: + return SkinUtils.As(new Bindable(1)); + + case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing: + case LegacyManiaSkinConfigurationLookups.RightColumnSpacing: + return SkinUtils.As(new Bindable(0)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: + return SkinUtils.As(new Bindable(stage_padding_bottom)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingTop: + return SkinUtils.As(new Bindable(0)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgress.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgress.cs new file mode 100644 index 0000000000..b6f0f79c5b --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgress.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Localisation.HUD; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class Ez2SongProgress : SongProgress + { + private readonly SongProgressInfo info; + private readonly Ez2SongProgressGraph graph; + private readonly Ez2SongProgressBar bar; + private readonly Container graphContainer; + private readonly Container content; + + private const float bar_height = 10; + + [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowGraph), nameof(SongProgressStrings.ShowGraphDescription))] + public Bindable ShowGraph { get; } = new BindableBool(true); + + [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowTime), nameof(SongProgressStrings.ShowTimeDescription))] + public Bindable ShowTime { get; } = new BindableBool(true); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] + public BindableBool UseRelativeSize { get; } = new BindableBool(true); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + + [Resolved] + private Player? player { get; set; } + + public Ez2SongProgress() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Masking = true; + CornerRadius = 5; + + Child = content = new Container + { + RelativeSizeAxes = Axes.X, + Children = new Drawable[] + { + info = new SongProgressInfo + { + Origin = Anchor.TopLeft, + Name = "Info", + Anchor = Anchor.TopLeft, + RelativeSizeAxes = Axes.X, + ShowProgress = false + }, + bar = new Ez2SongProgressBar(bar_height) + { + Name = "Seek bar", + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + OnSeek = time => player?.Seek(time), + }, + graphContainer = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Masking = true, + CornerRadius = 5, + Child = graph = new Ez2SongProgressGraph + { + Name = "Difficulty graph", + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive + }, + RelativeSizeAxes = Axes.X, + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + info.TextColour = Colour4.White; + info.Font = OsuFont.Torus.With(size: 18, weight: FontWeight.Bold); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Interactive.BindValueChanged(_ => bar.Interactive = Interactive.Value, true); + ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); + ShowTime.BindValueChanged(_ => info.FadeTo(ShowTime.Value ? 1 : 0, 200, Easing.In), true); + AccentColour.BindValueChanged(_ => Colour = AccentColour.Value, true); + + // see comment in Ez2HealthDisplay.cs regarding RelativeSizeAxes + float previousWidth = Width; + UseRelativeSize.BindValueChanged(v => RelativeSizeAxes = v.NewValue ? Axes.X : Axes.None, true); + Width = previousWidth; + } + + protected override void UpdateObjects(IEnumerable objects) + { + graph.Objects = objects; + + info.StartTime = bar.StartTime = FirstHitTime; + info.EndTime = bar.EndTime = LastHitTime; + } + + private void updateGraphVisibility() + { + graph.FadeTo(ShowGraph.Value ? 1 : 0, 200, Easing.In); + } + + protected override void Update() + { + base.Update(); + content.Height = bar.Height + bar_height + info.Height; + graphContainer.Height = bar.Height; + } + + protected override void UpdateProgress(double progress, bool isIntro) + { + bar.Progress = isIntro ? 0 : progress; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressBar.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressBar.cs new file mode 100644 index 0000000000..a8d4723e74 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressBar.cs @@ -0,0 +1,208 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class Ez2SongProgressBar : SongProgressBar, IHasTooltip + { + // Parent will handle restricting the area of valid input. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + private readonly float barHeight; + + private readonly RoundedBar playfieldBar; + private readonly RoundedBar audioBar; + + private readonly Box background; + + private readonly ColourInfo mainColour; + private ColourInfo catchUpColour; + + public double Progress { get; set; } + + private double trackTime => (EndTime - StartTime) * Progress; + + private float lastMouseX; + + public LocalisableString TooltipText + { + get + { + if (!Interactive) + return default; + + double progress = Math.Clamp(lastMouseX, 0, DrawWidth) / DrawWidth; + + TimeSpan currentSpan = TimeSpan.FromMilliseconds(Math.Round((EndTime - StartTime) * progress)); + + int seconds = currentSpan.Duration().Seconds; + int minutes = (int)Math.Floor(currentSpan.Duration().TotalMinutes); + + return $"{minutes}:{seconds:D2} ({progress:P0})"; + } + } + + public Ez2SongProgressBar(float barHeight) + { + RelativeSizeAxes = Axes.X; + Height = this.barHeight = barHeight; + + CornerRadius = 5; + Masking = true; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = OsuColour.Gray(0.2f), + Depth = float.MaxValue, + }, + audioBar = new RoundedBar + { + Name = "Audio bar", + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + CornerRadius = 5, + RelativeSizeAxes = Axes.Both + }, + playfieldBar = new RoundedBar + { + Name = "Playfield bar", + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + CornerRadius = 5, + AccentColour = mainColour = OsuColour.Gray(0.9f), + RelativeSizeAxes = Axes.Both + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + catchUpColour = colours.BlueDark; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + background.FadeTo(0.3f, 200, Easing.In); + playfieldBar.TransformTo(nameof(playfieldBar.AccentColour), mainColour, 200, Easing.In); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + base.OnMouseMove(e); + + lastMouseX = e.MousePosition.X; + return false; + } + + protected override bool OnHover(HoverEvent e) + { + if (Interactive) + this.ResizeHeightTo(barHeight * 3.5f, 200, Easing.Out); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + this.ResizeHeightTo(barHeight, 800, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override void Update() + { + base.Update(); + + playfieldBar.Length = (float)Interpolation.Lerp(playfieldBar.Length, Progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); + audioBar.Length = (float)Interpolation.Lerp(audioBar.Length, AudioProgress, Math.Clamp(Time.Elapsed / 40, 0, 1)); + + if (trackTime > AudioTime) + ChangeInternalChildDepth(audioBar, -1); + else + ChangeInternalChildDepth(audioBar, 1); + + float timeDelta = (float)Math.Abs(AudioTime - trackTime); + + const float colour_transition_threshold = 20000; + + audioBar.AccentColour = Interpolation.ValueAt( + Math.Min(timeDelta, colour_transition_threshold), + mainColour, + catchUpColour, + 0, colour_transition_threshold, + Easing.OutQuint); + } + + private partial class RoundedBar : Container + { + private readonly Box fill; + private readonly Container mask; + private float length; + + public RoundedBar() + { + Masking = true; + Children = new[] + { + mask = new Container + { + Masking = true, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(1), + Child = fill = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White + } + } + }; + } + + public float Length + { + get => length; + set + { + length = value; + mask.Width = value * DrawWidth; + } + } + + public new float CornerRadius + { + get => base.CornerRadius; + set + { + base.CornerRadius = value; + mask.CornerRadius = value; + } + } + + public ColourInfo AccentColour + { + get => fill.Colour; + set => fill.Colour = value; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressGraph.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressGraph.cs new file mode 100644 index 0000000000..d3c7b4cca9 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/Ez2SongProgressGraph.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class Ez2SongProgressGraph : SegmentedGraph + { + private const int tier_count = 5; + + private const int display_granularity = 200; + + private IEnumerable? objects; + + public IEnumerable Objects + { + set + { + objects = value; + + int[] values = new int[display_granularity]; + + if (!objects.Any()) + return; + + (double firstHit, double lastHit) = BeatmapExtensions.CalculatePlayableBounds(objects); + + if (lastHit == 0) + lastHit = objects.Last().StartTime; + + double interval = (lastHit - firstHit + 1) / display_granularity; + + foreach (var h in objects) + { + double endTime = h.GetEndTime(); + + Debug.Assert(endTime >= h.StartTime); + + int startRange = (int)((h.StartTime - firstHit) / interval); + int endRange = (int)((endTime - firstHit) / interval); + for (int i = startRange; i <= endRange; i++) + values[i]++; + } + + Values = values; + } + } + + public Ez2SongProgressGraph() + : base(tier_count) + { + var colours = new List(); + + for (int i = 0; i < tier_count; i++) + colours.Add(OsuColour.Gray(0.2f).Opacity(0.1f)); + + TierColours = colours; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboCounter.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboCounter.cs new file mode 100644 index 0000000000..f16d98e311 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboCounter.cs @@ -0,0 +1,153 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning.Components; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class EzComComboCounter : ComboCounter + { + [SettingSource("Font", "Font", SettingControlType = typeof(EzSelectorEnumList))] + public Bindable NameDropdown { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + [SettingSource("Effect Type", "Effect Type")] + public Bindable Effect { get; } = new Bindable(EzComEffectType.Scale); + + // [SettingSource("Effect Origin", "Effect Origin", SettingControlType = typeof(AnchorDropdown))] + // public Bindable EffectOrigin { get; } = new Bindable(Anchor.TopCentre) + // { + // Default = Anchor.TopCentre, + // Value = Anchor.TopCentre + // }; + + [SettingSource("Effect Start Factor", "Effect Start Factor")] + public BindableNumber EffectStartFactor { get; } = new BindableNumber(1.5f) + { + MinValue = 0.1f, + MaxValue = 5f, + Precision = 0.05f, + }; + + [SettingSource("Effect End Factor", "Effect End Factor")] + public BindableNumber EffectEndFactor { get; } = new BindableNumber(1f) + { + MinValue = 0.1f, + MaxValue = 5f, + Precision = 0.05f, + }; + + [SettingSource("Effect Start Duration", "Effect Start Duration")] + public BindableNumber EffectStartTime { get; } = new BindableNumber(10) + { + MinValue = 1, + MaxValue = 300, + Precision = 1f, + }; + + [SettingSource("Effect End Duration", "Effect End Duration")] + public BindableNumber EffectEndDuration { get; } = new BindableNumber(300) + { + MinValue = 10, + MaxValue = 500, + Precision = 10f, + }; + + [SettingSource("Alpha", "The alpha value of this box")] + public BindableNumber BoxAlpha { get; } = new BindableNumber(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + + public EzComboText Text = null!; + protected override double RollingDuration => 250; + protected virtual bool DisplayXSymbol => true; + + [BackgroundDependencyLoader] + private void load(ScoreProcessor scoreProcessor) + { + Current.BindTo(scoreProcessor.Combo); + Current.BindValueChanged(combo => + { + bool wasIncrease = combo.NewValue > combo.OldValue; + bool wasMiss = combo.OldValue > 1 && combo.NewValue == 0; + + applyAnimation(wasIncrease, wasMiss); + }); + + // EffectOrigin.BindValueChanged(e => + // { + // Text.TextPart.Origin = e.NewValue; + // }, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BoxAlpha.BindValueChanged(alpha => Text.Alpha = alpha.NewValue, true); + AccentColour.BindValueChanged(_ => Text.Colour = AccentColour.Value, true); + + NameDropdown.BindValueChanged(e => + { + Text.FontName.Value = e.NewValue; + Text.Invalidate(); // **强制刷新 EzCounterText** + }, true); + } + + private void applyAnimation(bool wasIncrease, bool wasMiss) + { + switch (Effect.Value) + { + case EzComEffectType.Scale: + EzEffectHelper.ApplyScaleAnimation( + Text.TextContainer, + wasIncrease, + wasMiss, + EffectStartFactor.Value, + EffectEndFactor.Value, + EffectStartTime.Value, + EffectEndDuration.Value); + break; + + case EzComEffectType.Bounce: + EzEffectHelper.ApplyBounceAnimation( + Text.TextContainer, + wasIncrease, + wasMiss, + EffectStartFactor.Value, + EffectEndFactor.Value, + EffectStartTime.Value, + EffectEndDuration.Value); + break; + } + } + + protected override LocalisableString FormatCount(int count) => DisplayXSymbol ? $@"{count}" : count.ToString(); + + protected override IHasText CreateText() + { + Text = new EzComboText(NameDropdown) + { + Scale = new Vector2(1.8f), + }; + return Text; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.cs new file mode 100644 index 0000000000..960bb88151 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.cs @@ -0,0 +1,178 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence comboSprite. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning.Components; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class EzComComboSprite : HitErrorMeter + { + [SettingSource("Combo Text Font", "Combo Text Font", SettingControlType = typeof(EzSelectorEnumList))] + public Bindable NameDropdown { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + [SettingSource("Effect Type", "Effect Type")] + public Bindable Effect { get; } = new Bindable(EzComEffectType.Scale); + + [SettingSource("Effect Origin", "Effect Origin", SettingControlType = typeof(AnchorDropdown))] + public Bindable EffectOrigin { get; } = new Bindable(Anchor.TopCentre) + { + Default = Anchor.TopCentre, + Value = Anchor.TopCentre + }; + + [SettingSource("Effect Start Factor", "Effect Start Factor")] + public BindableNumber EffectStartFactor { get; } = new BindableNumber(2f) + { + MinValue = 0.1f, + MaxValue = 5f, + Precision = 0.05f, + }; + + [SettingSource("Effect Start Duration", "Effect Start Duration")] + public BindableNumber EffectStartTime { get; } = new BindableNumber(10) + { + MinValue = 1, + MaxValue = 300, + Precision = 1f, + }; + + [SettingSource("Effect End Duration", "Effect End Duration")] + public BindableNumber EffectEndDuration { get; } = new BindableNumber(300) + { + MinValue = 10, + MaxValue = 500, + Precision = 10f, + }; + + [SettingSource("Alpha", "The alpha value of this box")] + public BindableNumber BoxAlpha { get; } = new BindableNumber(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + + public EzComboText Text = null!; + + public Bindable Current { get; } = new Bindable(); + + public EzComComboSprite() + { + // Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Size = new Vector2(120, 30); + } + + [BackgroundDependencyLoader] + private void load(ScoreProcessor scoreProcessor) + { + InternalChildren = new Drawable[] + { + Text = new EzComboText(NameDropdown) + { + Scale = new Vector2(0.8f), + Text = "c", + Alpha = 1 + }, + }; + + Current.BindTo(scoreProcessor.Combo); + Current.BindValueChanged(combo => + { + bool wasIncrease = combo.NewValue > combo.OldValue; + bool wasMiss = combo.OldValue > 1 && combo.NewValue == 0; + + applyAnimation(wasIncrease, wasMiss); + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BoxAlpha.BindValueChanged(alpha => Text.Alpha = alpha.NewValue, true); + AccentColour.BindValueChanged(_ => Text.Colour = AccentColour.Value, true); + + EffectOrigin.BindValueChanged(e => + { + if (e.NewValue != Anchor.TopCentre && e.NewValue != Anchor.Centre && e.NewValue != Anchor.BottomCentre) + { + EffectOrigin.Value = Anchor.TopCentre; // 设置为默认值 + } + + // switch (EffectOrigin.Value) + // { + // case Anchor.TopCentre: + // Text.TextContainer.Anchor = Anchor.BottomCentre; + // break; + // + // case Anchor.BottomCentre: + // Text.TextContainer.Anchor = Anchor.TopCentre; + // break; + // } + + Text.TextContainer.Anchor = EffectOrigin.Value; + }, true); + NameDropdown.BindValueChanged(e => + { + Text.FontName.Value = e.NewValue; + Text.Invalidate(); + }, true); + } + + private void applyAnimation(bool wasIncrease, bool wasMiss) + { + switch (Effect.Value) + { + case EzComEffectType.Scale: + EzEffectHelper.ApplyScaleAnimation( + Text.TextContainer, + wasIncrease, + wasMiss, + EffectStartFactor.Value, + 1, + EffectStartTime.Value, + EffectEndDuration.Value); + break; + + case EzComEffectType.Bounce: + EzEffectHelper.ApplyBounceAnimation( + Text.TextContainer, + wasIncrease, + wasMiss, + EffectStartFactor.Value / 2, + 0.8f, + EffectStartTime.Value, + EffectEndDuration.Value); + break; + } + } + + protected override void OnNewJudgement(JudgementResult judgement) + { + if (!judgement.IsHit) + return; + + Text.Text = judgement.IsHit ? "c" : string.Empty; + } + + public override void Clear() + { + Text.Text = string.Empty; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.txt b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.txt new file mode 100644 index 0000000000..3d8bebd24a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite.txt @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence comboSprite. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2.Ez2HUD +{ + public partial class EzComComboSprite : SkinnableDrawable, ISerialisableDrawable + { + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.SpriteName), nameof(SkinnableComponentStrings.SpriteNameDescription), SettingControlType = typeof(SpriteSelectorControl))] + public Bindable SpriteName { get; } = new Bindable(string.Empty); + + [SettingSource("Animation Type", "The type of animation to apply")] + public Bindable Animation { get; } = new Bindable(AnimationType.Scale); + + [SettingSource("Increase Scale", "The scale factor when the combo increases")] + public BindableNumber IncreaseScale { get; } = new BindableNumber(0.5f) + { + MinValue = 0.1f, + MaxValue = 5f, + Precision = 0.05f, + }; + + [SettingSource("Increase Duration", "The scale duration time when the combo increases")] + public BindableNumber IncreaseDuration { get; } = new BindableNumber(10) + { + MinValue = 1, + MaxValue = 300, + Precision = 1f, + }; + + [SettingSource("Decrease Duration", "The scale duration time when the combo decrease")] + public BindableNumber DecreaseDuration { get; } = new BindableNumber(200) + { + MinValue = 10, + MaxValue = 500, + Precision = 10f, + }; + + [SettingSource("Animation Origin", "The origin point for the animation")] + public Bindable AnimationOrigin { get; } = new Bindable(OriginOptions.TopCentre); + + [SettingSource("Alpha", "The alpha value of this box")] + public BindableNumber BoxAlpha { get; } = new BindableNumber(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + protected override bool ApplySizeRestrictionsToDefault => true; + public Bindable Current { get; } = new Bindable(); + private Sprite comboSprite = null!; + private Texture texture = null!; + private readonly Dictionary textureMap = new Dictionary(); + + // [Resolved] + // private ISkinSource source { get; set; } = null!; + + [Resolved] + private TextureStore textures { get; set; } = null!; + + public EzComComboSprite(string textureName, Vector2? maxSize = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(new SpriteComponentLookup(textureName, maxSize), confineMode) + { + SpriteName.Value = textureName; + } + + public EzComComboSprite() + : base(new SpriteComponentLookup(string.Empty), ConfineMode.NoScaling) + { + RelativeSizeAxes = Axes.None; + AutoSizeAxes = Axes.Both; + + SpriteName.BindValueChanged(name => + { + ((SpriteComponentLookup)ComponentLookup).LookupName = name.NewValue ?? string.Empty; + if (IsLoaded) + SkinChanged(CurrentSkin); + }); + } + + [BackgroundDependencyLoader] + private void load(ScoreProcessor scoreProcessor) + { + foreach (var item in SpriteSelectorControl.SPRITE_PATH_MAP) + { + texture = textures.Get(item.Value); + + if (texture != null) + { + textureMap[item.Key] = texture; + } + } + + Current.BindTo(scoreProcessor.Combo); + Current.BindValueChanged(combo => + { + bool wasIncrease = combo.NewValue > combo.OldValue; + bool wasMiss = combo.OldValue > 1 && combo.NewValue == 0; + + switch (Animation.Value) + { + case AnimationType.Scale: + applyScaleAnimation(wasIncrease, wasMiss); + break; + + case AnimationType.Bounce: + applyBounceAnimation(wasIncrease, wasMiss); + break; + } + }); + } + + private void applyScaleAnimation(bool wasIncrease, bool wasMiss) + { + float newScaleValue = Math.Clamp(comboSprite.Scale.X * (wasIncrease ? IncreaseScale.Value : 0.8f), 0.5f, 3f); + Vector2 newScale = new Vector2(newScaleValue); + + Anchor originAnchor = Enum.Parse(AnimationOrigin.Value.ToString()); + comboSprite.Anchor = originAnchor; + comboSprite.Origin = originAnchor; + + comboSprite + .ScaleTo(newScale, IncreaseDuration.Value, Easing.OutQuint) + .Then() + .ScaleTo(Vector2.One, DecreaseDuration.Value, Easing.OutQuint); + + if (wasMiss) + comboSprite.FlashColour(Color4.Red, DecreaseDuration.Value, Easing.OutQuint); + } + + private void applyBounceAnimation(bool wasIncrease, bool wasMiss) + { + float factor = 0; + + // 根据 AnimationOrigin 的值设置跳动方向 + switch (AnimationOrigin.Value) + { + case OriginOptions.TopCentre: + factor = Math.Clamp(wasIncrease ? 10 * IncreaseScale.Value : -50, -100f, 100f); // 向下跳 + break; + + case OriginOptions.BottomCentre: + factor = Math.Clamp(wasIncrease ? -10 * IncreaseScale.Value : 50, -100f, 100f); // 向上跳 + break; + + case OriginOptions.Centre: + factor = Math.Clamp(wasIncrease ? 10 * IncreaseScale.Value : -10 * IncreaseScale.Value, -100f, 100f); // 上下跳 + break; + } + + comboSprite + .MoveToY(factor, IncreaseDuration.Value / 4, Easing.OutBounce) + .Then() + .MoveToY(0, DecreaseDuration.Value, Easing.OutBounce); + + if (wasMiss) + comboSprite.FlashColour(Color4.Red, DecreaseDuration.Value, Easing.OutQuint); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BoxAlpha.BindValueChanged(alpha => comboSprite.Alpha = alpha.NewValue, true); + } + + public bool UsesFixedAnchor { get; set; } + + protected override Drawable CreateDefault(ISkinComponentLookup lookup) + { + var spriteLookup = (SpriteComponentLookup)lookup; + texture = textures.Get(spriteLookup.LookupName); + + if (texture == null || SpriteName.Value == string.Empty) + texture = textures.Get(@"Gameplay/ComboText/default_combo.png"); + + if (spriteLookup.MaxSize != null) + texture = texture.WithMaximumSize(spriteLookup.MaxSize.Value); + + comboSprite = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = texture + }; + return comboSprite; + } + + internal class SpriteComponentLookup : ISkinComponentLookup + { + public string LookupName { get; set; } + public Vector2? MaxSize { get; set; } + + public SpriteComponentLookup(string textureName, Vector2? maxSize = null) + { + LookupName = textureName; + MaxSize = maxSize; + } + } + + private const string base_path = @"Gameplay/ComboText"; + + public partial class SpriteSelectorControl : SettingsDropdown + { + [Resolved] + private TextureStore textures { get; set; } = null!; + + internal static readonly Dictionary SPRITE_PATH_MAP = new Dictionary(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + var resources = textures.GetAvailableResources(); + SPRITE_PATH_MAP.Clear(); + + var matchingResources = resources + .Where(r => r.StartsWith(base_path, StringComparison.OrdinalIgnoreCase) + && Path.GetExtension(r).Equals(".png", StringComparison.OrdinalIgnoreCase)); + + foreach (string? resource in matchingResources) + { + string fileName = Path.GetFileNameWithoutExtension(resource); + SPRITE_PATH_MAP[fileName] = resource; + } + + Items = SPRITE_PATH_MAP.Keys.ToList(); + + Current.ValueChanged += e => + { + if (SPRITE_PATH_MAP.TryGetValue(e.NewValue, out string? path)) + { + if (Current.Value != path) + Current.Value = path; + + // Ensure the sprite is updated when the value changes + // SpriteName.Value = e.NewValue; + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite2.txt b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite2.txt new file mode 100644 index 0000000000..6021b0718d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComComboSprite2.txt @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2.Ez2HUD +{ + public partial class EzComComboSprite2 : ComboCounter + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer = null!; + + private Drawable noteAnimation = null!; + + public EzComComboSprite2() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + InternalChild = directionContainer = new Container + { + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = noteAnimation = GetAnimation(skin) ?? Empty() + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(OnDirectionChanged, true); + } + + protected override void Update() + { + base.Update(); + + Texture? texture = null; + + if (noteAnimation is Sprite sprite) + texture = sprite.Texture; + else if (noteAnimation is TextureAnimation textureAnimation && textureAnimation.FrameCount > 0) + texture = textureAnimation.CurrentFrame; + + if (texture != null) + { + // The height is scaled to the minimum column width, if provided. + float minimumWidth = minimumColumnWidth ?? DrawWidth; + noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), texture.DisplayWidth); + } + } + + protected virtual void OnDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + directionContainer.Anchor = Anchor.TopCentre; + directionContainer.Scale = new Vector2(1, -1); + } + else + { + directionContainer.Anchor = Anchor.BottomCentre; + directionContainer.Scale = Vector2.One; + } + } + + protected virtual Drawable? GetAnimation(ISkinSource skin) => GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + + protected Drawable? GetAnimationFromLookup(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + { + string[] videoFiles = System.IO.Directory.GetFiles(@"Textures/Webm", "*.webm"); + + if (videoFiles.Length == 0) + { + // 没有找到视频文件,返回一个默认背景 + return new Background(getBackgroundTextureName()); + } + + string videoPath = videoFiles[RNG.Next(videoFiles.Length)]; + return new VideoBackgroundScreen(videoPath); + + return skin.GetAnimation(noteImage, WrapMode.ClampToEdge, WrapMode.ClampToEdge, true, true); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTiming.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTiming.cs new file mode 100644 index 0000000000..42da63a200 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTiming.cs @@ -0,0 +1,308 @@ +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Judgements; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning.Components; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class EzComHitTiming : HitErrorMeter + { + [SettingSource("Offset Number Font", "Offset Number Font", SettingControlType = typeof(EzSelectorEnumList))] + public Bindable NumberNameDropdown { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + [SettingSource("Offset Text Font", "Offset Text Font", SettingControlType = typeof(OffsetTextNameSelector))] + public Bindable TextNameDropdown { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + [SettingSource("Single Show E/L", "Show only Early or: Late separately")] + public Bindable AloneShow { get; } = new Bindable(AloneShowMenu.None); + + [SettingSource("Test Mode", "Show E/L on perfect hits for testing")] + public Bindable TestMode { get; } = new Bindable(); + + [SettingSource("(显示阈值) Displaying Threshold", "(显示阈值) Displaying Threshold")] + public BindableNumber Threshold { get; } = new BindableNumber(22) + { + MinValue = 0.0, + MaxValue = 100.0, + Precision = 1 + }; + + [SettingSource("(持续时间) Display Duration", "(持续时间) Duration disappears")] + public BindableNumber DisplayDuration { get; } = new BindableNumber(300) + { + MinValue = 10, + MaxValue = 10000, // 最大持续时间 + Precision = 1, // 精度 + }; + + [SettingSource("(对称间距) Symmetrical spacing", "(对称间距) Symmetrical spacing")] + public BindableNumber SymmetryOffset { get; } = new BindableNumber(60) + { + MinValue = 0, + MaxValue = 500, + Precision = 1, + }; + + [SettingSource("Text Alpha", "The alpha value of this offset text")] + public BindableNumber TextAlpha { get; } = new BindableNumber(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + [SettingSource("Number Alpha", "The alpha value of the offset number")] + public BindableNumber NumberAlpha { get; } = new BindableNumber(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + + private Container timingContainer = null!; + private FillFlowContainer errorContainer = null!; + private EzComboText timingTextL = null!; + private EzComboText timingText = null!; + private EzComboText timingTextR = null!; + private EzComboText offsetText = null!; + private Box backgroundBox = null!; + + public EzComHitTiming() + { + Size = new Vector2(300, 80); + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + backgroundBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black, + Alpha = 0f + }, + errorContainer = new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + timingContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2f), + // Spacing = new Vector2(SymmetryOffset.Value), + Children = new Drawable[] + { + timingTextL = new EzComboText(TextNameDropdown) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "e", + Alpha = 1, + Position = new Vector2(-SymmetryOffset.Value, 0) + }, + timingText = new EzComboText(TextNameDropdown) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "e/l", + Alpha = 0 + }, + timingTextR = new EzComboText(TextNameDropdown) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "l", + Alpha = 1, + Position = new Vector2(SymmetryOffset.Value, 0) + }, + } + }, + offsetText = new EzComboText(NumberNameDropdown) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.5f), + Text = "±000", + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TextAlpha.BindValueChanged(alpha => timingContainer.Alpha = alpha.NewValue, true); + NumberAlpha.BindValueChanged(alpha => offsetText.Alpha = alpha.NewValue, true); + AccentColour.BindValueChanged(_ => errorContainer.Colour = AccentColour.Value, true); + + NumberNameDropdown.BindValueChanged(e => + { + offsetText.FontName.Value = e.NewValue; + offsetText.Invalidate(); + }, true); + + TextNameDropdown.BindValueChanged(e => + { + timingText.FontName.Value = e.NewValue; + timingTextL.FontName.Value = e.NewValue; + timingTextR.FontName.Value = e.NewValue; + Invalidate(); + // timingText1.Invalidate(); + // timingText3.Invalidate(); + }, true); + + AloneShow.BindValueChanged(_ => updateAlpha(), true); + SymmetryOffset.BindValueChanged(_ => updateSpacing(), true); + } + + private void updateAlpha() + { + timingTextL.Alpha = AloneShow.Value == AloneShowMenu.None ? 1 : 0; + timingText.Alpha = AloneShow.Value == AloneShowMenu.None ? 0 : 1; + timingTextR.Alpha = AloneShow.Value == AloneShowMenu.None ? 1 : 0; + } + + private void updateSpacing() + { + timingTextL.Position = new Vector2(-SymmetryOffset.Value, 0); + timingTextR.Position = new Vector2(SymmetryOffset.Value, 0); + + timingContainer.Invalidate(); + } + + protected override void OnNewJudgement(JudgementResult judgement) + { + if (!judgement.IsHit) + return; + + if (!shouldDisplayJudgement(AloneShow.Value, judgement.TimeOffset)) + return; + + if (judgement.TimeOffset == 0) + { + if (TestMode.Value) + { + // 测试模式:显示 E 和 L + timingText.Text = "e/l"; + timingTextL.Text = "e"; + timingTextR.Text = "l"; + } + else + { + // 默认:不显示 + timingText.Text = string.Empty; + timingTextL.Text = string.Empty; + timingTextR.Text = string.Empty; + } + } + else + { + timingTextL.Text = judgement.TimeOffset < 0 ? "e" : string.Empty; + timingText.Text = judgement.TimeOffset < 0 ? "e" : "l"; + timingTextR.Text = judgement.TimeOffset < 0 ? string.Empty : "l"; + } + + offsetText.Text = judgement.TimeOffset == 0 ? "0" : $"{judgement.TimeOffset:+0;-0}"; + backgroundBox.Colour = GetColourForHitResult(judgement.Type); + + timingContainer.FadeTo(TextAlpha.Value, 10); // 渐现动画(上半文字) + offsetText.FadeTo(NumberAlpha.Value, 10); // 渐现动画(下半数字) + resetDisappearTask(); + } + + private bool hasTriggeredReset; + + private bool shouldDisplayJudgement(AloneShowMenu aloneShowMenu, double timeOffset) + { + if (!hasTriggeredReset) + { + resetDisappearTask(); // 第一次判定时触发任务 + hasTriggeredReset = true; + } + + if (timeOffset == 0) + return true; + + if (Math.Abs(timeOffset) < Threshold.Value) + { + return false; + } + + return aloneShowMenu switch + { + AloneShowMenu.Early => timeOffset < 0, + AloneShowMenu.Late => timeOffset > 0, + _ => true + }; + // return true; + } + + private ScheduledDelegate disappearTask = null!; + + private void resetDisappearTask() + { + // 如果已有任务在运行,取消它 + // ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + disappearTask?.Cancel(); + // ReSharper restore ConditionalAccessQualifierIsNonNullableAccordingToAPIContract + + // 启动新的任务,在持续时间后渐隐至透明度为零 + disappearTask = Scheduler.AddDelayed(() => + { + timingContainer.FadeOut(300); // 渐隐动画(上半文字) + offsetText.FadeOut(300); // 渐隐动画(下半数字) + }, DisplayDuration.Value); + } + + public override void Clear() + { + timingText.Text = string.Empty; + timingTextL.Text = string.Empty; + timingTextR.Text = string.Empty; + offsetText.Text = string.Empty; + backgroundBox.Colour = Colour4.Black; + + foreach (var j in errorContainer) + { + j.ClearTransforms(); + j.Expire(); + } + } + } + + public enum AloneShowMenu + { + Early, + Late, + None, + } + + public partial class OffsetTextNameSelector : EzSelectorEnumList + { + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTimingColumns.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTimingColumns.cs new file mode 100644 index 0000000000..73cbd64db2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComHitTimingColumns.cs @@ -0,0 +1,259 @@ +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class EzComHitTimingColumns : HitErrorMeter + { + [SettingSource("Minimum Hit Result", "Filter out judgments worse than this")] + public Bindable MinimumHitResult { get; } = new Bindable(HitResult.Good); + + [SettingSource("Markers Height", "Markers Height")] + public BindableNumber MarkerHeight { get; } = new BindableNumber(2) + { + MinValue = 1, + MaxValue = 20, + Precision = 1f, + }; + + [SettingSource("Move Height", "Move Height")] + public BindableNumber MoveHeight { get; } = new BindableNumber(20) + { + MinValue = 1, + MaxValue = 200, + Precision = 1f, + }; + + [SettingSource("Background Alpha", "Background Alpha")] + public BindableNumber BackgroundAlpha { get; } = new BindableNumber(0.2f) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.1f, + }; + + [SettingSource("Background Colour", "Background Colour")] + public BindableColour4 BackgroundColour { get; } = new BindableColour4(Colour4.Gray); + + private double[] floatingAverages = null!; + private Box[] judgementMarkers = null!; + private Box backgroundBox = null!; + private Container[] columns = null!; + + private int keyCount; + + private Bindable columnWidth = null!; + private Bindable specialFactor = null!; + + [Resolved] + private InputCountController controller { get; set; } = null!; + + [Resolved] + private ISkinSource skin { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(Ez2ConfigManager ezSkinConfig) + { + columnWidth = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + specialFactor = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + recreateComponents(); + } + + private void recreateComponents() + { + ClearInternal(); + keyCount = controller.Triggers.Count; + floatingAverages = new double[keyCount]; + judgementMarkers = new Box[keyCount]; + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Both, + RelativeSizeAxes = Axes.Both, + Margin = new MarginPadding(2), + Children = new Drawable[] + { + backgroundBox = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = BackgroundColour.Value, + Alpha = BackgroundAlpha.Value, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(0, 0), + Children = columns = Enumerable.Range(0, keyCount).Select(index => + { + var column = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0 + } + } + }; + var marker = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 1, + Height = MarkerHeight.Value, + Blending = BlendingParameters.Additive, + Colour = Colour4.Gray, + Alpha = 0.8f + }; + + column.Add(marker); + judgementMarkers[index] = marker; + return column; + }).ToArray() + } + } + }; + Height = MoveHeight.Value; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + controller.Triggers.BindCollectionChanged((_, __) => recreateComponents(), true); + + columnWidth.BindValueChanged(_ => updateWidth(), true); + specialFactor.BindValueChanged(_ => updateWidth(), true); + + // 更新标识块高度 + MarkerHeight.BindValueChanged(height => + { + foreach (var marker in judgementMarkers) + marker.Height = height.NewValue; + }, true); + + // 更新背景柱状列高度和标识块移动范围 + MoveHeight.BindValueChanged(height => + { + Height = height.NewValue; + + foreach (var marker in judgementMarkers) + { + // 按比例调整marker的Y位置 + marker.Y = marker.Y * (height.NewValue / height.OldValue); + marker.Y = Math.Clamp(marker.Y, -height.NewValue / 2, height.NewValue / 2); + } + + Invalidate(Invalidation.DrawSize); + }, true); + + // 更新背景透明度 + BackgroundAlpha.BindValueChanged(alpha => + { + backgroundBox.Alpha = alpha.NewValue; + }, true); + + // 更新背景颜色 + BackgroundColour.BindValueChanged(colour => + { + backgroundBox.Colour = colour.NewValue; + }, true); + } + + private void updateWidth() + { + if (keyCount <= 0) + return; + + float totalWidth = 0; + + for (int i = 0; i < keyCount; i++) + { + float? widthS = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) + ?.Value; + + float newWidth = widthS ?? (float)columnWidth.Value; + + columns[i].Width = newWidth; + totalWidth += newWidth; + } + + Width = totalWidth; + } + + protected override void OnNewJudgement(JudgementResult judgement) + { + if (!judgement.IsHit || !judgement.Type.IsScorable()) + return; + + if (judgement.Type > MinimumHitResult.Value) + return; + + int columnIndex = -1; + + if (judgement.HitObject is IHasColumn hasColumn) + columnIndex = hasColumn.Column; + + if (columnIndex < 0 || columnIndex >= keyCount) + return; + + floatingAverages[columnIndex] = floatingAverages[columnIndex] * 0.9 + judgement.TimeOffset * 0.1; + + const int marker_move_duration = 800; + var marker = judgementMarkers[columnIndex]; + + float targetY = getRelativeJudgementPosition(floatingAverages[columnIndex]); + + marker.Y = targetY; + + marker.MoveToY(targetY, marker_move_duration, Easing.OutQuint); + + marker.Colour = GetColourForHitResult(judgement.Type); + } + + private float getRelativeJudgementPosition(double value) + { + double missWindow = HitWindows.WindowFor(HitResult.Miss); + + if (missWindow == 0) + return 0; + + float pos = (float)(value / missWindow) * (MoveHeight.Value / 2); + return Math.Clamp(pos, -MoveHeight.Value / 2, MoveHeight.Value / 2); + } + + public override void Clear() + { + foreach (var column in columns) + { + column.Clear(); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComKeyCounterDisplay.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComKeyCounterDisplay.cs new file mode 100644 index 0000000000..9d6e0c7150 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComKeyCounterDisplay.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Specialized; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Screens; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + public partial class EzComKeyCounterDisplay : Container, ISerialisableDrawable + { + private readonly FillFlowContainer keyFlow; + private readonly IBindableList triggers = new BindableList(); + private IBindable columnWidth = null!; + private IBindable specialFactor = null!; + + [Resolved] + private InputCountController controller { get; set; } = null!; + + [Resolved] + private ISkinSource skin { get; set; } = null!; + + public EzComKeyCounterDisplay() + { + AutoSizeAxes = Axes.Y; + + Child = keyFlow = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0), + }; + } + + [BackgroundDependencyLoader] + private void load(Ez2ConfigManager ezSkinConfig) + { + columnWidth = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + specialFactor = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + updateWidths(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + triggers.BindTo(controller.Triggers); + triggers.BindCollectionChanged(triggersChanged, true); + columnWidth.BindValueChanged(_ => updateWidths(), true); + specialFactor.BindValueChanged(_ => updateWidths(), true); + } + + private void updateWidths() + { + int keyCount = keyFlow.Count; + + if (keyCount <= 0) + return; + + float totalWidth = 0; + + for (int i = 0; i < keyCount; i++) + { + float? widthS = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) + ?.Value; + + float newWidth = widthS ?? (float)columnWidth.Value; + + keyFlow[i].Width = newWidth; + totalWidth += newWidth; + } + + Width = totalWidth; + } + + private void triggersChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + keyFlow.Clear(); + + for (int i = 0; i < controller.Triggers.Count; i++) + { + float? widthS = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) + ?.Value; + + if (widthS == 0) + continue; + + keyFlow.Add(new EzKeyCounter(controller.Triggers[i])); + } + + // foreach (var trigger in controller.Triggers) + // keyFlow.Add(new EzKeyCounter(trigger)); + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComO2JamPillUI.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComO2JamPillUI.cs new file mode 100644 index 0000000000..9066e4c0fc --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/EzComO2JamPillUI.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osuTK.Graphics; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + /// + /// HUD 组件:显示 Pills(💊)图标数量,并提供两个下拉选项:Pill 精灵图选择 和 排列方向(横向/纵向)。 + /// 外部负责将游戏中的 PillCount 同步到此控件的 `UpdatePillCount(int)`。 + /// + public partial class EzComO2JamPillUI : CompositeDrawable, ISerialisableDrawable + { + public enum PillSprite + { + CheckCircle, + Heart, + Star, + ThumbsUp, + ModSuddenDeath, + } + + [SettingSource("Pill Sprite", "(药丸图)Pill Sprite")] + public Bindable SpriteDropdown { get; } = new Bindable(PillSprite.CheckCircle); + + [SettingSource("Pill Direction", "(药丸方向)Pill Direction")] + public Bindable PillFillDirection { get; } = new Bindable(FillDirection.Vertical); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.CornerRadius), nameof(SkinnableComponentStrings.CornerRadiusDescription), + SettingControlType = typeof(SettingsPercentageSlider))] + public new BindableFloat CornerRadius { get; } = new BindableFloat(0.25f) + { + MinValue = 0, + MaxValue = 0.5f, + Precision = 0.01f + }; + + [SettingSource("Box Element Alpha", "The alpha value of background")] + public BindableNumber BoxElementAlpha { get; } = new BindableNumber(0.7f) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + + // 可配置的精灵表(使用 osu 图标系统) + private static readonly IconUsage[] pill_sprites = new[] + { + OsuIcon.CheckCircle, + OsuIcon.Heart, + OsuIcon.Star, + OsuIcon.ThumbsUp, + OsuIcon.ModSuddenDeath, + }; + + public Bindable PillCount { get; set; } = new Bindable(); + + private int currentPillCount; + + private FillFlowContainer pillContainer = null!; + + private Container backgroundContainer = null!; + + private bool rebuildScheduled; + private bool layoutScheduled; + + public EzComO2JamPillUI() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + + InternalChildren = new Drawable[] + { + // 半透明背景底框 + backgroundContainer = new Container + { + Size = new Vector2(60, 280), // 默认垂直形状 + Masking = true, + CornerRadius = 8, + Alpha = BoxElementAlpha.Value, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.7f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Alpha = 0.3f, + } + } + }, + pillContainer = new FillFlowContainer + { + Name = "pills", + RelativeSizeAxes = Axes.Both, + Margin = new MarginPadding(5), + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + // Children = new Drawable[] + // { + // new OsuSpriteText + // { + // Text = "💊", + // Font = new FontUsage(null, 40), + // } + // } + } + }; + + BoxElementAlpha.BindValueChanged(value => requestAlphaUpdate(value.NewValue), true); + SpriteDropdown.BindValueChanged(_ => requestRebuild()); + PillFillDirection.BindValueChanged(_ => requestLayoutUpdate()); + } + + private void requestAlphaUpdate(float alpha) + { + // Mutations must occur on the update thread. + Schedule(() => + { + if (IsDisposed) + return; + + backgroundContainer.Alpha = alpha; + }); + } + + private void requestLayoutUpdate() + { + if (layoutScheduled) + return; + + layoutScheduled = true; + + Schedule(() => + { + layoutScheduled = false; + + if (IsDisposed) + return; + + pillContainer.Direction = PillFillDirection.Value; + backgroundContainer.Size = PillFillDirection.Value == FillDirection.Vertical + ? new Vector2(60, 280) + : new Vector2(280, 60); + + requestRebuild(); + }); + } + + private void requestRebuild() + { + if (rebuildScheduled) + return; + + rebuildScheduled = true; + + Schedule(() => + { + rebuildScheduled = false; + + if (IsDisposed) + return; + + rebuildPills(); + }); + } + + private void rebuildPills() + { + pillContainer.Clear(); + + for (int i = 0; i < currentPillCount; i++) + { + pillContainer.Add( + new SpriteIcon + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Size = new Vector2(50, 50), + Icon = pill_sprites[(int)SpriteDropdown.Value], + Colour = Color4.White + }); + // new OsuSpriteText + // { + // Text = "💊", + // Font = new FontUsage(null, 40), + // }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + PillCount.BindTo(O2HitModeExtension.PILL_COUNT); + + PillCount.BindValueChanged(value => + { + currentPillCount = value.NewValue; + // Logger.Log($"[EzComO2JamPillUI] PillCount changed -> {currentPillCount}"); + requestRebuild(); + }, true); + + AccentColour.BindValueChanged(_ => Colour = AccentColour.Value, true); + } + + protected override void Update() + { + base.Update(); + + base.CornerRadius = CornerRadius.Value * Math.Min(DrawWidth, DrawHeight); + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/YuComFastSlowDisplay.cs b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/YuComFastSlowDisplay.cs new file mode 100644 index 0000000000..ed7c5a54a7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Ez2HUD/YuComFastSlowDisplay.cs @@ -0,0 +1,674 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.LAsEZMania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.HitErrorMeters; + +namespace osu.Game.Rulesets.Mania.Skinning.Ez2HUD +{ + /// + /// 判定快慢显示 HUD 组件。可以自定义 表示Early/Late的字符 + /// 代码文件来自于 YuLiangSSS。 + /// + public partial class YuComFastSlowDisplay : HitErrorMeter + { + public const float DEFAULT_FONT_SIZE = 25f; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.ShowJudgement), nameof(FastSlowDisplayStrings.ShowStyleDescription))] + public Bindable Judgement { get; } = new Bindable(ManiaHitResult.Perfect); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.Gap), nameof(FastSlowDisplayStrings.GapDescription))] + public BindableNumber Gap { get; } = new BindableNumber(50) + { + MinValue = -200, + MaxValue = 200, + Precision = 0.1f, + }; + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.FadeDuration), nameof(FastSlowDisplayStrings.FadeDurationDescription))] + public BindableNumber FadeDuration { get; } = new BindableNumber(430) + { + MinValue = 0, + MaxValue = 2000, + Precision = 10, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font))] + public Bindable Font { get; } = new Bindable(Typeface.Torus); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.FontSize), nameof(FastSlowDisplayStrings.FontSizeDescription))] + public BindableNumber FontSize { get; } = new BindableNumber(DEFAULT_FONT_SIZE) + { + MinValue = 1, + MaxValue = 100, + Precision = 0.1f, + }; + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.FastText), nameof(FastSlowDisplayStrings.TextDescription))] + public Bindable FastText { get; } = new Bindable("Fast"); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.SlowText), nameof(FastSlowDisplayStrings.TextDescription))] + public Bindable SlowText { get; } = new Bindable("Slow"); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.FastColourStyle), nameof(FastSlowDisplayStrings.FastColourStyleDescription))] + public Bindable FastColourStyle { get; } = new Bindable(); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.FastColour), nameof(FastSlowDisplayStrings.TextColourDescription))] + public BindableColour4 FastColour { get; } = new BindableColour4(Colour4.FromHex("#97A5FF")); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.FastColour), nameof(FastSlowDisplayStrings.TextColourDescription))] + public BindableColour4 FastColourGradient { get; } = new BindableColour4(Colour4.LightPink); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.SlowColourStyle), nameof(FastSlowDisplayStrings.SlowColourStyleDescription))] + public Bindable SlowColourStyle { get; } = new Bindable(); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.SlowColour), nameof(FastSlowDisplayStrings.TextColourDescription))] + public BindableColour4 SlowColour { get; } = new BindableColour4(Colour4.FromHex("#D1FF74")); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.SlowColour), nameof(FastSlowDisplayStrings.TextColourDescription))] + public BindableColour4 SlowColourGradient { get; } = new BindableColour4(Colour4.LightCyan); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.DisplayStyle), nameof(FastSlowDisplayStrings.DisplayStyleDescription))] + public BindableBool DisplayStyle { get; } = new BindableBool(false); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.LowerColumn), nameof(FastSlowDisplayStrings.LowerColumnDescription))] + public BindableNumber LowerColumnBound { get; } = new BindableNumber(1) + { + MinValue = 1, + MaxValue = 18, + Precision = 1, + }; + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.UpperColumn), nameof(FastSlowDisplayStrings.UpperColumnDescription))] + public BindableNumber UpperColumnBound { get; } = new BindableNumber(18) + { + MinValue = 1, + MaxValue = 18, + Precision = 1, + }; + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.OnlyDisplayOne), nameof(FastSlowDisplayStrings.OnlyDisplayOneDescription))] + public BindableBool OnlyDisplayOne { get; } = new BindableBool(false); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.SelectColumn), nameof(FastSlowDisplayStrings.SelectColumnDescription))] + public Bindable SelectColumn { get; } = new Bindable(); + + private Container textContainer = null!; + private Container fast = null!; + private Container slow = null!; + private Container test = null!; + + private OsuSpriteText displayFastText = null!; + private OsuSpriteText displaySlowText = null!; + private OsuSpriteText testText = null!; + + private string fastTextString = string.Empty; + private string slowTextString = string.Empty; + private string fastTextLNString = string.Empty; + private string slowTextLNString = string.Empty; + + private BindableNumber gap = new BindableNumber(); + + private (HitResult result, double length)[] hitWindows = null!; + + public YuComFastSlowDisplay() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + const int text_height = 20; + const int text_width = 250; + + hitWindows = HitWindows.GetAllAvailableWindows().ToArray(); + + InternalChild = new Container + { + Height = text_height, + Width = text_width, + Margin = new MarginPadding(2), + Children = new Drawable[] + { + textContainer = new Container + { + Name = "fast slow text", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + fast = new Container + { + Name = "fast", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = Gap.Value, + Children = new Drawable[] + { + displayFastText = new OsuSpriteText + { + Font = OsuFont.Numeric.With(size: FontSize.Value), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + slow = new Container + { + Name = "slow", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = -Gap.Value, + Children = new Drawable[] + { + displaySlowText = new OsuSpriteText + { + Font = OsuFont.Numeric.With(size: FontSize.Value), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + + test = new Container + { + Name = "test", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = Gap.Value, + Children = new Drawable[] + { + testText = new OsuSpriteText + { + Text = "Test", + Font = OsuFont.Numeric.With(size: FontSize.Value), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Colour4.White, + Alpha = 0 + } + } + } + } + } + } + }; + + //displayFastText.Current.BindTo(FastText); + //displaySlowText.Current.BindTo(SlowText); + + displayFastText.Text = FastText.Value; + displaySlowText.Text = SlowText.Value; + testText.Current.BindTo(TestText); + gap.BindTo(Gap); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Gap.BindValueChanged(e => SetGap(e.NewValue), true); + + DisplayStyle.BindValueChanged(e => SetDisplayStyle(e.NewValue), true); + + SaveText(); + + FastText.BindValueChanged(e => SaveText(), true); + SlowText.BindValueChanged(e => SaveText(), true); + FastTextLN.BindValueChanged(e => SaveText(), true); + SlowTextLN.BindValueChanged(e => SaveText(), true); + + FastColour.BindValueChanged(e => SetFastTextColour(e.NewValue, FastColourGradient.Value), true); + SlowColour.BindValueChanged(e => SetSlowTextColour(e.NewValue, SlowColourGradient.Value), true); + + FastColourGradient.BindValueChanged(e => SetFastTextColour(FastColour.Value, e.NewValue), true); + SlowColourGradient.BindValueChanged(e => SetSlowTextColour(SlowColour.Value, e.NewValue), true); + + FastColourStyle.BindValueChanged(e => + { + if (e.NewValue == ColourStyle.SingleColour) + { + SetFastTextColour(FastColour.Value); + } + else if (e.NewValue == ColourStyle.HorizontalGradient) + { + SetFastTextColour(FastColour.Value, FastColourGradient.Value); + } + else if (e.NewValue == ColourStyle.VerticalGradient) + { + SetFastTextColour(FastColour.Value, FastColourGradient.Value); + } + }, true); + + SlowColourStyle.BindValueChanged(e => + { + if (e.NewValue == ColourStyle.SingleColour) + { + SetSlowTextColour(SlowColour.Value); + } + else if (e.NewValue == ColourStyle.HorizontalGradient) + { + SetSlowTextColour(SlowColour.Value, SlowColourGradient.Value); + } + else if (e.NewValue == ColourStyle.VerticalGradient) + { + SetSlowTextColour(SlowColour.Value, SlowColourGradient.Value); + } + }, true); + + FontSize.BindValueChanged(e => SetFontSize(e.NewValue), true); + Font.BindValueChanged(e => + { + // We only have bold weight for venera, so let's force that. + var fontWeight = e.NewValue == Typeface.Venera ? FontWeight.Bold : FontWeight.Regular; + + var f = OsuFont.GetFont(e.NewValue, weight: fontWeight); + SetFastFont(f); + SetSlowFont(f); + SetTestFont(f); + }, true); + + beatmap.BindValueChanged(_ => Reset(), true); + + //fastText.FadeOut(FadeDuration.Value, Easing.OutQuint); + //slowText.FadeOut(FadeDuration.Value, Easing.OutQuint); + //testText.FadeOut(FadeDuration.Value, Easing.OutQuint); + displayFastText.Alpha = 0; + displaySlowText.Alpha = 0; + + testText.Colour = randomColourInfo(); + + Test.BindValueChanged(e => testText.Alpha = e.NewValue ? 1 : 0, true); + } + + protected void Reset() + { + } + + protected void SaveText() + { + fastTextString = FastText.Value; + slowTextString = SlowText.Value; + fastTextLNString = FastTextLN.Value; + slowTextLNString = SlowTextLN.Value; + } + + private ColourInfo randomColourInfo() + { + var random = new Random(); + + switch (random.Next(3)) + { + case 0: + + return ColourInfo.SingleColour(randomColour()); + + case 1: + + return ColourInfo.GradientHorizontal(randomColour(), randomColour()); + + case 2: + + return ColourInfo.GradientVertical(randomColour(), randomColour()); + } + + return ColourInfo.SingleColour(Colour4.White); + } + + private Colour4 randomColour() + { + var random = new Random(); + return new Colour4((float)random.NextDouble(), (float)random.NextDouble(), (float)random.NextDouble(), 1); + } + + protected void SetDisplayStyle(bool value) + { + if (value) + { + fast.Y = Gap.Value; + slow.Y = -Gap.Value; + test.X = Gap.Value; + fast.X = 0; + slow.X = 0; + test.Y = 0; + } + else + { + fast.Y = 0; + slow.Y = 0; + test.X = 0; + fast.X = Gap.Value; + slow.X = -Gap.Value; + test.Y = Gap.Value; + } + } + + protected void SetFontSize(float value) + { + FontSize.Value = value; + SetFastFont(displayFastText.Font.With(size: value)); + SetSlowFont(displaySlowText.Font.With(size: value)); + SetTestFont(testText.Font.With(size: value)); + } + + protected void SetFastFont(FontUsage font) + { + displayFastText.Font = font.With(size: FontSize.Value); + } + + protected void SetSlowFont(FontUsage font) + { + displaySlowText.Font = font.With(size: FontSize.Value); + } + + protected void SetTestFont(FontUsage font) + { + testText.Font = font.With(size: FontSize.Value); + } + + protected void SetGap(float value) + { + if (DisplayStyle.Value) + { + gap.Value = value; + fast.X = 0; + slow.X = 0; + test.Y = 0; + fast.Y = value; + slow.Y = -value; + test.X = value; + } + else + { + gap.Value = value; + fast.Y = 0; + slow.Y = 0; + test.X = 0; + fast.X = value; + slow.X = -value; + test.Y = value; + } + } + + protected void SetFastTextColour(Colour4 colour, Colour4? gradient = null) + + { + FastColour.Value = colour; + + displayFastText.Colour = colour; + + if (gradient != null && FastColourStyle.Value != ColourStyle.SingleColour) + { + FastColourGradient.Value = gradient.Value; + + if (FastColourStyle.Value == ColourStyle.HorizontalGradient) + { + displayFastText.Colour = ColourInfo.GradientHorizontal(colour, gradient.Value); + } + else if (FastColourStyle.Value == ColourStyle.VerticalGradient) + { + displayFastText.Colour = ColourInfo.GradientVertical(colour, gradient.Value); + } + } + } + + protected void SetSlowTextColour(Colour4 colour, Colour4? gradient = null) + { + SlowColour.Value = colour; + displaySlowText.Colour = colour; + + if (gradient != null && FastColourStyle.Value != ColourStyle.SingleColour) + { + SlowColourGradient.Value = gradient.Value; + + if (SlowColourStyle.Value == ColourStyle.HorizontalGradient) + { + displaySlowText.Colour = ColourInfo.GradientHorizontal(colour, gradient.Value); + } + else if (SlowColourStyle.Value == ColourStyle.VerticalGradient) + { + displaySlowText.Colour = ColourInfo.GradientVertical(colour, gradient.Value); + } + } + } + + public override void Clear() + { + } + + protected override void OnNewJudgement(JudgementResult judgement) + { + if ((!judgement.IsHit || judgement.HitObject.HitWindows?.WindowFor(HitResult.Miss) == 0) && judgement.Type != HitResult.Miss) + { + return; + } + + if (!judgement.Type.IsScorable() || judgement.Type.IsBonus()) + { + return; + } + + var originalColumn = (IHasColumn)judgement.HitObject; + + if (checkHitResult(judgement.Type)) + { + checkColumn(judgement, originalColumn); + } + else // Higher than or equal to the selected judge. + { + } + + if (Test.Value) + { + checkColumn(judgement, originalColumn); + } + } + + private void checkColumn(JudgementResult judgement, IHasColumn? originalColumn) + { + if (originalColumn is null) + { + return; + } + + try + { + int column = originalColumn.Column + 1; + var legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + int keys = legacyRuleset.GetKeyCount(beatmap.Value.BeatmapInfo, mods.Value); + + if (SelectColumn.Value == Column.Middle && keys / 2.0 != Math.Truncate(keys / 2.0) && column == (keys / 2) + 1) + { + displayResult(judgement); + } + else if (SelectColumn.Value == Column.RightHalf && column > keys / 2.0) + { + if (keys % 2 != 0 && column > (keys / 2) + 1) + { + displayResult(judgement); + } + else if (keys % 2 == 0) + { + displayResult(judgement); + } + } + else if (SelectColumn.Value == Column.LeftHalf && column <= keys / 2.0) + { + displayResult(judgement); + } + else if (column >= LowerColumnBound.Value && column <= UpperColumnBound.Value && SelectColumn.Value == Column.None) + { + displayResult(judgement); + } + } + catch (Exception) + { + // Ignore + } + } + + private void displayResult(JudgementResult judgement) + { + if (Test.Value) + { + displayFastText.FadeOutFromOne(FadeDuration.Value, Easing.OutQuint); + displaySlowText.FadeOutFromOne(FadeDuration.Value, Easing.OutQuint); + testText.FadeOutFromOne(FadeDuration.Value, Easing.OutQuint); + + if (LNSwitch.Value) + { + if (judgement.HitObject is TailNote) + { + displayFastText.Text = fastTextLNString; + displaySlowText.Text = slowTextLNString; + } + else if (judgement.HitObject is Note) + { + displayFastText.Text = fastTextString; + displaySlowText.Text = slowTextString; + } + } + + return; + } + + if (judgement.TimeOffset < 0) + { + if (LNSwitch.Value) + { + if (judgement.HitObject is HeadNote) + { + displayFastText.Text = fastTextString; + } + else if (judgement.HitObject is TailNote) + { + displayFastText.Text = fastTextLNString; + } + else if (judgement.HitObject is Note) + { + displayFastText.Text = fastTextString; + } + } + + displayFastText.FadeOutFromOne(FadeDuration.Value, Easing.OutQuint); + + if (OnlyDisplayOne.Value) + { + displaySlowText.FadeOut(0); + } + } + + if (judgement.TimeOffset > 0) + { + if (LNSwitch.Value) + { + if (judgement.HitObject is HeadNote) + { + displaySlowText.Text = slowTextString; + } + else if (judgement.HitObject is TailNote) + { + displaySlowText.Text = slowTextLNString; + } + else if (judgement.HitObject is Note) + { + displaySlowText.Text = slowTextString; + } + } + + displaySlowText.FadeOutFromOne(FadeDuration.Value, Easing.OutQuint); + + if (OnlyDisplayOne.Value) + { + displayFastText.FadeOut(0); + } + } + } + + private bool checkHitResult(HitResult result) + { + int byHit = (int)result - 1; + + if (byHit <= (int)Judgement.Value) + { + return true; // true for display judge. + } + + return false; + } + + public enum Column + { + [LocalisableDescription(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.None))] + None, + + [LocalisableDescription(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.LeftHalf))] + LeftHalf, + + [LocalisableDescription(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.RightHalf))] + RightHalf, + + [LocalisableDescription(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.Middle))] + Middle + } + + public enum ColourStyle + { + [LocalisableDescription(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.SingleColour))] + SingleColour, + + [LocalisableDescription(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.HorizontalGradient))] + HorizontalGradient, + + [LocalisableDescription(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.VerticalGradient))] + VerticalGradient + } + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.LNSwitch), nameof(FastSlowDisplayStrings.LNSwitchDescription))] + public BindableBool LNSwitch { get; } = new BindableBool(false); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.FastTextLN), nameof(FastSlowDisplayStrings.TextDescription))] + public Bindable FastTextLN { get; } = new Bindable("Fast"); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.SlowTextLN), nameof(FastSlowDisplayStrings.TextDescription))] + public Bindable SlowTextLN { get; } = new Bindable("Slow"); + + [SettingSource(typeof(FastSlowDisplayStrings), nameof(FastSlowDisplayStrings.Test), nameof(FastSlowDisplayStrings.TestDescription))] + public BindableBool Test { get; } = new BindableBool(); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextElementText))] + public Bindable TestText { get; } = new Bindable("Test"); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzColumnBackground.cs new file mode 100644 index 0000000000..984d1eb6f8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzColumnBackground.cs @@ -0,0 +1,153 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Screens; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + /// + /// 用于显示列背景的组件,支持按键高亮和暗化效果。 + /// 背景虚化功能由 Stage 级别处理。 + /// + public partial class EzColumnBackground : CompositeDrawable, IKeyBindingHandler + { + private Bindable hitPosition = new Bindable(); + private Color4 brightColour; + private Color4 dimColour; + + private Sprite hitOverlay = null!; + private Box separator = null!; + + private Bindable accentColour = null!; + + [Resolved] + protected Column Column { get; private set; } = null!; + + [Resolved] + private StageDefinition stageDefinition { get; set; } = null!; + + [Resolved] + private TextureStore textures { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + public EzColumnBackground() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + var texture = textures.Get("EzResources/note/ColumnLight.png"); + + hitOverlay = new Sprite + { + Name = "Hit Overlay", + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + // Blending = BlendingParameters.Additive, + Alpha = 0, + Texture = texture, + }; + + separator = new Box + { + Name = "Separator", + Anchor = Anchor.TopRight, + Origin = Anchor.TopCentre, + Width = 2, + Colour = Color4.White.Opacity(0.5f), + Alpha = 0, + }; + + accentColour = new Bindable(ezSkinConfig.GetColumnColor(stageDefinition.Columns, Column.Index)); + accentColour.BindValueChanged(colour => + { + var baseCol = colour.NewValue; + var newColour = baseCol.Darken(3); + if (newColour.A != 0) + newColour = newColour.Opacity(0.8f); + hitOverlay.Colour = newColour; + brightColour = baseCol.Opacity(0.6f); + dimColour = baseCol.Opacity(0); + }, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (Column.BackgroundContainer.Children.OfType().All(b => b.Name != "Separator")) + Column.BackgroundContainer.Add(separator); + + if (!Column.BackgroundContainer.Children.Contains(hitOverlay)) + Column.BackgroundContainer.Add(hitOverlay); + + hitPosition = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + hitPosition.BindValueChanged(_ => updateSeparator(), true); + } + + private void updateSeparator() + { + float h = DrawHeight - (float)hitPosition.Value; + hitOverlay.Y = -(float)hitPosition.Value; + hitOverlay.Height = h; + separator.Height = h; + separator.Alpha = drawSeparator(Column.Index, stageDefinition) ? 0.25f : 0; + } + + protected virtual Color4 NoteColor => ezSkinConfig.GetColumnColor(stageDefinition.Columns, Column.Index); + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == Column.Action.Value) + { + var noteColour = NoteColor; + brightColour = noteColour.Opacity(0.9f); + dimColour = noteColour.Opacity(0); + hitOverlay.Colour = ColourInfo.GradientVertical(dimColour, brightColour); + hitOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == Column.Action.Value) + hitOverlay.FadeTo(0, 250, Easing.OutQuint); + } + + //TODO: 这里的逻辑可以优化,避免重复计算 + private bool drawSeparator(int columnIndex, StageDefinition stage) => stage.Columns switch + { + 12 => columnIndex is 0 or 10, + 14 => columnIndex is 0 or 5 or 6 or 11, + 16 => columnIndex is 0 or 5 or 9 or 14, + _ => false + }; + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitExplosion.cs new file mode 100644 index 0000000000..d37bcaed77 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitExplosion.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzHitExplosion : EzNoteBase, IHitExplosion + { + protected override bool BoolUpdateColor => false; + + // public override bool RemoveWhenNotAlive => true; + + private TextureAnimation? primaryAnimation; + private TextureAnimation? goodAnimation; + + public EzHitExplosion() + { + RelativeSizeAxes = Axes.Both; + Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + } + + protected override void OnDrawableChanged() + { + base.OnDrawableChanged(); + + // 清理旧动画 + MainContainer?.Clear(); + + primaryAnimation = Factory.CreateAnimation("noteflare", true); + goodAnimation = Factory.CreateAnimation("noteflaregood", true); + + if (primaryAnimation != null) + MainContainer?.Add(primaryAnimation); + + if (goodAnimation != null) + { + goodAnimation.Alpha = 0; + MainContainer?.Add(goodAnimation); + } + } + + protected override void UpdateSize() + { + base.UpdateSize(); + float moveY = NoteSize.Value.Y / 2; + // baseYPosition = LegacyManiaSkinConfiguration.DEFAULT_HIT_POSITION - (float)hitPosition.Value - moveY; + Position = new Vector2(0, -moveY); + } + + public void Animate(JudgementResult result) + { + if (primaryAnimation?.FrameCount > 0) + { + primaryAnimation.GotoFrame(0); + // primaryAnimation.Restart(); + } + + if (result.Type >= HitResult.Great && goodAnimation?.FrameCount > 0) + { + goodAnimation.Alpha = 1; + goodAnimation.GotoFrame(0); + // goodAnimation.Restart(); + } + + Schedule(UpdateSize); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitTarget.cs new file mode 100644 index 0000000000..7ef91dcc43 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHitTarget.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Screens; +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + internal partial class EzHitTarget : EzNote + { + protected override bool BoolUpdateColor => false; + protected override bool UseColorization => false; + protected override bool ShowSeparators => false; + + protected override string ColorPrefix => "white"; + + private Bindable hitTargetFloatFixed = new Bindable(); + private Bindable hitTargetAlpha = new Bindable(0.3); + + [Resolved] + private IBeatmap beatmap { get; set; } = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + public EzHitTarget() + { + RelativeSizeAxes = Axes.X; + FillMode = FillMode.Fill; + Depth = 1; + } + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + Alpha = (float)hitTargetAlpha.Value; + hitTargetAlpha = EzSkinConfig.GetBindable(Ez2Setting.HitTargetAlpha); + hitTargetAlpha.BindValueChanged(v => Alpha = (float)v.NewValue, true); + + hitTargetFloatFixed = EzSkinConfig.GetBindable(Ez2Setting.HitTargetFloatFixed); + hitTargetFloatFixed.BindValueChanged(_ => updatePosition()); + } + + private double beatInterval; + private bool requiresUpdate = true; + + protected override void LoadComplete() + { + base.LoadComplete(); + calculateBeatInterval(); + requiresUpdate = true; + } + + protected override void Update() + { + base.Update(); + + if (requiresUpdate) + { + updatePosition(); + } + } + + private void calculateBeatInterval() + { + double bpm = beatmap.BeatmapInfo.BPM * gameplayClock.GetTrueGameplayRate(); + beatInterval = 60000 / bpm; + } + + private void updatePosition() + { + // 平滑正弦波效果 + if (beatInterval > 0) + { + double progress = (gameplayClock.CurrentTime % beatInterval) / beatInterval; + double smoothValue = 0.3 * Math.Sin(progress * 2 * Math.PI); + Y = (float)(smoothValue * hitTargetFloatFixed.Value); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHead.cs new file mode 100644 index 0000000000..c6488c9119 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHead.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzHoldNoteHead : EzNoteBase + { + protected override bool ShowSeparators => true; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + FillMode = FillMode.Fill; + } + + protected override void OnDrawableChanged() + { + string newComponentName = $"{ColorPrefix}longnote/head"; + + var animation = Factory.CreateAnimation(newComponentName); + + if (animation is TextureAnimation textureAnimation && textureAnimation.FrameCount == 0) + { + animation.Dispose(); + animation = Factory.CreateAnimation($"{ColorPrefix}note"); + + if (animation is TextureAnimation newTexture && newTexture.FrameCount == 0) + { + animation.Dispose(); + return; + } + + if (MainContainer != null) + { + MainContainer.Clear(); + MainContainer.RelativeSizeAxes = Axes.X; + MainContainer.Anchor = Anchor.BottomCentre; + MainContainer.Origin = Anchor.BottomCentre; + MainContainer.Masking = true; + MainContainer.Child = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Child = animation, + }; + } + } + else + { + if (MainContainer != null) + { + MainContainer.Clear(); + MainContainer.Child = animation; + } + } + + Schedule(UpdateSize); + } + + protected override void UpdateSize() + { + base.UpdateSize(); + float v = NoteSize.Value.Y; + Height = v; + + if (MainContainer?.Children.Count > 0 && MainContainer.Child is Container c) + { + MainContainer.Height = v / 2; + c.Height = v; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHittingLayer.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHittingLayer.cs new file mode 100644 index 0000000000..df95f0496f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteHittingLayer.cs @@ -0,0 +1,99 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzHoldNoteHittingLayer : EzNoteBase + { + protected override bool BoolUpdateColor => false; + public readonly Bindable IsHitting = new Bindable(); + private TextureAnimation? animation; + + public IBindable HitPosition { get; } = new Bindable(); + + public EzHoldNoteHittingLayer() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.None; + Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader] + private void load() + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + if (animation == null) + OnDrawableChanged(); + + HitPosition.BindValueChanged(_ => UpdateSize(), true); + IsHitting.BindValueChanged(hitting => + { + ClearTransforms(); + + if (hitting.NewValue && animation.IsNotNull() && animation.FrameCount > 0) + { + Alpha = 1; + animation.Restart(); + } + else + { + Alpha = 0; + } + }, true); + } + + public void Recycle() + { + ClearTransforms(); + Alpha = 0; + } + + protected override void OnDrawableChanged() + { + ClearInternal(); + string[] componentsToTry = { "longnoteflare", "noteflaregood", "noteflare" }; + + foreach (string component in componentsToTry) + { + animation = Factory.CreateAnimation(component, true); + + if (animation != null) + { + if (animation.FrameCount > 0) + { + animation.Loop = true; + AddInternal(animation); + UpdateSize(); + break; + } + + animation.Dispose(); + } + } + + if (animation == null || animation.FrameCount == 0) + { + UpdateColor(); + } + } + + protected override void UpdateSize() + { + base.UpdateSize(); + float v = -(float)HitPosition.Value - NoteSize.Value.Y / 2; + Position = new Vector2(0, v); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteMiddle.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteMiddle.cs new file mode 100644 index 0000000000..05fc08e036 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteMiddle.cs @@ -0,0 +1,249 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Mania.Skinning.Legacy; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Screens; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzHoldNoteMiddle : EzNoteBase, IHoldNoteBody + { + private readonly IBindable isHitting = new Bindable(); + private DrawableHoldNote holdNote = null!; + + private Container? topContainer; + private Container? bodyContainer; + private Container? bodyScaleContainer; + private Container? bodyInnerContainer; + + private Bindable tailAlpha = null!; + private Bindable tailMaskHeight = new Bindable(); + private IBindable hitPosition = new Bindable(); + private EzHoldNoteHittingLayer? hittingLayer; + private Drawable? lightContainer; + + private float tailHeight; + + public EzHoldNoteMiddle() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject drawableObject) + { + holdNote = (DrawableHoldNote)drawableObject; + isHitting.BindTo(holdNote.IsHolding); + + hitPosition = EzSkinConfig.GetBindable(Ez2Setting.HitPosition); + tailMaskHeight = EzSkinConfig.GetBindable(Ez2Setting.ManiaHoldTailMaskGradientHeight); + tailAlpha = EzSkinConfig.GetBindable(Ez2Setting.ManiaHoldTailAlpha); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + isHitting.BindValueChanged(onIsHittingChanged, true); + + tailMaskHeight.BindValueChanged(_ => UpdateSize(), true); + tailAlpha.BindValueChanged(_ => UpdateSize(), true); + // 确保光效层被正确初始化 + if (lightContainer == null) + OnLightChanged(); + } + + private void OnLightChanged() + { + if (lightContainer != null) + { + Column.TopLevelContainer.Remove(lightContainer, false); + lightContainer.Expire(); + lightContainer = null; + } + + hittingLayer = new EzHoldNoteHittingLayer + { + Alpha = 0, + IsHitting = { BindTarget = isHitting } + }; + + lightContainer = new HitTargetInsetContainer + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Child = hittingLayer + }; + + hittingLayer.HitPosition.BindTo(hitPosition); + } + + private void onIsHittingChanged(ValueChangedEvent isHitting) + { + if (hittingLayer != null) hittingLayer.IsHitting.Value = isHitting.NewValue; + + if (lightContainer == null) + return; + + if (isHitting.NewValue) + { + lightContainer.ClearTransforms(); + + if (lightContainer.Parent == null) + Column.TopLevelContainer.Add(lightContainer); + + lightContainer.FadeIn(80); + } + else + { + lightContainer.FadeOut(120) + .OnComplete(d => Column.TopLevelContainer.Remove(d, false)); + } + } + + public void Recycle() + { + ClearTransforms(); + hittingLayer?.Recycle(); + } + + protected override void OnDrawableChanged() + { + // 清理之前的光效层和容器 + if (lightContainer != null) + { + if (lightContainer.Parent != null) + Column.TopLevelContainer.Remove(lightContainer, false); + lightContainer.Expire(); + lightContainer = null; + } + + if (hittingLayer != null) + { + hittingLayer.Expire(); + hittingLayer = null; + } + + var body = Factory.CreateAnimation($"{ColorPrefix}longnote/middle"); + var tail = Factory.CreateAnimation($"{ColorPrefix}longnote/tail"); + + string newComponentName = $"{ColorPrefix}note"; + if (body.FrameCount == 0) + body = Factory.CreateAnimation(newComponentName); + + if (tail.FrameCount == 0) + tail = Factory.CreateAnimation(newComponentName); + + topContainer?.Expire(); + bodyContainer?.Expire(); + + topContainer = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Masking = true, + Child = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = tail + } + }; + bodyContainer = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Masking = true, + Child = bodyScaleContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = bodyInnerContainer = new Container + { + RelativeSizeAxes = Axes.X, + Child = body + } + } + }; + + if (MainContainer != null) + { + MainContainer.Clear(); + MainContainer.Children = [bodyContainer, topContainer]; + } + + // 重新初始化光效层 + OnLightChanged(); + + Schedule(UpdateSize); + } + + protected override void UpdateSize() + { + base.UpdateSize(); + tailHeight = NoteSize.Value.Y * 0.5f; + + if (topContainer?.Child is Container topInner) + { + topContainer.Height = tailHeight; + topInner.Height = tailHeight * 2; + topContainer.Y = tailMaskHeight.Value > 0 + ? (float)tailMaskHeight.Value + : 0; + } + + if (bodyInnerContainer != null) + { + bodyInnerContainer.Height = tailHeight * 2; + bodyInnerContainer.Y = -tailHeight; + } + + // TODO: V3版应该增加一个顶部Dot标识,以免常规图无法分辨正确的面尾 + } + + protected override void Update() + { + base.Update(); + + if (MainContainer?.Children.Count > 0 && bodyContainer != null && tailHeight > 0) + { + float drawHeightMinusHalf = DrawHeight - tailHeight; + float middleHeight = Math.Max(drawHeightMinusHalf, tailHeight); + + bodyContainer.Height = tailMaskHeight.Value > 0 + ? middleHeight - (float)tailMaskHeight.Value + 1 + : middleHeight + 2; + + if (bodyScaleContainer != null) + bodyScaleContainer.Scale = new Vector2(1, drawHeightMinusHalf); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (isDisposing) + { + hittingLayer?.Expire(); + topContainer?.Expire(); + bodyContainer?.Expire(); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteTail.cs new file mode 100644 index 0000000000..f0e2c77b3a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzHoldNoteTail.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzHoldNoteTail : EzNoteBase + { + private readonly EzHoldNoteHittingLayer hittingLayer = null!; + private TextureAnimation? animation; + private Container container = null!; + + private Bindable enabledColor = null!; + private Bindable tailAlpha = null!; + + [Resolved] + private DrawableHitObject? drawableObject { get; set; } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject? drawableObject) + { + RelativeSizeAxes = Axes.Both; + Alpha = 0f; + + if (drawableObject != null) + { + // accentColour.BindTo(drawableObject.AccentColour); + // accentColour.BindValueChanged(onAccentChanged, true); + + drawableObject.HitObjectApplied += hitObjectApplied; + } + + enabledColor = EzSkinConfig.GetBindable(Ez2Setting.ColorSettingsEnabled); + tailAlpha = EzSkinConfig.GetBindable(Ez2Setting.ManiaHoldTailAlpha); + tailAlpha.BindValueChanged(alpha => + { + Alpha = (float)alpha.NewValue; + }, true); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + if (drawableObject != null) + drawableObject.HitObjectApplied -= hitObjectApplied; + } + + protected virtual string ComponentSuffix => "longnote/tail"; + protected virtual string ComponentName => $"{ColorPrefix}{ComponentSuffix}"; + + protected override void OnDrawableChanged() + { + ClearInternal(); + animation = Factory.CreateAnimation(ComponentName); + + if (animation.FrameCount == 0) + { + animation.Dispose(); + animation = Factory.CreateAnimation($"{ColorPrefix}note"); + } + + container = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Masking = true, + Child = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = animation, + } + }; + + if (enabledColor.Value) + container.Colour = NoteColor; + Schedule(() => + { + Invalidate(); + }); + + AddInternal(container); + } + + private void hitObjectApplied(DrawableHitObject drawableHitObject) + { + var holdNoteTail = (DrawableHoldNoteTail)drawableHitObject; + + // hittingLayer.AccentColour.UnbindBindings(); + // hittingLayer.AccentColour.BindTo(holdNoteTail.HoldNote.AccentColour); + + hittingLayer.IsHitting.UnbindBindings(); + ((IBindable)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHolding); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Up ? -1 : 1); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzJudgementLine.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzJudgementLine.cs new file mode 100644 index 0000000000..c793a126c1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzJudgementLine.cs @@ -0,0 +1,96 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens; +using osuTK; +using osu.Game.LAsEzExtensions; +using osu.Game.LAsEzExtensions.Configuration; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzJudgementLine : CompositeDrawable + { + private Container sprite = null!; + + // [Resolved] + // private StageDefinition stageDefinition { get; set; } = null!; + + [Resolved] + private EzLocalTextureFactory factory { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + private Bindable hitPositonBindable = null!; + private Bindable columnWidth = null!; + private Bindable noteSetName = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + sprite = new Container + { + RelativeSizeAxes = Axes.None, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = -ezSkinConfig.DefaultHitPosition, + } + }; + + noteSetName = ezSkinConfig.GetBindable(Ez2Setting.NoteSetName); + hitPositonBindable = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + columnWidth = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + noteSetName.BindValueChanged(_ => OnDrawableChanged(), true); + + hitPositonBindable.BindValueChanged(_ => updateSizes(), true); + columnWidth.BindValueChanged(_ => updateSizes(), true); + } + + protected override void Update() + { + base.Update(); + updateSizes(); + } + + protected void OnDrawableChanged() + { + sprite.Clear(); + + var container = factory.CreateAnimation("JudgementLine"); + sprite.Add(container); + + // updateSizes(); + } + + private void updateSizes() + { + float actualPanelWidth = DrawWidth; //ezSkinConfig.GetTotalWidth(cs); + float scale = actualPanelWidth / 412.0f; + + sprite.Scale = new Vector2(scale); + sprite.Y = 384f + ezSkinConfig.DefaultHitPosition - (float)hitPositonBindable.Value; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzKeyArea.cs new file mode 100644 index 0000000000..490beb9f5b --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzKeyArea.cs @@ -0,0 +1,217 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Skinning.Legacy; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Screens; +using osu.Game.Screens.Play; +using osuTK; +using osu.Game.LAsEzExtensions; +using osu.Game.LAsEzExtensions.Configuration; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzKeyArea : CompositeDrawable, IKeyBindingHandler + { + private Container sprite = null!; + private TextureAnimation? upSprite; + private TextureAnimation? downSprite; + protected virtual bool IsKeyPress => true; + protected virtual bool UseColorization => true; + + [Resolved] + private IBeatmap beatmap { get; set; } = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [Resolved] + private Column column { get; set; } = null!; + + [Resolved] + private StageDefinition stageDefinition { get; set; } = null!; + + [Resolved] + private EzLocalTextureFactory factory { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + private Bindable stageName = null!; + private Bindable hitPositonBindable = null!; + + private double bpm; + private double beatInterval; + private int keyMode; + private int columnIndex; + + public EzKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + sprite = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + FillMode = FillMode.Stretch, + }; + + keyMode = stageDefinition.Columns; + columnIndex = column.Index; + + stageName = ezSkinConfig.GetBindable(Ez2Setting.StageName); + hitPositonBindable = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + + bpm = beatmap.ControlPointInfo.TimingPointAt(gameplayClock.CurrentTime).BPM * gameplayClock.GetTrueGameplayRate(); + beatInterval = 60000 / bpm * 64; + + bool isFreeSize = free_size_stages.Contains(stageName.Value); + + if (isFreeSize) + { + sprite.RelativeSizeAxes = Axes.None; + sprite.AutoSizeAxes = Axes.Both; + sprite.Scale = new Vector2(2f); + AddInternal(sprite); + } + else + { + column.TopLevelContainer.Add(sprite); + } + + loadAnimation(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + stageName.BindValueChanged(_ => loadAnimation(), true); + hitPositonBindable.BindValueChanged(_ => OnConfigChanged(), true); + ezSkinConfig.OnNoteSizeChanged += OnConfigChanged; + } + + protected virtual string KeySuffix + { + get + { + var typeList = ezSkinConfig.GetColumnTypes(keyMode); + + switch (typeList[columnIndex]) + { + case EzColumnType.A: + return "0"; + + case EzColumnType.B: + return "1"; + + case EzColumnType.S: + case EzColumnType.P: + case EzColumnType.E: + return "2"; + + default: + return "0"; + } + } + } + + private void loadAnimation() + { + if (keyMode == 14 && columnIndex == 13) return; + + // ClearInternal(); + upSprite?.ClearFrames(); + downSprite?.ClearFrames(); + + upSprite = factory.CreateStageKeys("KeyBase", KeySuffix); + downSprite = factory.CreateStageKeys("KeyPress", KeySuffix); + + // upSprite.DefaultFrameLength = beatInterval; + // downSprite.DefaultFrameLength = beatInterval; + downSprite.Alpha = 0; + + // sprite.Clear(); + sprite.Add(upSprite); + sprite.Add(downSprite); + + OnConfigChanged(); + } + + private void OnConfigChanged() + { + float actualPanelWidth = factory.GetNoteSize(keyMode, columnIndex, true).Value.X; + float baseWidth = 410f / keyMode; + float scale = actualPanelWidth / baseWidth; + + sprite.Scale = new Vector2(2f, 2 * scale); + + sprite.Y = 768f - (float)hitPositonBindable.Value + 2f; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == column.Action.Value) + { + upSprite.FadeTo(0); + downSprite.FadeTo(1); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == column.Action.Value) + { + upSprite?.Delay(LegacyHitExplosion.FADE_IN_DURATION).FadeTo(1); + downSprite?.Delay(LegacyHitExplosion.FADE_IN_DURATION).FadeTo(0); + } + } + + private static readonly HashSet free_size_stages = new HashSet + { + "AZURE_EXPRESSION", + "Celeste_Lumiere", + "EC_Wheel", + "EVOLVE", + "Fortress3_Gear", + "Fortress3_Modern", + "GC", + "NIGHT_FALL", + "TANOc2", + "TECHNIKA", + }; + + public enum EzEnumGameThemeNameForFreeSize + { + // ReSharper disable InconsistentNaming + AZURE_EXPRESSION, + Celeste_Lumiere, + EC_Wheel, + EVOLVE, + Fortress3_Gear, + Fortress3_Modern, + GC, + NIGHT_FALL, + TANOc2, + TECHNIKA, + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNote.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNote.cs new file mode 100644 index 0000000000..79b45cc063 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNote.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzNote : EzNoteBase + { + protected override bool ShowSeparators => true; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + FillMode = FillMode.Fill; + } + + protected override void OnDrawableChanged() + { + var animation = Factory.CreateAnimation($"{ColorPrefix}note"); + + if (animation is TextureAnimation textureAnimation && textureAnimation.FrameCount == 0) + { + animation.Dispose(); + UpdateColor(); + return; + } + + if (MainContainer != null) + { + MainContainer.Clear(); + MainContainer.Child = animation; + } + + UpdateSize(); + UpdateColor(); + } + + protected override void UpdateSize() + { + base.UpdateSize(); + float v = NoteSize.Value.Y; + Height = v; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteBase.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteBase.cs new file mode 100644 index 0000000000..cdf21912d2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteBase.cs @@ -0,0 +1,199 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Screens; +using osuTK; +using osu.Game.LAsEzExtensions; +using osu.Game.LAsEzExtensions.Configuration; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public abstract partial class EzNoteBase : CompositeDrawable + { + protected virtual bool BoolUpdateColor => true; + protected virtual bool UseColorization => true; + protected virtual bool ShowSeparators => false; + + protected Container? LineContainer { get; private set; } + protected Container? MainContainer { get; private set; } + + [Resolved] + protected Column Column { get; private set; } = null!; + + [Resolved] + protected StageDefinition StageDefinition { get; private set; } = null!; + + [Resolved] + protected Ez2ConfigManager EzSkinConfig { get; private set; } = null!; + + [Resolved] + protected EzLocalTextureFactory Factory { get; private set; } = null!; + + // private IBindable columnColorBindable = null!; + protected Bindable EnabledColor = null!; + protected Bindable NoteSize = null!; + protected Bindable NoteSetName = null!; + protected int KeyMode; + protected int ColumnIndex; + + [BackgroundDependencyLoader] + private void load() + { + KeyMode = StageDefinition.Columns; + ColumnIndex = Column.Index; + EnabledColor = EzSkinConfig.GetBindable(Ez2Setting.ColorSettingsEnabled); + // columnColorBindable = EzSkinConfig.GetColumnColorBindable(KeyMode, ColumnIndex); + NoteSetName = EzSkinConfig.GetBindable(Ez2Setting.NoteSetName); + + createSeparators(); + MainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + AddInternal(MainContainer); //允许多个子元素 + + UpdateSize(); + Scheduler.AddOnce(OnDrawableChanged); + } + + private void createSeparators() + { + var noteSeparatorsL = new EzNoteSideLine + { + RelativeSizeAxes = Axes.X, + FillMode = FillMode.Fill, + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + }; + + var noteSeparatorsR = new EzNoteSideLine + { + RelativeSizeAxes = Axes.X, + FillMode = FillMode.Fill, + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + }; + + LineContainer = new Container + { + RelativeSizeAxes = Axes.X, + FillMode = FillMode.Stretch, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = ShowSeparators ? 1f : 0f, + Children = [noteSeparatorsL, noteSeparatorsR] + }; + + AddInternal(LineContainer); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + NoteSetName.BindValueChanged(OnNoteChanged); + NoteSize.BindValueChanged(_ => UpdateSize(), true); + EnabledColor.BindValueChanged(_ => UpdateColor(), true); + EzSkinConfig.OnNoteColourChanged += UpdateColor; + EzSkinConfig.OnNoteSizeChanged += (() => + { + UpdatedColor = false; + UpdateSize(); + }); + // columnColorBindable.BindValueChanged(_ => UpdateColor(), true); + } + + protected bool UpdatedColor; + + private void OnNoteChanged(ValueChangedEvent obj) + { + if (string.IsNullOrEmpty(obj.NewValue)) + return; + + MainContainer?.Clear(); + + Scheduler.AddOnce(OnDrawableChanged); + } + + protected override void Update() + { + base.Update(); + + if (!UpdatedColor) + UpdateColor(); + } + + protected virtual void UpdateSize() + { + NoteSize = Factory.GetNoteSize(KeyMode, ColumnIndex); + UpdateColor(); + } + + protected virtual void UpdateColor() + { + if (BoolUpdateColor) + { + // TODO: 命中HMT面条时,考虑是否跳过面头着色,只更新面身。 + // 或者单独做一个着色拓展设置,比如“仅着色面身”“着色面头和面身”“不着色”等等。 + // 或者,不着色时,使用新的开关拓展,在Middle中进一步单独管理着色 + if (MainContainer != null) + MainContainer.Colour = NoteColor; + + if (LineContainer?.Children != null) + { + foreach (var child in LineContainer.Children) + { + if (child is EzNoteSideLine sideLine) + sideLine.UpdateGlowEffect(NoteColor); + } + } + + UpdatedColor = true; + } + } + + protected virtual Colour4 NoteColor => (EnabledColor.Value && UseColorization) + ? EzSkinConfig.GetColumnColor(KeyMode, ColumnIndex) + : Colour4.White; + + protected virtual string ColorPrefix + { + get + { + if (EnabledColor.Value) return "white"; + + EzColumnType keyType = EzSkinConfig.GetColumnType(KeyMode, ColumnIndex); + + return keyType switch + { + EzColumnType.A => "white", + EzColumnType.B => "blue", + EzColumnType.S => "green", + EzColumnType.E => "white", + EzColumnType.P => "green", + _ => "white" + }; + } + } + + protected virtual void OnDrawableChanged() { } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + EzLocalTextureFactory.ClearGlobalCache(); + EzSkinConfig.OnNoteColourChanged -= UpdateColor; + EzSkinConfig.OnNoteSizeChanged -= UpdateSize; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteSideLine.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteSideLine.cs new file mode 100644 index 0000000000..dc05888bd2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzNoteSideLine.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Screens; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzNoteSideLine : CompositeDrawable + { + private Drawable separator = null!; + private Bindable noteTrackLineHeight = null!; + + [Resolved] + private TextureStore textures { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AlwaysPresent = true; + var texture = textures.Get("EzResources/note/NoteSideLine.png"); + + InternalChild = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + separator = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Child = new Sprite + { + RelativeSizeAxes = Axes.Y, + Width = 10, + Scale = new Vector2(2f, 1), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = texture, + // TextureRelativeSizeAxes = Axes.Y, + }, + } + } + }; + + noteTrackLineHeight = ezSkinConfig.GetBindable(Ez2Setting.NoteTrackLineHeight); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateSizes(); + noteTrackLineHeight.BindValueChanged(_ => updateSizes(), true); + } + + private void updateSizes() + { + separator.Height = (float)noteTrackLineHeight.Value; + } + + public void UpdateGlowEffect(Colour4 color) + { + separator.Colour = new ColourInfo + { + TopLeft = color, + BottomRight = color.Lighten(1.05f), + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzStageBottom.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzStageBottom.cs new file mode 100644 index 0000000000..a759144561 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/EzStageBottom.cs @@ -0,0 +1,105 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Screens; +using osu.Game.Skinning; +using osuTK; +using osu.Game.LAsEzExtensions; +using osu.Game.LAsEzExtensions.Configuration; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public partial class EzStageBottom : CompositeDrawable + { + private Bindable hitPositonBindable = null!; + private Bindable columnWidth = null!; + private Bindable stageName = null!; + private Container sprite = null!; + private int cs; + + protected virtual bool OpenEffect => true; + + [Resolved] + private StageDefinition stageDefinition { get; set; } = null!; + + [Resolved] + private EzLocalTextureFactory factory { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChild = + sprite = new Container + { + RelativeSizeAxes = Axes.None, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + cs = stageDefinition.Columns; + + hitPositonBindable = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + columnWidth = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + stageName = ezSkinConfig.GetBindable(Ez2Setting.StageName); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + stageName.BindValueChanged(_ => OnSkinChanged(), true); + // hitPositonBindable.BindValueChanged(_ => updateSizes(), true); + // columnWidth.BindValueChanged(_ => updateSizes(), true); + } + + protected override void Update() + { + base.Update(); + updateSizes(); + } + + private void OnSkinChanged() + { + sprite.Clear(); + + var container = factory.CreateStage("Body"); + sprite.Add(container); + + // var judgeLine = new EzJudgementLine(); + // sprite.Add(judgeLine); + // updateSizes(); + } + + private void updateSizes() + { + float actualPanelWidth = DrawWidth; //ezSkinConfig.GetTotalWidth(cs); + float scale = actualPanelWidth / 412.0f; + + sprite.Scale = new Vector2(scale); + sprite.Y = 205f - 384f * scale + ezSkinConfig.DefaultHitPosition - (float)hitPositonBindable.Value; + + // 计算纹理高度和位置 + // float textureHeight = sprite.Child.Height * scale; + // float textureTopY = DrawHeight + sprite.Y - textureHeight / 2; + + // 当纹理顶部低于屏幕顶部时隐藏 + // sprite.Alpha = textureTopY != 0 + // ? 1 + // : 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/EzStylePro/ManiaEzStyleProSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/ManiaEzStyleProSkinTransformer.cs new file mode 100644 index 0000000000..b335231451 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/EzStylePro/ManiaEzStyleProSkinTransformer.cs @@ -0,0 +1,279 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Skinning.Ez2HUD; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning.EzStylePro +{ + public class ManiaEzStyleProSkinTransformer : SkinTransformer + { + private readonly Ez2ConfigManager ezSkinConfig; + private readonly ManiaBeatmap beatmap; + private readonly IBindable columnWidthBindable; + private readonly IBindable specialFactorBindable; + private readonly IBindable hitPosition; + private readonly IBindable virtualHitPosition; + + //EzSkinSettings即使不用也不能删,否则特殊列计算会出错 + public ManiaEzStyleProSkinTransformer(ISkin skin, IBeatmap beatmap, Ez2ConfigManager ezSkinConfig) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + this.ezSkinConfig = ezSkinConfig; + columnWidthBindable = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + specialFactorBindable = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + hitPosition = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + virtualHitPosition = ezSkinConfig.GetBindable(Ez2Setting.VisualHitPosition); + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + case GlobalSkinnableContainerLookup containerLookup: + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var hitTiming = container.ChildrenOfType().ToArray(); + + if (hitTiming.Length >= 2) + { + var hitTiming1 = hitTiming[0]; + var hitTiming2 = hitTiming[1]; + const float mirror_x = 500; + + hitTiming1.Anchor = Anchor.Centre; + hitTiming1.Origin = Anchor.Centre; + hitTiming1.X = -mirror_x; + // hitTiming1.Scale = new Vector2(2); + hitTiming1.AloneShow.Value = AloneShowMenu.Early; + + hitTiming2.Anchor = Anchor.Centre; + hitTiming2.Origin = Anchor.Centre; + hitTiming2.X = mirror_x; + // hitTiming2.Scale = new Vector2(2); + hitTiming2.AloneShow.Value = AloneShowMenu.Late; + } + + var comboSprite = container.ChildrenOfType().FirstOrDefault(); + + if (comboSprite != null) + { + comboSprite.Anchor = Anchor.TopCentre; + comboSprite.Origin = Anchor.Centre; + comboSprite.Y = 190; + } + + var combos = container.ChildrenOfType().ToArray(); + + if (combos.Length >= 2) + { + var combo1 = combos[0]; + var combo2 = combos[1]; + + combo1.Anchor = Anchor.TopCentre; + combo1.Origin = Anchor.TopCentre; + combo1.Y = 200; + combo1.BoxAlpha.Value = 0.8f; + combo1.EffectStartFactor.Value = 1.5f; + combo1.EffectEndFactor.Value = 1f; + combo1.EffectStartTime.Value = 10; + combo1.EffectEndDuration.Value = 500; + + combo2.Anchor = Anchor.TopCentre; + combo2.Origin = Anchor.TopCentre; + combo2.Y = 200; + combo2.BoxAlpha.Value = 0.4f; + combo2.EffectStartFactor.Value = 2.5f; + combo2.EffectEndFactor.Value = 1f; + combo2.EffectStartTime.Value = 10; + combo2.EffectEndDuration.Value = 300; + } + + var keyCounter = container.ChildrenOfType().FirstOrDefault(); + var columnHitErrorMeter = container.OfType().FirstOrDefault(); + + if (keyCounter != null) + { + keyCounter.Anchor = Anchor.BottomCentre; + keyCounter.Origin = Anchor.TopCentre; + keyCounter.Position = new Vector2(0, -(float)hitPosition.Value - stage_padding_bottom); + } + + if (columnHitErrorMeter != null) + { + columnHitErrorMeter.Anchor = Anchor.BottomCentre; + columnHitErrorMeter.Origin = Anchor.Centre; + columnHitErrorMeter.Position = new Vector2(0, -(float)hitPosition.Value - stage_padding_bottom); + } + + var hitErrorMeter = container.OfType().FirstOrDefault(); + + if (hitErrorMeter != null) + { + hitErrorMeter.Anchor = Anchor.Centre; + hitErrorMeter.Origin = Anchor.Centre; + hitErrorMeter.Rotation = -90f; + hitErrorMeter.Position = new Vector2(0, -15); + hitErrorMeter.Scale = new Vector2(1.25f, 1.25f); + hitErrorMeter.JudgementLineThickness.Value = 2; + hitErrorMeter.ShowMovingAverage.Value = true; + hitErrorMeter.ColourBarVisibility.Value = false; + hitErrorMeter.CentreMarkerStyle.Value = BarHitErrorMeter.CentreMarkerStyles.Circle; + hitErrorMeter.LabelStyle.Value = BarHitErrorMeter.LabelStyles.None; + } + + var judgementPiece = container.OfType().FirstOrDefault(); + + if (judgementPiece != null) + { + judgementPiece.Anchor = Anchor.Centre; + judgementPiece.Origin = Anchor.Centre; + judgementPiece.Y = 100; + } + }) + { + new EzComComboSprite(), + new EzComComboCounter(), + new EzComComboCounter(), + new EzComKeyCounterDisplay(), + new EzComHitTimingColumns(), + new BarHitErrorMeter(), + new EzComHitResultScore(), + new EzComHitTiming(), + new EzComHitTiming(), + new EzComO2JamPillUI + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + }; + } + + return null; + + case SkinComponentLookup: + // if (Skin is Ez2Skin && resultComponent.Component > HitResult.Great) + // return Drawable.Empty(); + + // return new Ez2JudgementPiece(resultComponent.Component); + return Drawable.Empty(); + + case ManiaSkinComponentLookup maniaComponent: + if (columnWidth == 0) Drawable.Empty(); + + switch (maniaComponent.Component) + { + case ManiaSkinComponents.ColumnBackground: + // if (Skin is Ez2Skin && resultComponent.Component >= HitResult.Perfect) + // return Drawable.Empty(); + + return new EzColumnBackground(); + + case ManiaSkinComponents.KeyArea: + return new EzKeyArea(); + + case ManiaSkinComponents.Note: + return new EzNote(); + + case ManiaSkinComponents.HoldNoteHead: + return new EzHoldNoteHead(); + + case ManiaSkinComponents.HoldNoteBody: + return new EzHoldNoteMiddle(); + + case ManiaSkinComponents.HoldNoteTail: + // return new EzHoldNoteTail(); + return Drawable.Empty(); + + case ManiaSkinComponents.HitTarget: + return new EzHitTarget(); + + case ManiaSkinComponents.HitExplosion: + return new EzHitExplosion(); + // return HitExplosionPool.Rent(); + + case ManiaSkinComponents.StageBackground: + return new EzStageBottom(); + + case ManiaSkinComponents.StageForeground: + return new EzJudgementLine(); + } + + break; + } + + return base.GetDrawableComponent(lookup); + } + + private const int stage_padding_bottom = 0; + + #region GetConfig + + private float columnWidth; + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + int columnIndex = maniaLookup.ColumnIndex ?? 0; + var stage = beatmap.GetStageForColumnIndex(columnIndex); + bool isSpecialColumn = ezSkinConfig.IsSpecialColumn(stage.Columns, columnIndex); + columnWidth = (float)columnWidthBindable.Value * (isSpecialColumn ? (float)specialFactorBindable.Value : 1f); + // float hitPositionValue = (float)hitPosition.Value; // + (float)virtualHitPosition.Value - 110f; + + if (stage.Columns == 14 && columnIndex == 13) + columnWidth = 0f; + + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + return SkinUtils.As(new Bindable(columnWidth)); + + // case LegacyManiaSkinConfigurationLookups.HitPosition: + // return SkinUtils.As(new Bindable(hitPositionValue)); + + // case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + // var colour = stage.GetColourForLayout(columnIndex); + // return SkinUtils.As(new Bindable(colour)); + + case LegacyManiaSkinConfigurationLookups.BarLineHeight: + return SkinUtils.As(new Bindable(1)); + + case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing: + case LegacyManiaSkinConfigurationLookups.RightColumnSpacing: + return SkinUtils.As(new Bindable(0)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: + return SkinUtils.As(new Bindable(stage_padding_bottom)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingTop: + return SkinUtils.As(new Bindable(0)); + } + } + + return base.GetConfig(lookup); + } + + #endregion + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs index 608cde7272..37f87991fc 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; @@ -14,6 +15,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy public partial class HitTargetInsetContainer : Container { private readonly IBindable direction = new Bindable(); + private Bindable hitPositonBindable = new Bindable(); + private Bindable globalHitPosition = new Bindable(); protected override Container Content => content; private readonly Container content; @@ -28,12 +31,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy } [BackgroundDependencyLoader] - private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + private void load(ISkinSource skin, Ez2ConfigManager ezSkinConfig, IScrollingInfo scrollingInfo) { - hitPosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? Stage.HIT_TARGET_POSITION; - direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); + + globalHitPosition = ezSkinConfig.GetBindable(Ez2Setting.GlobalHitPosition); + hitPositonBindable = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + + hitPosition = globalHitPosition.Value + ? (float)hitPositonBindable.Value + : skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? (float)hitPositonBindable.Value; } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/ManiaSbISkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/ManiaSbISkinTransformer.cs new file mode 100644 index 0000000000..bddcc70cc1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/ManiaSbISkinTransformer.cs @@ -0,0 +1,215 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Background; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Skinning.Ez2HUD; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public class ManiaSbISkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + private readonly Ez2ConfigManager ezSkinConfig; + private readonly IBindable columnWidthBindable; + private readonly IBindable specialFactorBindable; + private readonly IBindable hitPosition; + private readonly IBindable virtualHitPosition; + + public ManiaSbISkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + + if (GlobalConfigStore.EzConfig == null) + { + Logger.Log("!GlobalConfigStore.EzConfig", LoggingTarget.Runtime, LogLevel.Important); + } + + ezSkinConfig = GlobalConfigStore.EzConfig!; + columnWidthBindable = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + specialFactorBindable = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + hitPosition = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + virtualHitPosition = ezSkinConfig.GetBindable(Ez2Setting.VisualHitPosition); + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + case GlobalSkinnableContainerLookup containerLookup: + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var hitTiming = container.ChildrenOfType().ToArray(); + + if (hitTiming.Length >= 2) + { + var hitTiming1 = hitTiming[0]; + var hitTiming2 = hitTiming[1]; + const float mirror_x = 350; + + hitTiming1.Anchor = Anchor.Centre; + hitTiming1.Origin = Anchor.Centre; + hitTiming1.DisplayDuration.Value = hitTiming1.DisplayDuration.MinValue; + hitTiming1.X = -mirror_x; + // hitTiming1.Scale = new Vector2(2); + hitTiming1.AloneShow.Value = AloneShowMenu.Early; + + hitTiming2.Anchor = Anchor.Centre; + hitTiming2.Origin = Anchor.Centre; + hitTiming2.DisplayDuration.Value = hitTiming2.DisplayDuration.MinValue; + hitTiming2.X = mirror_x; + // hitTiming2.Scale = new Vector2(2); + hitTiming2.AloneShow.Value = AloneShowMenu.Late; + } + + var combo1 = container.OfType().FirstOrDefault(); + + if (combo1 != null) + { + combo1.Anchor = Anchor.TopCentre; + combo1.Origin = Anchor.Centre; + combo1.Y = 200; + combo1.Effect.Value = EzComEffectType.None; + } + + var hitErrorMeter = container.OfType().FirstOrDefault(); + + if (hitErrorMeter != null) + { + hitErrorMeter.Anchor = Anchor.Centre; + hitErrorMeter.Origin = Anchor.Centre; + hitErrorMeter.Rotation = -90f; + hitErrorMeter.Position = new Vector2(0, -15); + hitErrorMeter.Scale = new Vector2(1.4f, 1.4f); + hitErrorMeter.JudgementLineThickness.Value = 2; + hitErrorMeter.JudgementFadeOutDuration.Value = hitErrorMeter.JudgementFadeOutDuration.MinValue; + hitErrorMeter.ShowMovingAverage.Value = false; + hitErrorMeter.ColourBarVisibility.Value = false; + hitErrorMeter.CentreMarkerStyle.Value = BarHitErrorMeter.CentreMarkerStyles.Line; + hitErrorMeter.LabelStyle.Value = BarHitErrorMeter.LabelStyles.None; + } + + var fsd = container.OfType().FirstOrDefault(); + }) + { + new EzComHitTiming(), + new EzComHitTiming(), + new EzComComboCounter(), + new BarHitErrorMeter(), + }; + } + + return null; + + case SkinComponentLookup: + // if (Skin is SbISkin && resultComponent.Component >= HitResult.Great) + // return Drawable.Empty(); + // return new EzComJudgementTexture(resultComponent.Component); + // return new SbIJudgementPiece(resultComponent.Component); + return Drawable.Empty(); + + case ManiaSkinComponentLookup maniaComponent: + switch (maniaComponent.Component) + { + // case ManiaSkinComponents.StageBackground: + // return new SbIStageBackground(); + + case ManiaSkinComponents.ColumnBackground: + // if (Skin is SbISkin && resultComponent.Component >= HitResult.Perfect) + // return Drawable.Empty(); + return new SbIColumnBackground(); + + case ManiaSkinComponents.Note: + return new SbINotePiece(); + + case ManiaSkinComponents.HoldNoteHead: + return new SbIHoldNoteHeadPiece(); + + case ManiaSkinComponents.HoldNoteTail: + return new SbIHoldNoteTailPiece(); + + case ManiaSkinComponents.HoldNoteBody: + return new SbIHoldBodyPiece(); + + // case ManiaSkinComponents.HitTarget: + // return new SbIHitTarget(); + + case ManiaSkinComponents.KeyArea: + return new SbIKeyArea(); + + // case ManiaSkinComponents.HitExplosion: + // return new SbIHitExplosion(); + } + + break; + } + + return base.GetDrawableComponent(lookup); + } + + private float columnWidth; + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + int columnIndex = maniaLookup.ColumnIndex ?? 0; + var stage = beatmap.GetStageForColumnIndex(columnIndex); + bool isSpecialColumn = ezSkinConfig.IsSpecialColumn(stage.Columns, columnIndex); + columnWidth = (float)columnWidthBindable.Value * (isSpecialColumn ? (float)specialFactorBindable.Value : 1f); + + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + return SkinUtils.As(new Bindable(columnWidth)); + + case LegacyManiaSkinConfigurationLookups.HitPosition: + return SkinUtils.As(new Bindable(0)); + + case LegacyManiaSkinConfigurationLookups.BarLineHeight: + return SkinUtils.As(new Bindable(0)); + + case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing: + case LegacyManiaSkinConfigurationLookups.RightColumnSpacing: + return SkinUtils.As(new Bindable(0)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: + return SkinUtils.As(new Bindable(0)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingTop: + return SkinUtils.As(new Bindable(0)); + + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + + var colour = Colour4.White; + + return SkinUtils.As(new Bindable(colour)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbIColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIColumnBackground.cs new file mode 100644 index 0000000000..3757440032 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIColumnBackground.cs @@ -0,0 +1,126 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public partial class SbIColumnBackground : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Color4 brightColour; + private Color4 dimColour; + + private Box backgroundOverlay = null!; + // private Box background = null!; + // private Box? separator; + + [Resolved] + private Column column { get; set; } = null!; + + // private Bindable accentColour = null!; + private readonly Bindable overlayHeight = new Bindable(0f); + + public SbIColumnBackground() + { + RelativeSizeAxes = Axes.Both; + + Masking = true; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo, ISkinSource skin, StageDefinition stageDefinition) + { + InternalChildren = new Drawable[] + { + new Box + { + Name = "Background", + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 1, + }, + backgroundOverlay = new Box + { + Name = "Background Gradient Overlay", + RelativeSizeAxes = Axes.Both, + Height = 0.1f, + Blending = BlendingParameters.Additive, + Alpha = 0, + Colour = Color4.White, + }, + }; + + overlayHeight.BindValueChanged(height => backgroundOverlay.Height = height.NewValue, true); + // accentColour.BindValueChanged(colour => + // { + // var newColour = colour.NewValue.Darken(3); + // + // if (newColour.A != 0) + // { + // newColour = newColour.Opacity(1f); + // } + // + // background.Colour = newColour; + // // background.Colour = colour.NewValue.Darken(3); + // // brightColour = colour.NewValue.Opacity(0.6f); + // // dimColour = colour.NewValue.Opacity(0); + // }, true); + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.TopLeft; + } + else + { + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.BottomLeft; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == column.Action.Value) + { + var noteColour = column.AccentColour.Value; + brightColour = noteColour.Opacity(1f); + dimColour = noteColour.Opacity(0); + + backgroundOverlay.Colour = direction.Value == ScrollingDirection.Up + ? ColourInfo.GradientVertical(brightColour, dimColour) + : ColourInfo.GradientVertical(dimColour, brightColour); + + overlayHeight.Value = 0.1f; + + backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == column.Action.Value) + backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldBodyPiece.cs new file mode 100644 index 0000000000..426fb87d51 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldBodyPiece.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Mania.Skinning.EzStylePro; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public partial class SbIHoldBodyPiece : EzNoteBase, IHoldNoteBody + { + private readonly Bindable accentColour = new Bindable(); + private Bindable tailMaskHeight = new Bindable(); + + private Container? topContainer; + private Container? bodyContainer; + + private float tailHeight; + + public SbIHoldBodyPiece() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Masking = true; + CornerRadius = 0; + } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject? drawableObject) + { + if (MainContainer != null) + { + MainContainer.Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White, + }, + }; + } + + if (drawableObject != null) + { + var holdNote = (DrawableHoldNote)drawableObject; + + accentColour.BindTo(holdNote.AccentColour); + // hittingLayer.AccentColour.BindTo(holdNote.AccentColour); + // ((IBindable)hittingLayer.IsHitting).BindTo(holdNote.IsHitting); + } + + tailMaskHeight = EzSkinConfig.GetBindable(Ez2Setting.ManiaHoldTailMaskGradientHeight); + tailMaskHeight.BindValueChanged(_ => UpdateSize(), true); + } + + protected override void OnDrawableChanged() + { + topContainer?.Expire(); + bodyContainer?.Expire(); + + topContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 1, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + }; + bodyContainer = new Container + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + } + }; + + if (MainContainer != null) + { + MainContainer.Clear(); + MainContainer.Children = [bodyContainer, topContainer]; + } + + Schedule(UpdateSize); + } + + protected override void UpdateSize() + { + base.UpdateSize(); + tailHeight = (float)tailMaskHeight.Value; + + if (topContainer != null) + { + topContainer.Y = tailHeight > 0 + ? tailHeight + : 0; + } + } + + protected override void Update() + { + base.Update(); + + if (MainContainer?.Children.Count > 0 && bodyContainer != null) + { + float drawHeightMinusHalf = DrawHeight - tailHeight; + float middleHeight = Math.Max(drawHeightMinusHalf, tailHeight); + + bodyContainer.Height = tailHeight > 0 + ? middleHeight - tailHeight + 1 + : middleHeight + 1; + + bodyContainer.Scale = new Vector2(1, drawHeightMinusHalf); + } + } + + public void Recycle() + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHeadPiece.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHeadPiece.cs new file mode 100644 index 0000000000..ec01ee0109 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHeadPiece.cs @@ -0,0 +1,9 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + internal partial class SbIHoldNoteHeadPiece : SbINotePiece + { + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHittingLayer.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHittingLayer.cs new file mode 100644 index 0000000000..dbbbd95f15 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteHittingLayer.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public partial class SbIHoldNoteHittingLayer : CompositeDrawable + { + public readonly Bindable AccentColour = new Bindable(); + public readonly Bindable IsHitting = new Bindable(); + + public SbIHoldNoteHittingLayer() + { + RelativeSizeAxes = Axes.Both; + Blending = BlendingParameters.Additive; + Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AccentColour.BindValueChanged(colour => + { + Colour = colour.NewValue.Lighten(0.2f).Opacity(0.3f); + }, true); + + IsHitting.BindValueChanged(hitting => + { + const float animation_length = 80; + + ClearTransforms(); + + if (hitting.NewValue) + { + // wait for the next sync point + double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); + + using (BeginDelayedSequence(synchronisedOffset)) + { + this.FadeTo(1, animation_length, Easing.OutSine).Then() + .FadeTo(0.5f, animation_length, Easing.InSine) + .Loop(); + } + } + else + { + this.FadeOut(animation_length); + } + }, true); + } + + public void Recycle() + { + ClearTransforms(); + Alpha = 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteTailPiece.cs new file mode 100644 index 0000000000..25b559b873 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIHoldNoteTailPiece.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Skinning.EzStylePro; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public partial class SbIHoldNoteTailPiece : SbINotePiece + { + [Resolved] + private DrawableHitObject? drawableObject { get; set; } + + private Bindable enabledColor = null!; + private Bindable tailAlpha = null!; + + // private SbIHoldNoteHittingLayer hittingLayer { get; set; } + + public SbIHoldNoteTailPiece() + { + RelativeSizeAxes = Axes.X; + Height = 8; + Alpha = 0; + } + + [BackgroundDependencyLoader(true)] + private void load() + { + if (MainContainer != null) + { + MainContainer.Rotation = 180; + } + + if (drawableObject != null) + { + drawableObject.HitObjectApplied += hitObjectApplied; + } + + enabledColor = EzSkinConfig.GetBindable(Ez2Setting.ColorSettingsEnabled); + tailAlpha = EzSkinConfig.GetBindable(Ez2Setting.ManiaHoldTailAlpha); + tailAlpha.BindValueChanged(alpha => + { + Alpha = (float)alpha.NewValue; + }, true); + } + + private void hitObjectApplied(DrawableHitObject drawableHitObject) + { + // var holdNoteTail = (DrawableHoldNoteTail)drawableHitObject; + + // hittingLayer.Recycle(); + // + // hittingLayer.AccentColour.UnbindBindings(); + // hittingLayer.AccentColour.BindTo(holdNoteTail.HoldNote.AccentColour); + // + // hittingLayer.IsHitting.UnbindBindings(); + // ((IBindable)hittingLayer.IsHitting).BindTo(holdNoteTail.HoldNote.IsHitting); + } + + // protected override void Update() + // { + // base.Update(); + // Height = DrawWidth / DrawWidth; + // } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.HitObjectApplied -= hitObjectApplied; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbIJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIJudgementPiece.cs new file mode 100644 index 0000000000..d6de03cea4 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIJudgementPiece.cs @@ -0,0 +1,204 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public partial class SbIJudgementPiece : TextJudgementPiece, IAnimatableJudgement + { + private const float judgement_y_position = 140; + + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private IBindable direction = null!; + + public SbIJudgementPiece(HitResult result) + : base(result) + { + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => onDirectionChanged(), true); + + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + }); + } + } + + private void onDirectionChanged() => Y = direction.Value == ScrollingDirection.Up ? -judgement_y_position : judgement_y_position; + + protected override SpriteText CreateJudgementText() => + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Spacing = new Vector2(10, 0), + Font = OsuFont.Default.With(size: 28, weight: FontWeight.Regular), + }; + + public virtual void PlayAnimation() + { + switch (Result) + { + case HitResult.Miss: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + this.MoveToY(judgement_y_position); + + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 300, new Vector2(1.5f, 0.1f), 300, 300); + break; + + case HitResult.Meh: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + this.MoveToY(judgement_y_position); + + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 300, new Vector2(1.5f, 0.1f), 300, 300); + break; + + case HitResult.Ok: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + this.MoveToY(judgement_y_position); + + applyScaleAndFadeOutEffect(this, new Vector2(1.3f), 200, new Vector2(1.3f, 0.1f), 400, 400); + break; + + case HitResult.Good: + this.MoveToY(judgement_y_position); + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.3f), 1800, Easing.OutQuint); + + applyScaleAndFadeOutEffect(this, new Vector2(1.3f), 200, new Vector2(1.3f, 0.1f), 400, 400); + break; + + case HitResult.Great: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.5f), 1800, Easing.OutQuint); + + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 100, new Vector2(1.5f, 0.1f), 500, 500); + break; + + case HitResult.Perfect: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.5f), 1800, Easing.OutQuint); + + applyScaleAndFadeOutEffect(this, new Vector2(1.5f), 100, new Vector2(1.5f, 0.1f), 500, 500); + break; + } + + this.FadeOutFromOne(800); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => null; + + private void applyScaleAndFadeOutEffect(Drawable drawable, Vector2 scaleUp, double scaleUpDuration, Vector2 scaleDown, double scaleDownDuration, double fadeOutDuration) + { + drawable.ScaleTo(scaleUp, scaleUpDuration, Easing.OutQuint).Then() + .ScaleTo(scaleDown, scaleDownDuration, Easing.InQuint) + .FadeOut(fadeOutDuration, Easing.InQuint); + } + + private partial class RingExplosion : CompositeDrawable + { + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + this.FadeOutFromOne(1000, Easing.OutQuint); + } + + public partial class RingPiece : CircularContainer + { + public RingPiece(float thickness = 9) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderThickness = thickness; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbIKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIKeyArea.cs new file mode 100644 index 0000000000..93e4e583d0 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbIKeyArea.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Mania.Skinning.EzStylePro; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public partial class SbIKeyArea : SbINotePiece + { + private Container directionContainer = null!; + private Drawable background = null!; + + private Bindable accentColour = null!; + + [Resolved] + private Column column { get; set; } = null!; + + public SbIKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + // Height = Stage.HIT_TARGET_POSITION + SbINotePiece.CORNER_RADIUS * 2, + Children = new Drawable[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + CornerRadius = (float)CORNER_RADIUS.Value, + Child = background = new Box + { + Name = "Key gradient", + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + } + }; + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.2f); + }, + true); + + column.TopLevelContainer.Add(CreateProxy()); + } + + protected KeyCounter CreateCounter(InputTrigger trigger) => new ArgonKeyCounter(trigger); + + private void onDirectionChanged(ValueChangedEvent direction) + { + switch (direction.NewValue) + { + case ScrollingDirection.Up: + directionContainer.Scale = new Vector2(1, -1); + directionContainer.Anchor = Anchor.TopCentre; + directionContainer.Origin = Anchor.BottomCentre; + break; + + case ScrollingDirection.Down: + directionContainer.Scale = new Vector2(1, 1); + directionContainer.Anchor = Anchor.BottomCentre; + directionContainer.Origin = Anchor.BottomCentre; + break; + } + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action != column.Action.Value) return; + + const double lighting_fade_out_duration = 800; + background.FadeTo(0f, 50, Easing.OutQuint) + .Then() + .FadeOut(lighting_fade_out_duration, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/SbI/SbINotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/SbI/SbINotePiece.cs new file mode 100644 index 0000000000..4959735609 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/SbI/SbINotePiece.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets.Mania.Skinning.EzStylePro; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.SbI +{ + public partial class SbINotePiece : EzNoteBase + { + public Bindable NoteAccentRatio = new Bindable(1f); + public Bindable NoteHeight = new Bindable(8); + public Bindable CORNER_RADIUS = new Bindable(0); + + private readonly IBindable accentColour = new Bindable(); + + private Box colouredBox = null!; + + public SbINotePiece() + { + RelativeSizeAxes = Axes.X; + + // Masking = true; + } + + // protected override void Update() + // { + // base.Update(); + // + // // CreateIcon().Size = new Vector2(DrawWidth / 43 * 0.7f); + // } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject? drawableObject) + { + CornerRadius = (float)CORNER_RADIUS.Value; + + if (MainContainer != null) + { + MainContainer.Children = new[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + // BorderColour = Color4.White.Opacity(1f), + // BorderColour = ColourInfo.GradientVertical(Color4.White.Opacity(0), Colour4.Black), + } + }, + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + // Masking = true, + // CornerRadius = CORNER_RADIUS, + Children = new Drawable[] + { + colouredBox = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + }; + } + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + + NoteAccentRatio = EzSkinConfig.GetBindable(Ez2Setting.NoteHeightScaleToWidth); + } + + protected override void UpdateSize() + { + base.UpdateSize(); + + float fixedA = NoteAccentRatio.Value > 5 + ? (float)NoteAccentRatio.Value * 1.5f + : NoteAccentRatio.Value > 2 + ? (float)NoteAccentRatio.Value * 1.5f + : (float)NoteAccentRatio.Value; + + Height = (float)NoteHeight.Value * fixedA; + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = ColourInfo.GradientVertical( + accent.NewValue.Lighten(0.1f), + accent.NewValue + ); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index dec30043f5..dbd267a5a2 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -11,10 +11,13 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Game.Extensions; +using osu.Game.LAsEzExtensions.Audio; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.Objects.Drawables; @@ -57,7 +60,7 @@ namespace osu.Game.Rulesets.Mania.UI public readonly bool IsSpecial; public readonly Bindable AccentColour = new Bindable(Color4.Black); - + private Bindable hitModeBindable = null!; private IBindable touchOverlay = null!; private float leftColumnSpacing; @@ -83,7 +86,7 @@ namespace osu.Game.Rulesets.Mania.UI private ISkinSource skin { get; set; } = null!; [BackgroundDependencyLoader] - private void load(GameHost host, ManiaRulesetConfigManager? rulesetConfig) + private void load(GameHost host, ManiaRulesetConfigManager? rulesetConfig, Ez2ConfigManager ezConfig) { SkinnableDrawable keyArea; @@ -122,6 +125,7 @@ namespace osu.Game.Rulesets.Mania.UI RegisterPool(10, 50); RegisterPool(10, 50); + hitModeBindable = ezConfig.GetBindable(Ez2Setting.HitMode); if (rulesetConfig != null) touchOverlay = rulesetConfig.GetBindable(ManiaRulesetSetting.TouchOverlay); } @@ -143,6 +147,8 @@ namespace osu.Game.Rulesets.Mania.UI { base.LoadComplete(); NewResult += OnNewResult; + + hitModeBindable.BindValueChanged(mode => configurePools(mode.NewValue), true); } protected override void Dispose(bool isDisposing) @@ -189,6 +195,9 @@ namespace osu.Game.Rulesets.Mania.UI if (e.Action != Action.Value) return false; + // 记录延迟追踪按键输入 + InputAudioLatencyTracker.Instance?.RecordColumnPress(Index); + sampleTriggerSource.Play(); return true; } @@ -204,6 +213,49 @@ namespace osu.Game.Rulesets.Mania.UI return DrawRectangle.Inflate(spacingInflation).Contains(ToLocalSpace(screenSpacePos)); } + private void configurePools(EzMUGHitMode hitMode) + { + switch (hitMode) + { + case EzMUGHitMode.EZ2AC: + // RegisterPool(10, 50); + RegisterPool(10, 50); + // RegisterPool(10, 50); + RegisterPool(10, 50); + // RegisterPool(10, 50); + break; + + case EzMUGHitMode.Malody: + // RegisterPool(10, 50); + RegisterPool(10, 50); + RegisterPool(10, 50); + break; + + // TODO: 暂时先用 EZ2AC 的物件池,以后根据使用反馈单独实现 + case EzMUGHitMode.IIDX_HD: + case EzMUGHitMode.LR2_HD: + case EzMUGHitMode.Raja_NM: + // RegisterPool(10, 50); + RegisterPool(10, 50); + // RegisterPool(10, 50); + RegisterPool(10, 50); + // RegisterPool(10, 50); + break; + + case EzMUGHitMode.O2Jam: + // RegisterPool(10, 50); + // RegisterPool(10, 50); + // RegisterPool(10, 50); + // RegisterPool(10, 50); + + RegisterPool(10, 50); + RegisterPool(10, 50); + RegisterPool(10, 50); + RegisterPool(10, 50); + break; + } + } + #region Touch Input [Resolved] diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index 03e5791519..77f768f074 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Skinning; @@ -66,13 +67,29 @@ namespace osu.Game.Rulesets.Mania.UI [Resolved] private ISkinSource skin { get; set; } = null!; + [Resolved] + private SkinManager skinManager { get; set; } = null!; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + private readonly Bindable mobileLayout = new Bindable(); + private readonly Bindable columnWidthBindable = new Bindable(); + private readonly Bindable specialFactorBindable = new Bindable(); + private readonly Bindable ezColumnWidthStyle = new Bindable(); [BackgroundDependencyLoader] private void load(ManiaRulesetConfigManager? rulesetConfig) { rulesetConfig?.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout); + ezSkinConfig.BindWith(Ez2Setting.ColumnWidthStyle, ezColumnWidthStyle); + ezSkinConfig.BindWith(Ez2Setting.ColumnWidth, columnWidthBindable); + ezSkinConfig.BindWith(Ez2Setting.SpecialFactor, specialFactorBindable); + ezColumnWidthStyle.BindValueChanged(v => updateColumnSize()); + columnWidthBindable.BindValueChanged(v => updateColumnSize()); + specialFactorBindable.BindValueChanged(v => updateColumnSize()); + mobileLayout.BindValueChanged(_ => invalidateLayout()); skin.SourceChanged += invalidateLayout; } @@ -138,7 +155,33 @@ namespace osu.Game.Rulesets.Mania.UI new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) ?.Value; - bool isSpecialColumn = stageDefinition.IsSpecialColumn(i); + if (width == 0) + { + columns[i].Width = 0; + columns[i].Margin = new MarginPadding { Left = 0, Right = 0 }; + continue; + } + + bool isSpecialColumn = + ezSkinConfig.IsSpecialColumn(stageDefinition.Columns, i); + float ezWidth = (float)columnWidthBindable.Value * (isSpecialColumn ? (float)specialFactorBindable.Value : 1); + + switch (ezColumnWidthStyle.Value) + { + case ColumnWidthStyle.EzStyleProOnly: + var skinInfo = skinManager.CurrentSkinInfo.Value; + if (skinInfo.Value.Name.Contains("Ez Style Pro")) + width = ezWidth; + break; + + case ColumnWidthStyle.GlobalWidth: + width = ezWidth; + break; + + case ColumnWidthStyle.GlobalTotalWidth: + width = ezWidth * 10 / stageDefinition.Columns; + break; + } // only used by default skin (legacy skins get defaults set in LegacyManiaSkinConfiguration) width ??= isSpecialColumn ? Column.SPECIAL_COLUMN_WIDTH : Column.COLUMN_WIDTH; diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitPositionPaddedContainer.cs b/osu.Game.Rulesets.Mania/UI/Components/HitPositionPaddedContainer.cs index 72daf4b21d..5ab1a4ef15 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/HitPositionPaddedContainer.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/HitPositionPaddedContainer.cs @@ -6,8 +6,10 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI.Components @@ -19,22 +21,39 @@ namespace osu.Game.Rulesets.Mania.UI.Components [Resolved] private ISkinSource skin { get; set; } = null!; + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + private Bindable hitPositonBindable = new Bindable(); + private Bindable globalHitPosition = new Bindable(); + [BackgroundDependencyLoader] private void load(IScrollingInfo scrollingInfo) { Direction.BindTo(scrollingInfo.Direction); Direction.BindValueChanged(_ => UpdateHitPosition(), true); + globalHitPosition = ezSkinConfig.GetBindable(Ez2Setting.GlobalHitPosition); + hitPositonBindable = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); skin.SourceChanged += onSkinChanged; } + protected override void LoadComplete() + { + base.LoadComplete(); + globalHitPosition.BindValueChanged(_ => UpdateHitPosition(), true); + hitPositonBindable.BindValueChanged(_ => UpdateHitPosition(), true); + } + private void onSkinChanged() => UpdateHitPosition(); protected virtual void UpdateHitPosition() { - float hitPosition = skin.GetConfig( - new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value - ?? Stage.HIT_TARGET_POSITION; + float hitPosition = globalHitPosition.Value + ? (float)hitPositonBindable.Value + : skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value + ?? (float)hitPositonBindable.Value; Padding = Direction.Value == ScrollingDirection.Up ? new MarginPadding { Top = hitPosition } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index d9a03d1c30..e74745799f 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -1,24 +1,31 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Input; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Input.Handlers; +using osu.Game.LAsEzExtensions; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Replays; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; +using osu.Game.Rulesets.Mania.LAsEZMania; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mods; @@ -73,6 +80,19 @@ namespace osu.Game.Rulesets.Mania.UI [Resolved] private GameHost gameHost { get; set; } = null!; + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; + + private Bindable hitPositonBindable = new Bindable(); + private Bindable globalHitPosition = new Bindable(); + private Bindable barLinesBindable = new Bindable(); + private Bindable hitMode = new Bindable(); + + //自定义判定系统 + private Bindable scrollingStyle = new Bindable(); + private readonly BindableDouble configBaseMs = new BindableDouble(); + private readonly BindableDouble configTimePerSpeed = new BindableDouble(); + public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { @@ -105,27 +125,80 @@ namespace osu.Game.Rulesets.Mania.UI p.EffectPoint = new EffectControlPoint(); } - BarLines.ForEach(Playfield.Add); - Config.BindWith(ManiaRulesetSetting.ScrollDirection, configDirection); configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); + Config.BindWith(ManiaRulesetSetting.ScrollBaseSpeed, configBaseMs); + Config.BindWith(ManiaRulesetSetting.ScrollTimePerSpeed, configTimePerSpeed); Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed); configScrollSpeed.BindValueChanged(speed => { if (!AllowScrollSpeedAdjustment) return; - TargetTimeRange = ComputeScrollTime(speed.NewValue); + TargetTimeRange = ComputeScrollTime(speed.NewValue, configBaseMs.Value, configTimePerSpeed.Value); }); - TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value); + scrollingStyle = Config.GetBindable(ManiaRulesetSetting.ScrollStyle); + scrollingStyle.BindValueChanged(_ => updateTimeRange()); + + TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value, configBaseMs.Value, configTimePerSpeed.Value); Config.BindWith(ManiaRulesetSetting.MobileLayout, mobileLayout); mobileLayout.BindValueChanged(_ => updateMobileLayout(), true); Config.BindWith(ManiaRulesetSetting.TouchOverlay, touchOverlay); touchOverlay.BindValueChanged(_ => updateMobileLayout(), true); + + hitPositonBindable = ezConfig.GetBindable(Ez2Setting.HitPosition); + hitPositonBindable.BindValueChanged(_ => skinChanged(), true); + globalHitPosition = ezConfig.GetBindable(Ez2Setting.GlobalHitPosition); + globalHitPosition.BindValueChanged(_ => skinChanged(), true); + barLinesBindable = ezConfig.GetBindable(Ez2Setting.ManiaBarLinesBool); + hitMode = ezConfig.GetBindable(Ez2Setting.HitMode); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + hitMode.BindValueChanged(h => + { + if (h.NewValue == EzMUGHitMode.O2Jam) + { + O2HitModeExtension.SetOriginalBPM(Beatmap.BeatmapInfo.BPM); + O2HitModeExtension.SetControlPoints(Beatmap.ControlPointInfo); + O2HitModeExtension.PillActivated = true; + } + }, true); + barLinesBindable.BindValueChanged(b => + { + if (b.NewValue) + { + BarLines.ForEach(Playfield.Add); + } + }, true); + // 启动独立的异步任务,预加载EzPro皮肤中会用到的贴图 + Schedule(() => + { + _ = Task.Run(async () => + { + try + { + var factory = Dependencies.Get(); + + if (factory != null) + { + await factory.PreloadGameTextures().ConfigureAwait(false); + } + } + catch (Exception ex) + { + Logger.Log($"[DrawableManiaRuleset] Preload textures failed: {ex.Message}", + LoggingTarget.Runtime, LogLevel.Error); + } + }); + }); } private ManiaTouchInputArea? touchInputArea; @@ -164,9 +237,14 @@ namespace osu.Game.Rulesets.Mania.UI private void skinChanged() { - hitPosition = currentSkin.GetConfig( - new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value - ?? Stage.HIT_TARGET_POSITION; + if (globalHitPosition.Value) + hitPosition = (float)hitPositonBindable.Value; + else + { + hitPosition = currentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value + ?? (float)hitPositonBindable.Value; + } pendingSkinChange = null; } @@ -174,10 +252,31 @@ namespace osu.Game.Rulesets.Mania.UI private void updateTimeRange() { const float length_to_default_hit_position = 768 - LegacyManiaSkinConfiguration.DEFAULT_HIT_POSITION; + + skinChanged(); float lengthToHitPosition = 768 - hitPosition; // This scaling factor preserves the scroll speed as the scroll length varies from changes to the hit position. - float scale = lengthToHitPosition / length_to_default_hit_position; + float scale = 1.0f; + + switch (scrollingStyle.Value) + { + case EzManiaScrollingStyle.ScrollSpeedStyle: + case EzManiaScrollingStyle.ScrollTimeStyle: + // Preserve the scroll speed as the scroll length varies from changes to the hit position. + scale = lengthToHitPosition / length_to_default_hit_position; + break; + + case EzManiaScrollingStyle.ScrollTimeForRealJudgement: + // 直接使用设置的速度作为时间范围,忽略 hit position 的影响 + scale = 1.0f; + break; + + case EzManiaScrollingStyle.ScrollTimeStyleFixed: + // Ensure the travel time from the top of the screen to the hit position remains constant. + scale = lengthToHitPosition / 768; + break; + } // we're intentionally using the game host's update clock here to decouple the time range tween from the gameplay clock (which can be arbitrarily paused, or even rewinding) currentTimeRange = Interpolation.DampContinuously(currentTimeRange, TargetTimeRange, 50, gameHost.UpdateThread.Clock.ElapsedFrameTime); @@ -188,8 +287,13 @@ namespace osu.Game.Rulesets.Mania.UI /// Computes a scroll time (in milliseconds) from a scroll speed in the range of 1-40. /// /// The scroll speed. + /// + /// /// The scroll time. - public static double ComputeScrollTime(double scrollSpeed) => MAX_TIME_RANGE / scrollSpeed; + public static double ComputeScrollTime(double scrollSpeed, double baseSpeed, double timePerSpeed) + { + return baseSpeed - (scrollSpeed - 200) * timePerSpeed; + } public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(this); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index faa9fc318c..14520aa0b3 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -5,9 +5,16 @@ using System; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.Backgrounds; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; @@ -20,6 +27,8 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Backgrounds; +using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK; @@ -60,6 +69,30 @@ namespace osu.Game.Rulesets.Mania.UI private ISkinSource currentSkin = null!; + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + [Resolved] + private OsuConfigManager osuConfig { get; set; } = null!; + + [Resolved] + private Player? player { get; set; } + + [Resolved(canBeNull: true)] + private IBindable? beatmap { get; set; } + + private Bindable uiScale = null!; + private Bindable osuConfigDim = null!; + private Bindable editorShowStoryboard = null!; + private Bindable columnDim = null!; + private Bindable columnBlur = null!; + private readonly Bindable showBlurStoryboard = new Bindable(); + private IBindable workingBeatmap { get; set; } = new Bindable(); + + private readonly Box dimBox; + private readonly Container backgroundContainer; + private readonly BackgroundScreenBeatmap.DimmableBackground maniaMaskedDimmable; + public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction columnStartAction) { this.firstColumnIndex = firstColumnIndex; @@ -77,6 +110,25 @@ namespace osu.Game.Rulesets.Mania.UI InternalChildren = new Drawable[] { + backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + Child = maniaMaskedDimmable = new BackgroundScreenBeatmap.DimmableBackground + { + RelativeSizeAxes = Axes.None, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + } + }, + dimBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black, + }, new Container { Anchor = Anchor.TopCentre, @@ -136,6 +188,7 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < definition.Columns; i++) { bool isSpecial = definition.IsSpecialColumn(i); + // bool isSpecial = ezSkinConfig.IsSpecialColumn(definition.Columns, i); var action = columnStartAction; columnStartAction++; @@ -169,8 +222,53 @@ namespace osu.Game.Rulesets.Mania.UI skin.SourceChanged += onSkinChanged; onSkinChanged(); + + ezSkinConfig.KeyMode = Definition.Columns; //确保 KeyMode 已设置正确 + ezSkinConfig.ColumnTotalWidth = DrawWidth; //确保 ColumnTotalWidth 已设置正确 + + uiScale = osuConfig.GetBindable(OsuSetting.UIScale); + osuConfigDim = osuConfig.GetBindable(OsuSetting.DimLevel); + editorShowStoryboard = osuConfig.GetBindable(OsuSetting.EditorShowStoryboard); + editorShowStoryboard.BindValueChanged(_ => loadBackgroundAsync()); + + columnDim = ezSkinConfig.GetBindable(Ez2Setting.ColumnDim); + columnDim.BindValueChanged(v => + { + dimBox.Alpha = (float)Math.Max(v.NewValue, osuConfigDim.Value / 2); + }, true); + + bindWorkingBeatmapSource(); + loadBackgroundAsync(); + columnBlur = ezSkinConfig.GetBindable(Ez2Setting.ColumnBlur); + columnBlur.BindValueChanged(v => maniaMaskedDimmable.BlurAmount.Value = (float)v.NewValue * 50, true); } + private void bindWorkingBeatmapSource() + { + // Prefer the beatmap provided by Player (gameplay). In editor, Player will be missing, + // but a bindable beatmap is still available via dependency injection. + workingBeatmap.ValueChanged -= onWorkingBeatmapChanged; + workingBeatmap.UnbindAll(); + + // Rebind to an appropriate upstream source. + // GetBoundCopy() is used because we may only have access to IBindable (editor), not Bindable. + IBindable? newWorkingBeatmap = null; + + if (player?.Beatmap.Value != null) + newWorkingBeatmap = player.Beatmap.GetBoundCopy(); + else if (beatmap != null) + newWorkingBeatmap = beatmap.GetBoundCopy(); + + workingBeatmap = newWorkingBeatmap ?? new Bindable(); + + // Editor may swap DummyWorkingBeatmap -> real beatmap asynchronously. + // Refresh the background as soon as the bindable updates. + workingBeatmap.ValueChanged += onWorkingBeatmapChanged; + } + + private void onWorkingBeatmapChanged(ValueChangedEvent _) + => loadBackgroundAsync(); + private void onSkinChanged() { float paddingTop = currentSkin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.StagePaddingTop))?.Value ?? 0; @@ -188,6 +286,9 @@ namespace osu.Game.Rulesets.Mania.UI // must happen before children are disposed in base call to prevent illegal accesses to the judgement pool. NewResult -= OnNewResult; + workingBeatmap.ValueChanged -= onWorkingBeatmapChanged; + workingBeatmap.UnbindAll(); + base.Dispose(isDisposing); if (currentSkin.IsNotNull()) @@ -200,6 +301,66 @@ namespace osu.Game.Rulesets.Mania.UI NewResult += OnNewResult; } + private void updateDimmableAlphaOpen(bool _ = true) + { + maniaMaskedDimmable.Alpha = _ ? 1 : 0; + } + + private void loadBackgroundAsync() + { + if (player?.DimmableStoryboard != null) + { + showBlurStoryboard.Value = player.DimmableStoryboard.ContentDisplayed && + !player.DimmableStoryboard.HasStoryboardEnded.Value; + + if (showBlurStoryboard.Value) + { + updateDimmableAlphaOpen(false); + return; + } + } + + if (workingBeatmap.Value != null) + { + updateDimmableAlphaOpen(); + var maskedBackground = new BeatmapBackground(workingBeatmap.Value); + maskedBackground.FadeInFromZero(500, Easing.OutQuint); + maniaMaskedDimmable.Background = maskedBackground; + + // Gameplay: bind to player state. Editor: use editor settings + beatmap storyboard metadata. + if (player != null) + { + maniaMaskedDimmable.StoryboardReplacesBackground.BindTo(player.StoryboardReplacesBackground); + maniaMaskedDimmable.IgnoreUserSettings.BindTo(new Bindable(true)); + maniaMaskedDimmable.IsBreakTime.BindTo(player.IsBreakTime); + } + else + { + maniaMaskedDimmable.StoryboardReplacesBackground.UnbindAll(); + maniaMaskedDimmable.IgnoreUserSettings.UnbindAll(); + maniaMaskedDimmable.IsBreakTime.UnbindAll(); + + // We don't have gameplay break tracking in editor, so assume not in break. + ((Bindable)maniaMaskedDimmable.IsBreakTime).Value = false; + + // Keep behaviour consistent with gameplay mania background screen: + // ignore user storyboard setting and drive replacement ourselves. + maniaMaskedDimmable.IgnoreUserSettings.Value = true; + + // Match editor behaviour: only consider storyboard replacement when editor storyboard display is enabled. + // (EditorBackgroundScreen also uses EditorShowStoryboard). + maniaMaskedDimmable.StoryboardReplacesBackground.Value = editorShowStoryboard.Value + && workingBeatmap.Value.Storyboard.ReplacesBackground + && workingBeatmap.Value.Storyboard.HasDrawable; + } + } + else + { + updateDimmableAlphaOpen(false); + Logger.Log("Working beatmap is null, cannot load background.", LoggingTarget.Runtime, LogLevel.Error); + } + } + public override void Add(HitObject hitObject) => Columns[((ManiaHitObject)hitObject).Column - firstColumnIndex].Add(hitObject); public override bool Remove(HitObject hitObject) => Columns[((ManiaHitObject)hitObject).Column - firstColumnIndex].Remove(hitObject); @@ -224,6 +385,8 @@ namespace osu.Game.Rulesets.Mania.UI // Due to masking differences, it is not possible to get the width of the columns container automatically // While masking on effectively only the Y-axis, so we need to set the width of the bar line container manually barLineContainer.Width = columnFlow.Width; + + if (player != null) maniaMaskedDimmable.Size = player.DrawSize / 0.95f / uiScale.Value; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEzSkinEditorScene.cs b/osu.Game.Tests/Visual/Editing/TestSceneEzSkinEditorScene.cs new file mode 100644 index 0000000000..50e72cbb0e --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEzSkinEditorScene.cs @@ -0,0 +1,60 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.LAsEzExtensions.Screens; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public partial class TestSceneEzSkinEditorScene : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Cached] + private readonly DialogOverlay dialogOverlay = new DialogOverlay(); + + private EzSkinEditorScreen ezSkinEditorScreen = null!; + + [BackgroundDependencyLoader] + private void load() + { + // Instantiate the screen for testing purposes + ezSkinEditorScreen = new EzSkinEditorScreen(); + Child = ezSkinEditorScreen; + Add(dialogOverlay); + } + + [Test] + public void TestLoadScreen() + { + // Test that the screen loads without errors + AddStep("load screen", () => { }); + AddAssert("screen is not null", () => ezSkinEditorScreen != null); + AddAssert("screen is EzSkinEditorScreen", () => ezSkinEditorScreen is EzSkinEditorScreen); + } + + // Removed TestPushScreen as Stack is not accessible in this context + // [Test] + // public void TestPushScreen() + // { + // AddStep("push screen", () => Stack.Push(ezSkinEditorScreen)); + // AddUntilStep("screen is current", () => Stack.CurrentScreen == ezSkinEditorScreen); + // } + + [Test] + public void TestPopulateSettings() + { + AddStep("populate settings", () => ezSkinEditorScreen.PopulateSettings()); + } + + [Test] + public void TestPresentGameplay() + { + AddStep("present gameplay", () => ezSkinEditorScreen.PresentGameplay()); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index e46d8f74a5..94b4567c80 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -472,7 +472,7 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep($"config value is {configValue}", () => getConfigManager().Get(ManiaRulesetSetting.ScrollSpeed), () => Is.EqualTo(configValue)); AddUntilStep($"gameplay value is {gameplayValue}", () => this.ChildrenOfType().Single().TargetTimeRange, - () => Is.EqualTo(DrawableManiaRuleset.ComputeScrollTime(gameplayValue))); + () => Is.EqualTo(DrawableManiaRuleset.ComputeScrollTime(gameplayValue, 200, 5))); } ManiaRulesetConfigManager getConfigManager() => ((ManiaRulesetConfigManager)Game.Dependencies.Get().GetConfigFor(new ManiaRuleset())!); diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 4ea26b46f8..b424a2ff6a 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -294,8 +294,12 @@ namespace osu.Game.Beatmaps // Convert IBeatmap converted = converter.Convert(token); + // 应用转换后的Mod。如果Mod实现了IHasApplyOrder接口,则尊重它们的顺序(值较小的优先)。 + // 没有该接口的Mod默认为顺序0,以保持现有行为。 // Apply conversion mods to the result - foreach (var mod in mods.OfType()) + foreach (var mod in mods.OfType() + .OrderBy(m => (m as IHasApplyOrder)?.ApplyOrder) + ) { token.ThrowIfCancellationRequested(); mod.ApplyToBeatmap(converted); diff --git a/osu.Game/Configuration/BackgroundSource.cs b/osu.Game/Configuration/BackgroundSource.cs index 2d9ed6df2c..a8f6b142d4 100644 --- a/osu.Game/Configuration/BackgroundSource.cs +++ b/osu.Game/Configuration/BackgroundSource.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Localisation; @@ -16,5 +17,11 @@ namespace osu.Game.Configuration [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.BeatmapWithStoryboard))] BeatmapWithStoryboard, + + [Description("'EzResource/Webm/*' from osu data folder ")] + WebmSource, + + [Description("'EzResource/BG/*' from local folders")] + Slides, } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index cdccf7eb61..8221022052 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -45,7 +45,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.BeatmapLeaderboardSortMode, LeaderboardSortMode.Score); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); - SetDefault(OsuSetting.ShowConvertedBeatmaps, true); + SetDefault(OsuSetting.ShowConvertedBeatmaps, false); SetDefault(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); SetDefault(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); @@ -54,7 +54,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); SetDefault(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Sequential); - SetDefault(OsuSetting.ModSelectTextSearchStartsActive, true); + SetDefault(OsuSetting.ModSelectTextSearchStartsActive, false); SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f, 0.01f); @@ -65,7 +65,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full); - SetDefault(OsuSetting.SongSelectBackgroundBlur, false); + SetDefault(OsuSetting.SongSelectBackgroundBlur, true); // Online settings SetDefault(OsuSetting.Username, string.Empty); @@ -123,7 +123,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.TouchDisableGameplayTaps, false); // Graphics - SetDefault(OsuSetting.ShowFpsDisplay, false); + SetDefault(OsuSetting.ShowFpsDisplay, true); SetDefault(OsuSetting.ShowStoryboard, true); SetDefault(OsuSetting.BeatmapSkins, true); @@ -138,18 +138,18 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Prefer24HourTime, !CultureInfoHelper.SystemCulture.DateTimeFormat.ShortTimePattern.Contains(@"tt")); // Gameplay - SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1, 0.01f); + SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.8f, 0, 1, 0.01f); SetDefault(OsuSetting.DimLevel, 0.7, 0, 1, 0.01); SetDefault(OsuSetting.BlurLevel, 0, 0, 1, 0.01); SetDefault(OsuSetting.LightenDuringBreaks, true); SetDefault(OsuSetting.HitLighting, true); - SetDefault(OsuSetting.StarFountains, true); + SetDefault(OsuSetting.StarFountains, false); SetDefault(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always); SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true); - SetDefault(OsuSetting.KeyOverlay, false); + SetDefault(OsuSetting.KeyOverlay, true); SetDefault(OsuSetting.ReplaySettingsOverlay, true); SetDefault(OsuSetting.ReplayPlaybackControlsExpanded, true); SetDefault(OsuSetting.GameplayLeaderboard, true); @@ -223,8 +223,8 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.EditorContractSidebars, false); - SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, false); - SetDefault(OsuSetting.AlwaysRequireHoldingForPause, false); + SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, true); + SetDefault(OsuSetting.AlwaysRequireHoldingForPause, true); SetDefault(OsuSetting.EditorShowStoryboard, true); SetDefault(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, true); diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index fa54ed538a..068af75750 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -206,10 +206,10 @@ namespace osu.Game.Database if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) Filename += realm_extension; -#if DEBUG +// #if DEBUG if (!DebugUtils.IsNUnitRunning) applyFilenameSchemaSuffix(ref Filename); -#endif +// #endif // `prepareFirstRealmAccess()` triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date. using (var realm = prepareFirstRealmAccess()) diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs index 685f03ae56..63e0dfa266 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs @@ -15,7 +15,7 @@ namespace osu.Game.Graphics.Backgrounds private readonly string fallbackTextureName; - public BeatmapBackground(WorkingBeatmap beatmap, string fallbackTextureName = @"Backgrounds/bg1") + public BeatmapBackground(WorkingBeatmap beatmap, string fallbackTextureName = @"Menu/Ez-background-3") { Beatmap = beatmap; this.fallbackTextureName = fallbackTextureName; diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs index 784c8e4b44..90f429de57 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs @@ -31,7 +31,7 @@ namespace osu.Game.Graphics.Backgrounds [Resolved] private IBindable> mods { get; set; } = null!; - public BeatmapBackgroundWithStoryboard(WorkingBeatmap beatmap, string fallbackTextureName = "Backgrounds/bg1") + public BeatmapBackgroundWithStoryboard(WorkingBeatmap beatmap, string fallbackTextureName = "Menu/Ez-background-3") : base(beatmap, fallbackTextureName) { storyboardClock = new InterpolatingFramedClock(); diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs index b4be330f9c..fb2081718c 100644 --- a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -98,7 +98,7 @@ namespace osu.Game.Graphics.Backgrounds public partial class SeasonalBackground : Background { private readonly string url; - private const string fallback_texture_name = @"Backgrounds/bg1"; + private const string fallback_texture_name = @"Menu/Ez-background-3"; public SeasonalBackground(string url) { diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 22dabc55ce..9223defe36 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -12,6 +12,8 @@ using osu.Framework.Layout; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Rulesets; using osu.Game.Screens; using osu.Game.Screens.Backgrounds; using osuTK; @@ -126,12 +128,20 @@ namespace osu.Game.Graphics.Containers } } + private Bindable scalingGameMode = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + [BackgroundDependencyLoader] - private void load(GameHost host, OsuConfigManager config, ISafeArea safeArea) + private void load(GameHost host, OsuConfigManager config, ISafeArea safeArea, Ez2ConfigManager ezConfig) { scalingMode = config.GetBindable(OsuSetting.Scaling); scalingMode.ValueChanged += _ => Scheduler.AddOnce(updateSize); + scalingGameMode = ezConfig.GetBindable(Ez2Setting.ScalingGameMode); + scalingGameMode.ValueChanged += _ => Scheduler.AddOnce(updateSize); + sizeX = config.GetBindable(OsuSetting.ScalingSizeX); sizeX.ValueChanged += _ => Scheduler.AddOnce(updateSize); @@ -204,12 +214,30 @@ namespace osu.Game.Graphics.Containers } else if (targetMode == null || scalingMode.Value == targetMode) { - sizableContainer.RelativePositionAxes = Axes.Both; + if (targetMode == ScalingMode.Gameplay) + { + if ((scalingGameMode.Value == ScalingGameMode.Standard && ruleset.Value.OnlineID == 0) || + (scalingGameMode.Value == ScalingGameMode.Taiko && ruleset.Value.OnlineID == 1) || + (scalingGameMode.Value == ScalingGameMode.Catch && ruleset.Value.OnlineID == 2) || + (scalingGameMode.Value == ScalingGameMode.Mania && ruleset.Value.OnlineID == 3)) + { + sizableContainer.RelativePositionAxes = Axes.Both; - Vector2 scale = new Vector2(sizeX.Value, sizeY.Value); - Vector2 pos = new Vector2(posX.Value, posY.Value) * (Vector2.One - scale); + Vector2 scale = new Vector2(sizeX.Value, sizeY.Value); + Vector2 pos = new Vector2(posX.Value, posY.Value) * (Vector2.One - scale); - targetRect = new RectangleF(pos, scale); + targetRect = new RectangleF(pos, scale); + } + } + else + { + sizableContainer.RelativePositionAxes = Axes.Both; + + Vector2 scale = new Vector2(sizeX.Value, sizeY.Value); + Vector2 pos = new Vector2(posX.Value, posY.Value) * (Vector2.One - scale); + + targetRect = new RectangleF(pos, scale); + } } bool requiresMasking = targetRect.Size != Vector2.One diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 5dd6dc0c53..4d24237935 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -139,6 +139,9 @@ namespace osu.Game.Graphics case HitResult.Great: return Blue; + case HitResult.Pool: + return PurpleLight; + default: return BlueLight; } @@ -193,6 +196,12 @@ namespace osu.Game.Graphics { switch (modType) { + case ModType.LA_Mod: + return BlueLight; + + case ModType.YuLiangSSS_Mod: + return Purple; + case ModType.Automation: return Blue1; diff --git a/osu.Game/Graphics/UserInterface/StarCounter.cs b/osu.Game/Graphics/UserInterface/StarCounter.cs index 4e9e34d840..ac420fbcd8 100644 --- a/osu.Game/Graphics/UserInterface/StarCounter.cs +++ b/osu.Game/Graphics/UserInterface/StarCounter.cs @@ -35,6 +35,19 @@ namespace osu.Game.Graphics.UserInterface set => stars.Direction = value; } + /// + /// 提供属性修改星星图标 + /// + public IconUsage Icon + { + get => (stars.Children.FirstOrDefault() as DefaultStar)?.Icon.Icon ?? FontAwesome.Solid.Star; + set + { + foreach (var star in stars.Children.OfType()) + star.Icon.Icon = value; + } + } + private float current; /// diff --git a/osu.Game/ISkinEditorVirtualProvider.cs b/osu.Game/ISkinEditorVirtualProvider.cs new file mode 100644 index 0000000000..609e11d79f --- /dev/null +++ b/osu.Game/ISkinEditorVirtualProvider.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Skinning; + +namespace osu.Game +{ + /// + /// Interface for creating virtual playfield for skin editor. + /// + public interface ISkinEditorVirtualProvider + { + /// + /// Creates a virtual playfield drawable for skin editing, using the provided skin and beatmap. + /// + /// The skin to use for the playfield. + /// The beatmap for the playfield. + /// The drawable playfield. + Drawable CreateVirtualPlayfield(ISkin skin, IBeatmap beatmap); + + /// + /// Creates a drawable for displaying the current skin's note. + /// + /// The skin to use. + /// The drawable note display. + Drawable CreateCurrentSkinNoteDisplay(ISkin skin); + + /// + /// Creates a drawable for displaying the edited note. + /// + /// The skin to use. + /// The drawable note display. + Drawable CreateEditedNoteDisplay(ISkin skin); + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/BaseEzScoreGraph.cs b/osu.Game/LAsEzExtensions/Analysis/BaseEzScoreGraph.cs new file mode 100644 index 0000000000..f9c0be6e87 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/BaseEzScoreGraph.cs @@ -0,0 +1,333 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + /// + /// 基类分数图表,用于分析和可视化得分数据。 + /// + public abstract partial class BaseEzScoreGraph : CompositeDrawable + { + protected readonly ScoreInfo Score; + protected readonly IBeatmap Beatmap; + + protected HitWindows HitWindows { get; set; } + + protected static double HP; + protected static double OD; + + protected float LeftMarginConst { get; set; } = 158; + protected float RightMarginConst { get; set; } = 7; + + private const int current_offset = 0; + private const int time_bins = 50; + + private double binSize; + private double maxTime; + private double minTime; + private double timeRange; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected double V1Accuracy { get; set; } + protected long V1Score { get; set; } + protected Dictionary V1Counts { get; set; } = new Dictionary(); + + protected double V2Accuracy { get; set; } + protected long V2Score { get; set; } + protected Dictionary V2Counts { get; set; } = new Dictionary(); + + private readonly IReadOnlyList originalHitEvents; + + protected IReadOnlyList HitEvents => FilterHitEvents(); + + /// + /// 继承类应 HitWindows.IsHitResultAllowed 等方式过滤出有效的 HitEvent。 + /// + /// 应当返回与当前规则集HitWindows匹配的 HitEvent + protected virtual IReadOnlyList FilterHitEvents() + { + return originalHitEvents.Where(e => e.Result.IsBasic()).ToList(); + } + + protected BaseEzScoreGraph(ScoreInfo score, IBeatmap beatmap, HitWindows hitWindows) + { + Score = score; + HitWindows = hitWindows; + originalHitEvents = score.HitEvents; + Beatmap = beatmap; + + HP = beatmap.Difficulty.DrainRate; + OD = beatmap.Difficulty.OverallDifficulty; + } + + [BackgroundDependencyLoader] + private void load() + { + if (HitEvents.Count == 0) + return; + + binSize = Math.Ceiling(HitEvents.Max(e => e.HitObject.StartTime) / time_bins); + binSize = Math.Max(1, binSize); + + maxTime = HitEvents.Count > 0 ? HitEvents.Max(e => e.HitObject.StartTime) : 1; + minTime = HitEvents.Count > 0 ? HitEvents.Min(e => e.HitObject.StartTime) : 0; + timeRange = maxTime - minTime; + + Scheduler.AddOnce(UpdateDisplay); + } + + /// + /// Calculate V1 (Classic) accuracy. Subclasses should override CalculateV1ScoresManually instead of this method. + /// Sets V1Accuracy, V1Score, and V1Counts properties instead of returning values. + /// + protected virtual void CalculateV1Accuracy() + { + var v1ScoreProcessor = Score.Ruleset.CreateInstance().CreateScoreProcessor(); + v1ScoreProcessor.IsLegacyScore = true; + v1ScoreProcessor.Mods.Value = Score.Mods; + v1ScoreProcessor.ApplyBeatmap(Beatmap); + + var v1Counts = new Dictionary(); + + foreach (var hitEvent in HitEvents) + { + var recalculated = RecalculateV1Result(hitEvent); + v1Counts[recalculated] = v1Counts.GetValueOrDefault(recalculated, 0) + 1; + v1ScoreProcessor.ApplyResult(new JudgementResult(hitEvent.HitObject, hitEvent.HitObject.CreateJudgement()) + { + Type = recalculated, + TimeOffset = hitEvent.TimeOffset + }); + } + + double accuracy = v1ScoreProcessor.AccuracyClassic.Value; + long totalScore = v1ScoreProcessor.TotalScore.Value; + + Logger.Log($"[V1 ScoreProcessor]: {accuracy * 100:F2}%, Score: {totalScore / 10000}w"); + + // Set properties instead of returning + V1Accuracy = accuracy; + V1Score = totalScore; + V1Counts = v1Counts; + } + + /// + /// Recalculate the V1-style HitResult for a given . + /// Subclasses may override to provide ruleset-specific V1 judgement logic (e.g. Mania's CustomHitWindowsHelper). + /// + /// The hit event to recalculate for. + /// The recalculated for V1 accuracy. + protected virtual HitResult RecalculateV1Result(HitEvent hitEvent) + { + return HitWindows.ResultFor(hitEvent.TimeOffset); + } + + protected virtual HitResult RecalculateV2Result(HitEvent hitEvent) + { + return HitWindows.ResultFor(hitEvent.TimeOffset); + } + + /// + /// Calculate V2 accuracy. Subclasses can override to customize calculation. + /// Sets V2Accuracy, V2Score, and V2Counts properties instead of returning values. + /// + protected virtual void CalculateV2Accuracy() + { + // Create a fresh ScoreProcessor for V2 calculation (V1 already used one) + var v2ScoreProcessor = Score.Ruleset.CreateInstance().CreateScoreProcessor(); + v2ScoreProcessor.Mods.Value = Score.Mods; + v2ScoreProcessor.ApplyBeatmap(Beatmap); + var v2Counts = new Dictionary(); + + foreach (var hitEvent in HitEvents) + { + var recalculated = RecalculateV2Result(hitEvent); + v2Counts[recalculated] = v2Counts.GetValueOrDefault(recalculated, 0) + 1; + v2ScoreProcessor.ApplyResult(new JudgementResult(hitEvent.HitObject, hitEvent.HitObject.CreateJudgement()) + { + Type = recalculated, + TimeOffset = hitEvent.TimeOffset + }); + } + + double accuracy = v2ScoreProcessor.Accuracy.Value; + long totalScore = v2ScoreProcessor.TotalScore.Value; + + Logger.Log($"[V2 ScoreProcessor] Accuracy: {accuracy * 100:F2}%, Score: {totalScore / 10000}w"); + + // Set properties instead of returning + V2Accuracy = accuracy; + V2Score = totalScore; + V2Counts = v2Counts; + } + + protected virtual void UpdateDisplay() + { + if (!IsAlive || IsDisposed) + return; + + if (DrawWidth <= 0 || DrawHeight <= 0) + { + Scheduler.AddOnce(UpdateDisplay); + return; + } + + ClearInternal(); + + CalculateV1Accuracy(); + CalculateV2Accuracy(); + UpdateText(); + + foreach (HitResult result in Enum.GetValues(typeof(HitResult)).Cast().Where(r => r <= HitResult.Perfect && r >= HitResult.Meh)) + { + double boundary = UpdateBoundary(result); + drawBoundaryLine(boundary, result); + drawBoundaryLine(-boundary, result); + } + + var sortedHitEvents = HitEvents.OrderBy(e => e.HitObject.StartTime).ToList(); + + drawHealthLine(sortedHitEvents); + drawPointsGraph(sortedHitEvents); + } + + private void drawPointsGraph(List sortedHitEvents) + { + var pointList = new List<(Vector2 pos, Color4 colour)>(); + + foreach (var e in sortedHitEvents) + { + double time = e.HitObject.StartTime; + float xPosition = timeRange > 0 ? (float)((time - minTime) / timeRange) : 0; + float yPosition = (float)(e.TimeOffset + current_offset); + + float x = (xPosition * (DrawWidth - LeftMarginConst - RightMarginConst)) - (DrawWidth / 2) + LeftMarginConst; + pointList.Add((new Vector2(x, yPosition), colours.ForHitResult(e.Result))); + } + + if (pointList.Count > 0) + { + var scorePoints = new GirdPoints() + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + scorePoints.SetPoints(pointList); + AddInternal(scorePoints); + } + } + + protected virtual void UpdateText() + { + } + + /// + /// Request the graph to recalculate and redraw. Safe to call from other threads (will schedule on update thread). + /// + protected void Refresh() + { + Scheduler.AddOnce(UpdateDisplay); + } + + protected virtual double UpdateBoundary(HitResult result) + { + return HitWindows.WindowFor(result); + } + + private void drawHealthLine(List sortedHitEvents) + { + double currentHealth = 0.0; + List healthPoints = new List(); + + foreach (var e in sortedHitEvents) + { + var judgement = e.HitObject.CreateJudgement(); + var judgementResult = new JudgementResult(e.HitObject, judgement) { Type = e.Result }; + double healthIncrease = judgement.HealthIncreaseFor(judgementResult); + currentHealth = Math.Clamp(currentHealth + healthIncrease, 0, 1); + + double time = e.HitObject.StartTime; + float xPosition = timeRange > 0 ? (float)((time - minTime) / timeRange) : 0; + float x = (xPosition * (DrawWidth - LeftMarginConst - RightMarginConst)) - (DrawWidth / 2) + LeftMarginConst; + float y = (float)((1 - currentHealth) * DrawHeight - DrawHeight / 2); + + healthPoints.Add(new Vector2(x, y)); + } + + if (healthPoints.Count > 1) + { + AddInternal(new Path + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + PathRadius = 1, + Colour = Color4.Red, + Alpha = 0.3f, + Vertices = healthPoints.ToArray() + }); + } + } + + private void drawBoundaryLine(double boundary, HitResult result) + { + float availableWidth = DrawWidth - LeftMarginConst - RightMarginConst + 20; + float relativeWidth = availableWidth / DrawWidth; + + AddInternal(new Box + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + Height = 1, + Width = relativeWidth, + Alpha = 0.1f, + Colour = Color4.Gray, + }); + + AddInternal(new Box + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + Height = 1, + Width = relativeWidth, + Alpha = 0.1f, + Colour = colours.ForHitResult(result), + Y = (float)(boundary + current_offset), + }); + + AddInternal(new OsuSpriteText + { + Text = $"{boundary:+0.##;-0.##}", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Font = OsuFont.GetFont(size: 12), + Colour = Color4.White, + X = LeftMarginConst - 25, + Y = (float)(boundary + current_offset), + }); + } + } +} + diff --git a/osu.Game/LAsEzExtensions/Analysis/Diagnostics/EzManiaAnalysisPerf.cs b/osu.Game/LAsEzExtensions/Analysis/Diagnostics/EzManiaAnalysisPerf.cs new file mode 100644 index 0000000000..4afdb0a661 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/Diagnostics/EzManiaAnalysisPerf.cs @@ -0,0 +1,295 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Threading; +using osu.Framework.Logging; + +namespace osu.Game.LAsEzExtensions.Analysis.Diagnostics +{ + public static class EzManiaAnalysisPerf + { + /// + /// Enables aggregated logging for mania analysis/persistence. + /// Intended for performance debugging only. + /// + public static volatile bool Enabled; + + static EzManiaAnalysisPerf() + { + // Default to disabled to minimise overhead. Enable explicitly via environment variable. + // Accepted values: 1/true/yes/on (case-insensitive) + string? raw = Environment.GetEnvironmentVariable("EZ_MANIA_ANALYSIS_PERF"); + if (raw == null) + return; + + raw = raw.Trim(); + Enabled = raw.Equals("1", StringComparison.OrdinalIgnoreCase) + || raw.Equals("true", StringComparison.OrdinalIgnoreCase) + || raw.Equals("yes", StringComparison.OrdinalIgnoreCase) + || raw.Equals("on", StringComparison.OrdinalIgnoreCase); + } + + private const string log_category = "mania_analysis_perf"; + + private static long last_log_timestamp; + + private static long request_count; + private static long compute_started_count; + private static long compute_completed_count; + private static long compute_cancelled_count; + + private static long compute_total_ticks; + private static long compute_non_persist_ticks; + private static long compute_persist_hit_ticks; + + private static long persistence_tryget_count; + private static long persistence_hit_count; + private static long persistence_deserialize_ticks; + private static long persistence_kps_json_chars; + private static long persistence_cols_json_chars; + private static long persistence_holds_json_chars; + + private static long persistence_store_count; + private static long persistence_serialize_ticks; + + private static long eviction_count; + + private static long ui_update_count; + private static long ui_update_ticks; + private static long ui_update_alloc_bytes; + + private static long ui_graph_set_count; + private static long ui_graph_set_ticks; + private static long ui_graph_set_alloc_bytes; + private static long ui_graph_points_total; + + private static long ui_kpc_update_count; + private static long ui_kpc_update_ticks; + private static long ui_kpc_update_alloc_bytes; + private static long ui_kpc_columns_total; + private static long ui_kpc_barchart_count; + + private static int in_memory_cache_size; + private static int in_memory_cache_limit; + private static int high_priority_inflight; + private static int low_priority_inflight; + + public static void RecordRequest() + { + if (!Enabled) return; + + Interlocked.Increment(ref request_count); + } + + public static void RecordComputeStart(bool isLowPriority) + { + if (!Enabled) return; + + Interlocked.Increment(ref compute_started_count); + + if (isLowPriority) + Interlocked.Increment(ref low_priority_inflight); + else + Interlocked.Increment(ref high_priority_inflight); + } + + public static void RecordComputeEnd(bool isLowPriority) + { + if (!Enabled) return; + + Interlocked.Increment(ref compute_completed_count); + + if (isLowPriority) + Interlocked.Decrement(ref low_priority_inflight); + else + Interlocked.Decrement(ref high_priority_inflight); + } + + public static void RecordComputeCancelled() + { + if (!Enabled) return; + + Interlocked.Increment(ref compute_cancelled_count); + } + + public static void RecordComputeElapsedTicks(long elapsedTicks, bool wasPersistHit) + { + if (!Enabled) return; + + Interlocked.Add(ref compute_total_ticks, elapsedTicks); + + if (wasPersistHit) + Interlocked.Add(ref compute_persist_hit_ticks, elapsedTicks); + else + Interlocked.Add(ref compute_non_persist_ticks, elapsedTicks); + } + + public static void RecordPersistenceTryGet(bool hit, long deserializeTicks, int kpsJsonChars, int colsJsonChars, int holdsJsonChars) + { + if (!Enabled) return; + + Interlocked.Increment(ref persistence_tryget_count); + + if (hit) + Interlocked.Increment(ref persistence_hit_count); + + Interlocked.Add(ref persistence_deserialize_ticks, deserializeTicks); + Interlocked.Add(ref persistence_kps_json_chars, kpsJsonChars); + Interlocked.Add(ref persistence_cols_json_chars, colsJsonChars); + Interlocked.Add(ref persistence_holds_json_chars, holdsJsonChars); + } + + public static void RecordPersistenceStore(long serializeTicks) + { + if (!Enabled) return; + + Interlocked.Increment(ref persistence_store_count); + Interlocked.Add(ref persistence_serialize_ticks, serializeTicks); + } + + public static void RecordEviction() + { + if (!Enabled) return; + + Interlocked.Increment(ref eviction_count); + } + + public static void UpdateCacheGauges(int currentSize, int limit) + { + if (!Enabled) return; + + Volatile.Write(ref in_memory_cache_size, currentSize); + Volatile.Write(ref in_memory_cache_limit, limit); + } + + public static void RecordUiUpdate(long elapsedTicks, long allocatedBytes) + { + if (!Enabled) return; + + Interlocked.Increment(ref ui_update_count); + Interlocked.Add(ref ui_update_ticks, elapsedTicks); + Interlocked.Add(ref ui_update_alloc_bytes, allocatedBytes); + } + + public static void RecordUiGraphSet(int points, long elapsedTicks, long allocatedBytes) + { + if (!Enabled) return; + + Interlocked.Increment(ref ui_graph_set_count); + Interlocked.Add(ref ui_graph_set_ticks, elapsedTicks); + Interlocked.Add(ref ui_graph_set_alloc_bytes, allocatedBytes); + Interlocked.Add(ref ui_graph_points_total, points); + } + + public static void RecordUiKpcUpdate(int columns, bool isBarChart, long elapsedTicks, long allocatedBytes) + { + if (!Enabled) return; + + Interlocked.Increment(ref ui_kpc_update_count); + Interlocked.Add(ref ui_kpc_update_ticks, elapsedTicks); + Interlocked.Add(ref ui_kpc_update_alloc_bytes, allocatedBytes); + Interlocked.Add(ref ui_kpc_columns_total, columns); + if (isBarChart) + Interlocked.Increment(ref ui_kpc_barchart_count); + } + + public static void MaybeLog() + { + if (!Enabled) return; + + long now = Stopwatch.GetTimestamp(); + long last = Volatile.Read(ref last_log_timestamp); + + if (now - last < Stopwatch.Frequency) + return; + + if (Interlocked.CompareExchange(ref last_log_timestamp, now, last) != last) + return; + + long req = Interlocked.Exchange(ref request_count, 0); + long started = Interlocked.Exchange(ref compute_started_count, 0); + long completed = Interlocked.Exchange(ref compute_completed_count, 0); + long cancelled = Interlocked.Exchange(ref compute_cancelled_count, 0); + + long computeTicks = Interlocked.Exchange(ref compute_total_ticks, 0); + long computeNonPersistTicks = Interlocked.Exchange(ref compute_non_persist_ticks, 0); + long computePersistHitTicks = Interlocked.Exchange(ref compute_persist_hit_ticks, 0); + + long tryGet = Interlocked.Exchange(ref persistence_tryget_count, 0); + long hit = Interlocked.Exchange(ref persistence_hit_count, 0); + long deserTicks = Interlocked.Exchange(ref persistence_deserialize_ticks, 0); + long kpsChars = Interlocked.Exchange(ref persistence_kps_json_chars, 0); + long colsChars = Interlocked.Exchange(ref persistence_cols_json_chars, 0); + long holdsChars = Interlocked.Exchange(ref persistence_holds_json_chars, 0); + + long store = Interlocked.Exchange(ref persistence_store_count, 0); + long serTicks = Interlocked.Exchange(ref persistence_serialize_ticks, 0); + + long evict = Interlocked.Exchange(ref eviction_count, 0); + + long uiCount = Interlocked.Exchange(ref ui_update_count, 0); + long uiTicks = Interlocked.Exchange(ref ui_update_ticks, 0); + long uiAlloc = Interlocked.Exchange(ref ui_update_alloc_bytes, 0); + + long graphCount = Interlocked.Exchange(ref ui_graph_set_count, 0); + long graphTicks = Interlocked.Exchange(ref ui_graph_set_ticks, 0); + long graphAlloc = Interlocked.Exchange(ref ui_graph_set_alloc_bytes, 0); + long graphPoints = Interlocked.Exchange(ref ui_graph_points_total, 0); + + long kpcCount = Interlocked.Exchange(ref ui_kpc_update_count, 0); + long kpcTicks = Interlocked.Exchange(ref ui_kpc_update_ticks, 0); + long kpcAlloc = Interlocked.Exchange(ref ui_kpc_update_alloc_bytes, 0); + long kpcCols = Interlocked.Exchange(ref ui_kpc_columns_total, 0); + long kpcBar = Interlocked.Exchange(ref ui_kpc_barchart_count, 0); + + int cacheSize = Volatile.Read(ref in_memory_cache_size); + int cacheLimit = Volatile.Read(ref in_memory_cache_limit); + int highInflight = Volatile.Read(ref high_priority_inflight); + int lowInflight = Volatile.Read(ref low_priority_inflight); + + double ticksToMs(long t) => t * 1000.0 / Stopwatch.Frequency; + + double avgComputeMs = completed > 0 ? ticksToMs(computeTicks) / completed : 0; + double avgComputeNonPersistMs = completed > 0 ? ticksToMs(computeNonPersistTicks) / completed : 0; + double avgComputePersistHitMs = completed > 0 ? ticksToMs(computePersistHitTicks) / completed : 0; + + double totalDeserMs = ticksToMs(deserTicks); + double avgDeserMs = tryGet > 0 ? totalDeserMs / tryGet : 0; + + double totalSerMs = ticksToMs(serTicks); + double avgSerMs = store > 0 ? totalSerMs / store : 0; + + double uiTotalMs = ticksToMs(uiTicks); + double uiAvgMs = uiCount > 0 ? uiTotalMs / uiCount : 0; + double uiTotalKb = uiAlloc / 1024.0; + double uiAvgKb = uiCount > 0 ? uiTotalKb / uiCount : 0; + + double graphTotalMs = ticksToMs(graphTicks); + double graphAvgMs = graphCount > 0 ? graphTotalMs / graphCount : 0; + double graphTotalKb = graphAlloc / 1024.0; + double graphAvgKb = graphCount > 0 ? graphTotalKb / graphCount : 0; + + double kpcTotalMs = ticksToMs(kpcTicks); + double kpcAvgMs = kpcCount > 0 ? kpcTotalMs / kpcCount : 0; + double kpcTotalKb = kpcAlloc / 1024.0; + double kpcAvgKb = kpcCount > 0 ? kpcTotalKb / kpcCount : 0; + + string persistRate = tryGet > 0 ? $"{hit}/{tryGet}" : "0/0"; + + Logger.Log( + $"req={req} compute(started/completed/cancelled)={started}/{completed}/{cancelled} " + + $"avgComputeMs={avgComputeMs:F2} (nonPersist~{avgComputeNonPersistMs:F2}, persistHit~{avgComputePersistHitMs:F2}) " + + $"persist(hit/try)={persistRate} deserMs(total/avg)={totalDeserMs:F2}/{avgDeserMs:F2} " + + $"jsonChars(kps/cols/holds)={kpsChars}/{colsChars}/{holdsChars} " + + $"store={store} serMs(total/avg)={totalSerMs:F2}/{avgSerMs:F2} " + + $"ui(upd count/ms/KB)={uiCount}/{uiTotalMs:F2}/{uiTotalKb:F1} avg={uiAvgMs:F2}ms/{uiAvgKb:F1}KB " + + $"graph(set count/ms/KB pts)={graphCount}/{graphTotalMs:F2}/{graphTotalKb:F1} pts={graphPoints} avg={graphAvgMs:F2}ms/{graphAvgKb:F1}KB " + + $"kpc(upd count/ms/KB cols bar)={kpcCount}/{kpcTotalMs:F2}/{kpcTotalKb:F1} cols={kpcCols} bar={kpcBar} avg={kpcAvgMs:F2}ms/{kpcAvgKb:F1}KB " + + $"cache={cacheSize}/{cacheLimit} evict={evict} inflight(H/L)={highInflight}/{lowInflight}", + log_category, + LogLevel.Important); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/EzAnalysisOptionsPopover.cs b/osu.Game/LAsEzExtensions/Analysis/EzAnalysisOptionsPopover.cs new file mode 100644 index 0000000000..84949933af --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/EzAnalysisOptionsPopover.cs @@ -0,0 +1,55 @@ +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public partial class EzAnalysisOptionsPopover : OsuPopover + { + // private readonly BeatmapInfo beatmapInfo; + + public EzAnalysisOptionsPopover(BeatmapInfo beatmapInfo) + : base(false) + { + // this.beatmapInfo = beatmapInfo; + + Body.CornerRadius = 4; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new[] + { + new OsuMenu(Direction.Vertical, true) + { + Items = items, + MaxHeight = 375, + }, + }; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + Hide(); + } + + private OsuMenuItem[] items => new[] + { + new OsuMenuItem("列队选项1", MenuItemType.Standard, () => OnOptionSelected("列队选项1")), + new OsuMenuItem("列队选项2", MenuItemType.Standard, () => OnOptionSelected("列队选项2")), + new OsuMenuItem("列队选项3", MenuItemType.Standard, () => OnOptionSelected("列队选项3")) + }; + + private void OnOptionSelected(string option) + { + // 处理选项选择逻辑 + Console.WriteLine($"选中: {option}"); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/EzAnalysisScoreButton.cs b/osu.Game/LAsEzExtensions/Analysis/EzAnalysisScoreButton.cs new file mode 100644 index 0000000000..d921915ef8 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/EzAnalysisScoreButton.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; +using Realms; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public partial class EzAnalysisScoreButton : GrayButton, IHasPopover + { + private readonly BeatmapInfo beatmapInfo; + private readonly Bindable isInAnyCollection; + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + private IDisposable? collectionSubscription; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public EzAnalysisScoreButton(BeatmapInfo beatmapInfo) + : base(FontAwesome.Solid.Book) + { + this.beatmapInfo = beatmapInfo; + isInAnyCollection = new Bindable(false); + + Size = new Vector2(75, 30); + + TooltipText = "Other MUG Determinator"; + } + + [BackgroundDependencyLoader] + private void load() + { + Action = ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + collectionSubscription = realmAccess.RegisterForNotifications(r => r.All(), collectionsChanged); + + isInAnyCollection.BindValueChanged(_ => updateState(), true); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + collectionSubscription?.Dispose(); + } + + private void collectionsChanged(IRealmCollection sender, ChangeSet? changes) + { + isInAnyCollection.Value = sender.AsEnumerable().Any(c => c.BeatmapMD5Hashes.Contains(beatmapInfo.MD5Hash)); + } + + private void updateState() + { + Background.FadeColour(isInAnyCollection.Value ? colours.Green : colours.Gray4, 500, Easing.InOutExpo); + } + + public Popover GetPopover() => new EzAnalysisOptionsPopover(beatmapInfo); + + public void ShowPopover() + { + var lAsAnalysisOptionsPopover = new EzAnalysisOptionsPopover(beatmapInfo); + if (lAsAnalysisOptionsPopover == null) throw new ArgumentNullException(nameof(lAsAnalysisOptionsPopover)); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/EzBeatmapCalculator.cs b/osu.Game/LAsEzExtensions/Analysis/EzBeatmapCalculator.cs new file mode 100644 index 0000000000..dae625b0bd --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/EzBeatmapCalculator.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public class EzBeatmapCalculator + { + public static (double averageKps, double maxKps, List kpsList) GetKps(IBeatmap beatmap) + { + var kpsList = new List(); + var hitObjects = beatmap.HitObjects; + if (hitObjects.Count == 0) + return (0, 0, kpsList); + + double interval = 4 * 60000 / beatmap.BeatmapInfo.BPM; + double songEndTime = hitObjects[^1].StartTime; + const double start_time = 0; + + // 预处理HitObjects按StartTime排序(假设原列表已排序,可省略) + // 使用二分查找优化区间查询 + for (double currentTime = start_time; currentTime < songEndTime; currentTime += interval) + { + double endTime = currentTime + interval; + int startIdx = findFirstIndexGreaterOrEqual(hitObjects, currentTime); + int endIdx = findFirstIndexGreaterOrEqual(hitObjects, endTime); + int hits = endIdx - startIdx; + + kpsList.Add(hits / (interval / 1000)); + } + + if (kpsList.Count == 0) + return (0, 0, kpsList); + + return (kpsList.Average(), kpsList.Max(), kpsList); + } + + private static int findFirstIndexGreaterOrEqual(IReadOnlyList hitObjects, double targetTime) + { + int low = 0, high = hitObjects.Count; + + while (low < high) + { + int mid = (low + high) / 2; + if (hitObjects[mid].StartTime < targetTime) + low = mid + 1; + else + high = mid; + } + + return low; + } + + public static Dictionary GetColumnNoteCounts(IBeatmap beatmap) + { + var counts = new Dictionary(); + + foreach (var obj in beatmap.HitObjects.OfType()) + { + if (obj is IHasDuration) continue; + + counts[obj.Column] = counts.TryGetValue(obj.Column, out int c) ? c + 1 : 1; + } + + return counts; + } + + /// + /// 复用外部已经计算好的 列统计与 KPS 数据,生成 Scratch 标签。 + /// 用于选歌面板:避免重复遍历 HitObjects / 重复计算 KPS。 + /// + // TODO: 计算比较粗糙,后续可优化。 + public static string GetScratchFromPrecomputed(Dictionary columnCounts, double maxKps, List kpsList, int keyCount) + { + if (keyCount <= 0) return "[?K] "; + + if (maxKps == 0) return $"[{keyCount}K] "; + + // 将列统计映射为固定长度数组,方便计算 empty 列。 + int[] countsByColumn = new int[keyCount]; + + foreach (var (column, count) in columnCounts) + { + if ((uint)column < (uint)keyCount) + countsByColumn[column] = count; + } + + var (isFirstLow, isFirstHigh, isLastLow, isLastHigh) = checkNotes(countsByColumn, keyCount); + + // 去掉两侧列,计算“中间列”平均/最大。 + // int[] middleCounts = keyCount > 2 ? countsByColumn.Skip(1).Take(keyCount - 2).ToArray() : Array.Empty(); + // double averageNotes = middleCounts.Length > 0 ? middleCounts.Average() : 0; + // int maxNotesInMiddle = middleCounts.Length > 0 ? middleCounts.Max() : 0; + + string result = $"[{keyCount}K] "; + + if (keyCount == 6 || keyCount == 8) + { + if (isFirstHigh || isLastHigh) + result = $"[{keyCount - 1}K1S] "; + else if (isFirstLow || isLastLow) + result = $"[{keyCount - 1}+1K] "; + } + else if (keyCount >= 7) + { + if (isFirstHigh || isLastHigh) + result = $"[{keyCount - 2}K2S] "; + else if (isFirstLow || isLastLow) + result = $"[{keyCount - 2}+2K] "; + } + + int emptyColumns = countsByColumn.Count(c => c == 0); + if (emptyColumns > 0) + result = $"[{keyCount - emptyColumns}K_{emptyColumns}Empty] "; + + return result; + } + + private static (bool isFirstLow, bool isFirstHigh, bool isLastLow, bool isLastHigh) checkNotes(int[] countsByColumn, int keyCount) + { + bool isFirstLow = false; + bool isFirstHigh = false; + bool isLastLow = false; + bool isLastHigh = false; + + if (keyCount >= 2) + { + int firstCount = countsByColumn[0]; + int secondCount = countsByColumn[1]; + isFirstLow = (firstCount > 0 && firstCount < secondCount / 2.0); + isFirstHigh = firstCount > secondCount * 2; + + int lastCount = countsByColumn[^1]; + int secondLastCount = countsByColumn[^2]; + isLastLow = (lastCount > 0 && lastCount < secondLastCount / 2.0); + isLastHigh = lastCount > secondLastCount * 2; + } + + return (isFirstLow, isFirstHigh, isLastLow, isLastHigh); + } + + // private static bool checkHighSpeed(double maxKps, List kpsList) + // { + // double threshold = maxKps / 4; + + // foreach (double kps in kpsList) + // { + // if (kps > threshold) + // return true; + // } + + // return false; + // } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/EzBeatmapManiaAnalysisCache.cs b/osu.Game/LAsEzExtensions/Analysis/EzBeatmapManiaAnalysisCache.cs new file mode 100644 index 0000000000..6d6e5e90da --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/EzBeatmapManiaAnalysisCache.cs @@ -0,0 +1,682 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Textures; +using osu.Framework.Lists; +using osu.Framework.Logging; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.LAsEzExtensions.Analysis.Persistence; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.SelectV2; +using osu.Game.Skinning; +using osu.Game.Storyboards; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + /// + /// 仅用于 mania 的选歌面板分析缓存(KPS/每列 notes/scratch)。 + /// 参考 的中心化缓存模式: + /// - 单线程 控制后台计算并发,避免拖动滚动条时造成卡顿。 + /// - 统一监听当前 ruleset/mods 及 mod 设置变化,批量更新所有已追踪的 bindable。 + /// - 缓存 key 包含 mod 设置(依赖 mod 的相等性/哈希语义),避免“切/调 mod 不重算”。 + /// + public partial class EzBeatmapManiaAnalysisCache : MemoryCachingComponent + { + private static int computeFailCount; + + // (Removed runtime instrumentation counters) + // 太多同时更新会导致卡顿;官方 star cache 使用 1 线程,但我们可以尝试略微提高并发以加快可见项响应。 + // 这里将高优先级并发从 1 增加到 2 来观察是否能减少感知延迟,同时保留低优先级为 1。 + private readonly ThreadedTaskScheduler highPriorityScheduler = new ThreadedTaskScheduler(2, nameof(EzBeatmapManiaAnalysisCache)); + private readonly ThreadedTaskScheduler lowPriorityScheduler = new ThreadedTaskScheduler(1, $"{nameof(EzBeatmapManiaAnalysisCache)} (Warmup)"); + + private readonly ManualResetEventSlim highPriorityIdleEvent = new ManualResetEventSlim(true); + + // A small gate to avoid flooding SQLite with too many concurrent readers. + private readonly SemaphoreSlim persistenceReadGate = new SemaphoreSlim(4, 4); + + // Deduplicate in-flight computations so multiple requests for the same lookup reuse the same Task + private readonly ConcurrentDictionary> inflightComputations = + new ConcurrentDictionary>(); + + private static readonly AsyncLocal low_priority_scope_depth = new AsyncLocal(); + + private readonly WeakList trackedBindables = new WeakList(); + private readonly List linkedCancellationSources = new List(); + private readonly object bindableUpdateLock = new object(); + + private CancellationTokenSource trackedUpdateCancellationSource = new CancellationTokenSource(); + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private EzManiaAnalysisPersistentStore persistentStore { get; set; } = null!; + + [Resolved(CanBeNull = true)] + private Bindable currentRuleset { get; set; } = null!; + + [Resolved] + private Bindable> currentMods { get; set; } = null!; + + private ModSettingChangeTracker? modSettingChangeTracker; + private ScheduledDelegate? debouncedModSettingsChange; + + private int modsRevision; + + // Limit in-memory cache size. + // - When persistence is available, keep a small working set (current page) in memory. + // - When persistence is unavailable/disabled, allow a slightly larger set to reduce recomputation. + private const int max_in_memory_entries_with_persistence = 24; + private const int max_in_memory_entries_without_persistence = 48; + private readonly ConcurrentQueue cacheInsertionOrder = new ConcurrentQueue(); + + // We avoid modifying the official MemoryCachingComponent by keeping our own set of cached keys. + // Eviction is then performed by calling Invalidate(key == oldest), which is O(n) over the base cache, + // but n is bounded to a small working set (24/48) so this is fine. + private readonly ConcurrentDictionary cachedLookups = new ConcurrentDictionary(); + private int cachedLookupsCount; + + // 与 SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE 保持一致。 + private const int mod_settings_debounce = SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE + 10; + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentRuleset.BindValueChanged(_ => Scheduler.AddOnce(updateTrackedBindables)); + + currentMods.BindValueChanged(mods => + { + Interlocked.Increment(ref modsRevision); + modSettingChangeTracker?.Dispose(); + + Scheduler.AddOnce(updateTrackedBindables); + + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += _ => + { + Interlocked.Increment(ref modsRevision); + debouncedModSettingsChange?.Cancel(); + debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, mod_settings_debounce); + }; + }, true); + } + + protected override bool CacheNullValues => false; + + /// + /// Marks the current async flow as low-priority (warmup). Any cache misses triggered within the returned scope + /// will be executed on the low-priority scheduler. + /// + public IDisposable BeginLowPriorityScope() + { + low_priority_scope_depth.Value++; + return new InvokeOnDisposal(() => low_priority_scope_depth.Value--); + } + + /// + /// Warm up the persistent store only (no-mod baseline) without populating the in-memory cache. + /// Intended for startup warmup to avoid retaining a large set of results in memory. + /// + public Task WarmupPersistentOnlyAsync(BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) + { + if (!EzManiaAnalysisPersistentStore.Enabled) + return Task.CompletedTask; + + // Only mania is supported. + if (beatmapInfo.Ruleset is not RulesetInfo rulesetInfo || rulesetInfo.OnlineID != 3) + return Task.CompletedTask; + + // Always run as low-priority and never compete with visible computations. + // Warmup should be classified via BeginLowPriorityScope so that other code can + // correctly distinguish warmup flows (e.g. persistence read gating). Additionally, + // limit concurrent persistent reads to avoid flooding SQLite during warmup. + return Task.Factory.StartNew(() => + { + // Mark this async flow as low-priority for classification elsewhere. + using (BeginLowPriorityScope()) + { + highPriorityIdleEvent.Wait(cancellationToken); + + // No mods: only baseline is persisted. + var lookup = new ManiaAnalysisCacheLookup(beatmapInfo, rulesetInfo, mods: null); + + // First, gate and probe the persistent store to avoid flooding readers. + bool persistedExists = false; + + if (EzManiaAnalysisPersistentStore.Enabled) + { + bool gateAcquired = false; + + try + { + persistenceReadGate.Wait(cancellationToken); + gateAcquired = true; + + if (persistentStore.TryGet(lookup.BeatmapInfo, out _)) + { + // xxysr 补算机制已禁用,直接返回持久化结果 + persistedExists = true; + } + } + catch (OperationCanceledException) + { + // cancellation requested - abort warmup for this item. + return; + } + catch + { + // ignore persistence probe failures and fall through to compute. + } + finally + { + if (gateAcquired) + { + try { persistenceReadGate.Release(); } + catch { } + } + } + } + + // Only compute and store baseline if a persisted entry does not already exist. + if (!persistedExists) + { + try + { + computeAnalysis(lookup, cancellationToken); + } + catch (OperationCanceledException) + { + // ignore cancellations during warmup + } + catch + { + // ignore failures; warmup should not crash. + } + } + } + }, cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, lowPriorityScheduler); + } + + public IBindable GetBindableAnalysis(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken = default, int computationDelay = 0) + { + var localBeatmapInfo = beatmapInfo as BeatmapInfo; + + var bindable = new BindableManiaBeatmapAnalysis(beatmapInfo, cancellationToken) + { + Value = ManiaBeatmapAnalysisDefaults.EMPTY + }; + + if (localBeatmapInfo == null) + return bindable; + + updateBindable(bindable, localBeatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken, computationDelay); + + lock (bindableUpdateLock) + trackedBindables.Add(bindable); + + return bindable; + } + + public Task GetAnalysisAsync(IBeatmapInfo beatmapInfo, + IRulesetInfo? rulesetInfo = null, + IEnumerable? mods = null, + CancellationToken cancellationToken = default, + int computationDelay = 0) + { + var localBeatmapInfo = beatmapInfo as BeatmapInfo; + var localRulesetInfo = (rulesetInfo ?? beatmapInfo.Ruleset) as RulesetInfo; + + if (localBeatmapInfo == null || localRulesetInfo == null) + return Task.FromResult(null); + + // Use the original constructor that handles mod cloning and signature computation + var lookup = new ManiaAnalysisCacheLookup(localBeatmapInfo, localRulesetInfo, mods); + + return getAndMaybeEvictAsync(lookup, cancellationToken, computationDelay); + } + + private async Task getAndMaybeEvictAsync(ManiaAnalysisCacheLookup lookup, CancellationToken cancellationToken, int computationDelay) + { + ManiaBeatmapAnalysisResult? result = await GetAsync(lookup, cancellationToken, computationDelay).ConfigureAwait(false); + + if (result.HasValue) + { + cacheInsertionOrder.Enqueue(lookup); + + if (cachedLookups.TryAdd(lookup, 0)) + Interlocked.Increment(ref cachedLookupsCount); + + int maxEntries = EzManiaAnalysisPersistentStore.Enabled + ? max_in_memory_entries_with_persistence + : max_in_memory_entries_without_persistence; + + while (Volatile.Read(ref cachedLookupsCount) > maxEntries && cacheInsertionOrder.TryDequeue(out var toRemove)) + { + if (!cachedLookups.TryRemove(toRemove, out _)) + continue; + + Interlocked.Decrement(ref cachedLookupsCount); + Invalidate(k => k.Equals(toRemove)); + } + } + + return result; + } + + protected override Task ComputeValueAsync(ManiaAnalysisCacheLookup lookup, CancellationToken token = default) + { + return computeValueWithDedupAsync(lookup, token); + } + + private async Task computeValueWithDedupAsync(ManiaAnalysisCacheLookup lookup, CancellationToken token) + { + // If a computation for this lookup is already in-flight, reuse it. + // Important: the computation itself should not be cancelled by any single requester. + // Panels get recycled and cancel their tokens aggressively (eg. when off-screen), but we still + // want shared computations to complete so results can be reused. + var existing = inflightComputations.GetOrAdd(lookup, lookupKey => + { + var task = computeValueInternalCore(lookup, CancellationToken.None); + + // Remove the entry when the task completes (best-effort), but only if the value matches. + task.ContinueWith( + completedTask => inflightComputations.TryRemove(new KeyValuePair>(lookup, task)), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + return task; + }); + + // Allow individual callers to cancel waiting without cancelling the shared computation. + try + { + return await existing.WaitAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + } + + private async Task computeValueInternalCore(ManiaAnalysisCacheLookup lookup, CancellationToken token) + { + // Quick path: if a persisted no-mod baseline exists, return it without entering the single-thread compute queue. + // This avoids head-of-line blocking where one expensive miss would delay many cheap hits. + if (lookup.OrderedMods.Length == 0 && EzManiaAnalysisPersistentStore.Enabled) + { + // Use low_priority_scope_depth to distinguish warmup/background flows from visible flows. + // Warmup flows will call BeginLowPriorityScope and set the async-local depth > 0. + // We gate only warmup/background flows to limit DB concurrency; visible flows use + // the fast-path read to avoid UI stalls. + if (low_priority_scope_depth.Value > 0) + { + bool gateAcquired = false; + + try + { + await persistenceReadGate.WaitAsync(token).ConfigureAwait(false); + gateAcquired = true; + + var persistedResult = await Task.Run(() => + { + if (persistentStore.TryGet(lookup.BeatmapInfo, out var persisted)) + { + // xxysr 补算机制已禁用,直接返回持久化结果 + return persisted; + } + + return null; + }, token).ConfigureAwait(false); + + if (persistedResult != null) + return persistedResult; + } + catch (OperationCanceledException) + { + return null; + } + catch + { + // Ignore persistence failures and fall back to compute. + } + finally + { + if (gateAcquired) + persistenceReadGate.Release(); + } + } + else + { + try + { + var persistedResult = await Task.Run(() => + { + if (persistentStore.TryGet(lookup.BeatmapInfo, out var persisted)) + { + // xxysr 补算机制已禁用,直接返回持久化结果 + return persisted; + } + + return null; + }, token).ConfigureAwait(false); + + if (persistedResult != null) + return persistedResult; + } + catch (OperationCanceledException) + { + return null; + } + catch + { + // Ignore persistence failures and fall back to compute. + } + } + } + + bool isLowPriority = low_priority_scope_depth.Value > 0; + var scheduler = isLowPriority ? lowPriorityScheduler : highPriorityScheduler; + + var task = Task.Factory.StartNew(() => + { + if (CheckExists(lookup, out var existing)) + return existing; + + // Warmup: never compete with visible/high-priority computations. + if (isLowPriority) + highPriorityIdleEvent.Wait(token); + + return computeAnalysis(lookup, token); + }, token, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, scheduler); + + return await task.ConfigureAwait(false); + } + + // enter/exit high-priority work helpers removed; gating uses ManualResetEventSlim directly. + + private ManiaBeatmapAnalysisResult? computeAnalysis(ManiaAnalysisCacheLookup lookup, CancellationToken cancellationToken) + { + try + { + // 无mod快速路径:尝试直接从持久化存储获取结果 + if (lookup.OrderedMods.Length == 0 && persistentStore.TryGet(lookup.BeatmapInfo, out var persisted)) + { + return persisted; + } + + var rulesetInstance = lookup.Ruleset.CreateInstance(); + + if (!(rulesetInstance is ILegacyRuleset)) + return null; + + var legacyRuleset = (ILegacyRuleset)rulesetInstance; + int keyCount = legacyRuleset.GetKeyCount(lookup.BeatmapInfo, lookup.OrderedMods); + + PlayableCachedWorkingBeatmap workingBeatmap = new PlayableCachedWorkingBeatmap(beatmapManager.GetWorkingBeatmap(lookup.BeatmapInfo)); + var playableBeatmap = workingBeatmap.GetPlayableBeatmap(lookup.Ruleset, lookup.OrderedMods, cancellationToken); + + int inferredKeyCount = getKeyCountFromBeatmap(playableBeatmap); + if (keyCount <= 0) + keyCount = inferredKeyCount; + else if (inferredKeyCount > keyCount) + keyCount = inferredKeyCount; + + cancellationToken.ThrowIfCancellationRequested(); + + var (averageKps, maxKps, kpsList, columnCounts, holdNoteCounts) = OptimizedBeatmapCalculator.GetAllDataOptimized(playableBeatmap); + + // Apply rate-adjust mods (DT/HT etc.) to KPS values. + // These mods affect effective time progression, so KPS should scale with rate. + double rate = getRateAdjustMultiplier(lookup.OrderedMods); + + if (!Precision.AlmostEquals(rate, 1.0)) + { + averageKps *= rate; + maxKps *= rate; + + for (int i = 0; i < kpsList.Count; i++) + kpsList[i] *= rate; + } + + double? xxySr = null; + + bool shouldCalculateXxy = lookup.Ruleset.OnlineID == 3; + + if (shouldCalculateXxy && playableBeatmap.HitObjects.Count > 0 && XxySrCalculatorBridge.TryCalculate(playableBeatmap, rate, out double sr) && !double.IsNaN(sr) && !double.IsInfinity(sr)) + xxySr = sr; + + cancellationToken.ThrowIfCancellationRequested(); + + string scratchText = EzBeatmapCalculator.GetScratchFromPrecomputed(columnCounts, maxKps, kpsList, keyCount); + + kpsList = OptimizedBeatmapCalculator.DownsampleToFixedCount(kpsList, OptimizedBeatmapCalculator.DEFAULT_KPS_GRAPH_POINTS); + + var analysis = new ManiaBeatmapAnalysisResult( + averageKps, + maxKps, + kpsList, + columnCounts, + holdNoteCounts, + scratchText, + xxySr); + + if (lookup.OrderedMods.Length == 0 && analysis.ColumnCounts.Count > 0) + { + persistentStore.StoreIfDifferent(lookup.BeatmapInfo, analysis); + } + + return analysis; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + if (Interlocked.Increment(ref computeFailCount) <= 10) + { + string mods = lookup.OrderedMods.Length == 0 ? "(none)" : string.Join(',', lookup.OrderedMods.Select(m => m.Acronym)); + Logger.Error(ex, $"[EzBeatmapManiaAnalysisCache] computeAnalysis failed. beatmapId={lookup.BeatmapInfo.ID} diff=\"{lookup.BeatmapInfo.DifficultyName}\" ruleset={lookup.Ruleset.ShortName} mods={mods}"); + } + + return null; + } + } + + private static double getRateAdjustMultiplier(Mod[] mods) + { + // Prefer the generic ruleset mechanism: any mod implementing IApplicableToRate should contribute. + // Apply in mod order to match beatmap conversion / gameplay application semantics. + try + { + double rate = 1.0; + + for (int i = 0; i < mods.Length; i++) + { + if (mods[i] is IApplicableToRate applicableToRate) + rate = applicableToRate.ApplyToRate(0, rate); + } + + if (double.IsNaN(rate) || double.IsInfinity(rate) || rate <= 0) + return 1.0; + + return rate; + } + catch + { + return 1.0; + } + } + + private static int getKeyCountFromBeatmap(IBeatmap beatmap) + { + try + { + int maxColumn = beatmap.HitObjects.OfType().Select(h => h.Column).DefaultIfEmpty(-1).Max(); + return maxColumn + 1; + } + catch + { + return 0; + } + } + + private void updateTrackedBindables() + { + if (currentRuleset.Value == null) + return; + + lock (bindableUpdateLock) + { + cancelTrackedBindableUpdate(); + + foreach (var b in trackedBindables) + { + // 只重算仍“活跃”的 bindable:离屏/回收的面板会取消 token。 + // 这样可以确保计算预算优先服务当前可见内容。 + if (b.CancellationToken.IsCancellationRequested) + continue; + + var localBeatmapInfo = b.BeatmapInfo as BeatmapInfo; + if (localBeatmapInfo == null) + continue; + + var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken); + linkedCancellationSources.Add(linkedSource); + + updateBindable(b, localBeatmapInfo, currentRuleset.Value, currentMods.Value, linkedSource.Token); + } + } + } + + private void cancelTrackedBindableUpdate() + { + lock (bindableUpdateLock) + { + trackedUpdateCancellationSource.Cancel(); + trackedUpdateCancellationSource = new CancellationTokenSource(); + + foreach (var c in linkedCancellationSources) + c.Dispose(); + + linkedCancellationSources.Clear(); + } + } + + private void updateBindable(BindableManiaBeatmapAnalysis bindable, + BeatmapInfo beatmapInfo, + IRulesetInfo? rulesetInfo, + IEnumerable? mods, + CancellationToken cancellationToken = default, + int computationDelay = 0) + { + // If the bindable is already cancelled, do nothing. + if (cancellationToken.IsCancellationRequested) + return; + + // Request the analysis. Apply the result only when the task completes successfully. + _ = applyAsync(); + + async Task applyAsync() + { + try + { + var analysis = await GetAnalysisAsync(beatmapInfo, rulesetInfo, mods, cancellationToken, computationDelay).ConfigureAwait(false); + if (analysis == null) + return; + + Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + bindable.Value = analysis.Value; + }); + } + catch (OperationCanceledException) + { + // Ignore cancellations. + } + catch + { + // Ignore failures; they should not crash the UI. + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + modSettingChangeTracker?.Dispose(); + + cancelTrackedBindableUpdate(); + highPriorityScheduler.Dispose(); + lowPriorityScheduler.Dispose(); + highPriorityIdleEvent.Dispose(); + } + + private class BindableManiaBeatmapAnalysis : Bindable + { + public readonly IBeatmapInfo BeatmapInfo; + public readonly CancellationToken CancellationToken; + + public BindableManiaBeatmapAnalysis(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken) + { + BeatmapInfo = beatmapInfo; + CancellationToken = cancellationToken; + } + } + + private class PlayableCachedWorkingBeatmap : IWorkingBeatmap + { + private readonly IWorkingBeatmap working; + private IBeatmap? playable; + + public PlayableCachedWorkingBeatmap(IWorkingBeatmap working) + { + this.working = working; + } + + public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods) => playable ??= working.GetPlayableBeatmap(ruleset, mods); + + public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods, CancellationToken cancellationToken) => + playable ??= working.GetPlayableBeatmap(ruleset, mods, cancellationToken); + + IBeatmapInfo IWorkingBeatmap.BeatmapInfo => working.BeatmapInfo; + bool IWorkingBeatmap.BeatmapLoaded => working.BeatmapLoaded; + bool IWorkingBeatmap.TrackLoaded => working.TrackLoaded; + IBeatmap IWorkingBeatmap.Beatmap => working.Beatmap; + Texture IWorkingBeatmap.GetBackground() => working.GetBackground(); + Texture IWorkingBeatmap.GetPanelBackground() => working.GetPanelBackground(); + Waveform IWorkingBeatmap.Waveform => working.Waveform; + Storyboard IWorkingBeatmap.Storyboard => working.Storyboard; + ISkin IWorkingBeatmap.Skin => working.Skin; + Track IWorkingBeatmap.Track => working.Track; + Track IWorkingBeatmap.LoadTrack() => working.LoadTrack(); + Stream IWorkingBeatmap.GetStream(string storagePath) => working.GetStream(storagePath); + void IWorkingBeatmap.BeginAsyncLoad() => working.BeginAsyncLoad(); + void IWorkingBeatmap.CancelAsyncLoad() => working.CancelAsyncLoad(); + void IWorkingBeatmap.PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint) => working.PrepareTrackForPreview(looping, offsetFromPreviewPoint); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/EzBeatmapXxySrCache.cs b/osu.Game/LAsEzExtensions/Analysis/EzBeatmapXxySrCache.cs new file mode 100644 index 0000000000..47c8240383 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/EzBeatmapXxySrCache.cs @@ -0,0 +1,304 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Lists; +using osu.Framework.Logging; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + /// + /// 选歌面板用的 xxy_SR(Mania)缓存。 + /// - 计算入口在 osu.Game.Rulesets.Mania 程序集中,为避免循环依赖,这里通过反射调用。 + /// - 使用单线程 统一调度,避免拖动滚动条时同时触发大量重算。 + /// - 跟随 ruleset/mods 及 mod 设置变化自动更新已追踪的 bindable。 + /// + [Obsolete("已由 EzBeatmapManiaAnalysisCache 接管(统一缓存 KPS/KPC/Scratch/xxy_SR)。该类型仅保留为备份/回归对比用途,请不要在运行时再注入或使用。")] + public partial class EzBeatmapXxySrCache : MemoryCachingComponent + { + private const string logger_name = "xxy_sr"; + private const int mod_settings_debounce = 150; + + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(1, nameof(EzBeatmapXxySrCache)); + + private readonly WeakList trackedBindables = new WeakList(); + private readonly List linkedCancellationSources = new List(); + private readonly object bindableUpdateLock = new object(); + + private CancellationTokenSource trackedUpdateCancellationSource = new CancellationTokenSource(); + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private Bindable currentRuleset { get; set; } = null!; + + [Resolved] + private Bindable> currentMods { get; set; } = null!; + + private ModSettingChangeTracker? modSettingChangeTracker; + private ScheduledDelegate? debouncedModSettingsChange; + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentRuleset.BindValueChanged(_ => Scheduler.AddOnce(updateTrackedBindables)); + + currentMods.BindValueChanged(mods => + { + modSettingChangeTracker?.Dispose(); + + Scheduler.AddOnce(updateTrackedBindables); + + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += _ => + { + debouncedModSettingsChange?.Cancel(); + debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, mod_settings_debounce); + }; + }, true); + } + + protected override bool CacheNullValues => false; + + public IBindable GetBindableXxySr(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken = default, int computationDelay = 0) + { + var localBeatmapInfo = beatmapInfo as BeatmapInfo; + + var bindable = new BindableXxySr(beatmapInfo, cancellationToken) + { + Value = null + }; + + if (localBeatmapInfo == null) + return bindable; + + updateBindable(bindable, localBeatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken, computationDelay); + + lock (bindableUpdateLock) + trackedBindables.Add(bindable); + + return bindable; + } + + public Task GetXxySrAsync(IBeatmapInfo beatmapInfo, + IRulesetInfo? rulesetInfo = null, + IEnumerable? mods = null, + CancellationToken cancellationToken = default, + int computationDelay = 0) + { + var localBeatmapInfo = beatmapInfo as BeatmapInfo; + var localRulesetInfo = (rulesetInfo ?? beatmapInfo.Ruleset) as RulesetInfo; + + if (localBeatmapInfo == null || localRulesetInfo == null) + return Task.FromResult(null); + + return GetAsync(new XxySrCacheLookup(localBeatmapInfo, localRulesetInfo, mods), cancellationToken, computationDelay); + } + + protected override Task ComputeValueAsync(XxySrCacheLookup lookup, CancellationToken token = default) + { + return Task.Factory.StartNew(() => + { + if (CheckExists(lookup, out double? existing)) + return existing; + + return computeXxySr(lookup, token); + }, token, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + } + + private double? computeXxySr(in XxySrCacheLookup lookup, CancellationToken cancellationToken) + { + try + { + // 目前算法仅支持 mania。 + if (lookup.Ruleset.OnlineID != 3) + return null; + + var workingBeatmap = beatmapManager.GetWorkingBeatmap(lookup.BeatmapInfo); + + // 注意:playable beatmap 的内容取决于 mods。 + // 这里必须按当前 lookup.OrderedMods 获取,否则会导致“关 mod 后仍显示旧 SR”的问题。 + var playableBeatmap = workingBeatmap.GetPlayableBeatmap(lookup.Ruleset, lookup.OrderedMods, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + // 明显异常:如果 hitobjects 为空,仍计算出 SR 会导致离谱结果。 + // 这种情况更像是转换/加载/算法输入不对,直接记录并返回 null。 + if (playableBeatmap.HitObjects.Count == 0) + { + string mods = lookup.OrderedMods.Length == 0 ? "(none)" : string.Join(',', lookup.OrderedMods.Select(m => m.Acronym)); + Logger.Log($"xxy_SR aborted: playableBeatmap has 0 hitobjects. beatmapId={lookup.BeatmapInfo.ID} diff=\"{lookup.BeatmapInfo.DifficultyName}\" ruleset={lookup.Ruleset.ShortName} mods={mods}", logger_name, LogLevel.Error); + return null; + } + + if (!XxySrCalculatorBridge.TryCalculate(playableBeatmap, out double sr)) + return null; + + // Defensive: avoid propagating invalid values to UI. + if (double.IsNaN(sr) || double.IsInfinity(sr)) + { + Logger.Log($"xxy_SR returned invalid value (NaN/Infinity). beatmapId={lookup.BeatmapInfo.ID} ruleset={lookup.Ruleset.ShortName}", logger_name, LogLevel.Error); + return null; + } + + // "异常":出现极端偏差时记录(不记录正常计算)。 + if (sr < 0 || sr > 1000) + { + string mods = lookup.OrderedMods.Length == 0 ? "(none)" : string.Join(',', lookup.OrderedMods.Select(m => m.Acronym)); + Logger.Log($"xxy_SR abnormal value: {sr}. hitobjects={playableBeatmap.HitObjects.Count} beatmapId={lookup.BeatmapInfo.ID} diff=\"{lookup.BeatmapInfo.DifficultyName}\" ruleset={lookup.Ruleset.ShortName} mods={mods}", logger_name, LogLevel.Error); + } + + return sr; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + // 只记录异常:用于排查“值偏差非常大/计算失败导致空 pill”。 + string mods = lookup.OrderedMods.Length == 0 ? "(none)" : string.Join(',', lookup.OrderedMods.Select(m => m.Acronym)); + Logger.Error(ex, $"xxy_SR compute exception. beatmapId={lookup.BeatmapInfo.ID} diff=\"{lookup.BeatmapInfo.DifficultyName}\" ruleset={lookup.Ruleset.ShortName} mods={mods}", logger_name); + return null; + } + } + + private void updateTrackedBindables() + { + lock (bindableUpdateLock) + { + cancelTrackedBindableUpdate(); + + // 规则集变化到非 mania 时,不触发后台计算,并清空已显示的 SR,避免残留旧值。 + if (currentRuleset.Value.OnlineID != 3) + { + foreach (var b in trackedBindables) + Schedule(() => b.Value = null); + + return; + } + + foreach (var b in trackedBindables) + { + var localBeatmapInfo = b.BeatmapInfo as BeatmapInfo; + if (localBeatmapInfo == null) + continue; + + var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken); + linkedCancellationSources.Add(linkedSource); + + updateBindable(b, localBeatmapInfo, currentRuleset.Value, currentMods.Value, linkedSource.Token); + } + } + } + + private void cancelTrackedBindableUpdate() + { + lock (bindableUpdateLock) + { + trackedUpdateCancellationSource.Cancel(); + trackedUpdateCancellationSource = new CancellationTokenSource(); + + foreach (var c in linkedCancellationSources) + c.Dispose(); + + linkedCancellationSources.Clear(); + } + } + + private void updateBindable(BindableXxySr bindable, + BeatmapInfo beatmapInfo, + IRulesetInfo? rulesetInfo, + IEnumerable? mods, + CancellationToken cancellationToken = default, + int computationDelay = 0) + { + GetXxySrAsync(beatmapInfo, rulesetInfo, mods, cancellationToken, computationDelay) + .ContinueWith(task => + { + Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + bindable.Value = task.GetResultSafely(); + }); + }, cancellationToken); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + modSettingChangeTracker?.Dispose(); + + cancelTrackedBindableUpdate(); + updateScheduler.Dispose(); + } + + public readonly struct XxySrCacheLookup : IEquatable + { + public readonly BeatmapInfo BeatmapInfo; + public readonly RulesetInfo Ruleset; + public readonly Mod[] OrderedMods; + + public XxySrCacheLookup(BeatmapInfo beatmapInfo, RulesetInfo ruleset, IEnumerable? mods) + { + BeatmapInfo = beatmapInfo; + Ruleset = ruleset; + + // DeepClone 用于冻结 mod 设置快照,保证缓存 key 与显示一致。 + // IMPORTANT: mod application order matters for beatmap conversion. + // WorkingBeatmap.GetPlayableBeatmap() applies mods in the order provided. + // Do not reorder here (eg. by Acronym). + OrderedMods = mods?.Select(mod => mod.DeepClone()).ToArray() ?? Array.Empty(); + } + + public bool Equals(XxySrCacheLookup other) + => BeatmapInfo.Equals(other.BeatmapInfo) + && Ruleset.Equals(other.Ruleset) + && OrderedMods.SequenceEqual(other.OrderedMods); + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + hashCode.Add(BeatmapInfo.ID); + hashCode.Add(Ruleset.ShortName); + + foreach (var mod in OrderedMods) + hashCode.Add(mod); + + return hashCode.ToHashCode(); + } + } + + private class BindableXxySr : Bindable + { + public readonly IBeatmapInfo BeatmapInfo; + public readonly CancellationToken CancellationToken; + + public BindableXxySr(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken) + { + BeatmapInfo = beatmapInfo; + CancellationToken = cancellationToken; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/EzManiaAnalysisWarmupProcessor.cs b/osu.Game/LAsEzExtensions/Analysis/EzManiaAnalysisWarmupProcessor.cs new file mode 100644 index 0000000000..1a09db2e34 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/EzManiaAnalysisWarmupProcessor.cs @@ -0,0 +1,286 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.LAsEzExtensions.Analysis.Persistence; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Performance; +using osu.Game.Screens.Play; +using Realms; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + /// + /// 在启动阶段后台预热 mania analysis 缓存。 + /// 机制对齐官方 : + /// - 使用 展示进度并允许用户取消。 + /// - 游戏进行中 / 高性能会话期间自动 sleep,避免抢占。 + /// - 作为长任务在后台运行,不阻塞启动。 + /// + public partial class EzManiaAnalysisWarmupProcessor : Component + { + private const string logger_name = "mania_analysis"; + + protected Task ProcessingTask { get; private set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + [Resolved] + private EzBeatmapManiaAnalysisCache maniaAnalysisCache { get; set; } = null!; + + [Resolved] + private EzManiaAnalysisPersistentStore persistentStore { get; set; } = null!; + + [Resolved(CanBeNull = true)] + private INotificationOverlay? notificationOverlay { get; set; } + + [Resolved] + private ILocalUserPlayInfo? localUserPlayInfo { get; set; } + + [Resolved] + private IHighPerformanceSessionManager? highPerformanceSessionManager { get; set; } + + protected virtual int TimeToSleepDuringGameplay => 30000; + + protected override void LoadComplete() + { + base.LoadComplete(); + + ProcessingTask = Task.Factory.StartNew(populateManiaAnalysis, TaskCreationOptions.LongRunning).ContinueWith(t => + { + if (t.Exception?.InnerException is ObjectDisposedException) + { + Logger.Log("Finished mania analysis warmup aborted during shutdown"); + return; + } + + Logger.Log("Finished background mania analysis warmup!"); + }); + } + + private void populateManiaAnalysis() + { + if (!EzManiaAnalysisPersistentStore.Enabled) + { + Logger.Log("Mania analysis persistence is disabled; skipping warmup.", logger_name, LogLevel.Important); + return; + } + + List<(Guid id, string hash)> beatmaps = new List<(Guid id, string hash)>(); + + Logger.Log("Querying for mania beatmaps to warm up analysis cache...", logger_name, LogLevel.Important); + + realmAccess.Run(r => + { + int totalBeatmaps = 0; + int totalWithSet = 0; + int maniaTotal = 0; + int maniaWithSet = 0; + int maniaHidden = 0; + + Dictionary rulesetDistribution = new Dictionary(); + + // Align with official BackgroundDataStoreProcessor: don't exclude Hidden beatmaps here. + // Hidden beatmaps can still be attached to sets and may contribute to cache hit rates. + foreach (var b in r.All()) + { + totalBeatmaps++; + + bool isMania = b.Ruleset.OnlineID == 3 || string.Equals(b.Ruleset.ShortName, "mania", StringComparison.OrdinalIgnoreCase); + + if (isMania) + { + maniaTotal++; + if (b.Hidden) + maniaHidden++; + } + + if (totalBeatmaps <= 2000) + { + string key = $"{b.Ruleset.ShortName}:{b.Ruleset.OnlineID}"; + rulesetDistribution.TryGetValue(key, out int count); + rulesetDistribution[key] = count + 1; + } + + if (b.BeatmapSet == null) + continue; + + totalWithSet++; + + if (!isMania) + continue; + + maniaWithSet++; + beatmaps.Add((b.ID, b.Hash)); + } + + Logger.Log($"Warmup beatmap query summary: total={totalBeatmaps}, total_with_set={totalWithSet}, mania_total={maniaTotal}, mania_with_set={maniaWithSet}, mania_hidden={maniaHidden}", logger_name, LogLevel.Important); + + if (maniaTotal == 0) + { + string dist = string.Join(", ", rulesetDistribution.OrderByDescending(kvp => kvp.Value).Take(10).Select(kvp => $"{kvp.Key}={kvp.Value}")); + Logger.Log($"Warmup beatmap ruleset distribution (first 2000): {dist}", logger_name, LogLevel.Important); + } + }); + + if (beatmaps.Count == 0) + return; + + // 增量:只重算缺失/过期(hash/version 不匹配)的条目。 + // 与官方 star rating 进度通知一致:只在确实需要做“预计算”时展示一条 ProgressNotification。 + var needingRecompute = persistentStore.GetBeatmapsNeedingRecompute(beatmaps); + + if (needingRecompute.Count == 0) + { + Logger.Log("No beatmaps require mania analysis warmup.", logger_name, LogLevel.Important); + return; + } + + Logger.Log($"Found {needingRecompute.Count} beatmaps which require mania analysis warmup.", logger_name, LogLevel.Important); + + Logger.Log($"Starting mania analysis warmup. total={needingRecompute.Count}", logger_name, LogLevel.Important); + + var notification = showProgressNotification(needingRecompute.Count, "Precomputing mania analysis for beatmaps", "beatmaps' mania analysis has been precomputed"); + + int processedCount = 0; + int failedCount = 0; + + foreach (Guid id in needingRecompute) + { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, needingRecompute.Count); + + sleepIfRequired(); + + var beatmap = realmAccess.Run(r => r.Find(id)?.Detach()); + + if (beatmap == null) + { + ++failedCount; + continue; + } + + try + { + // 仅预热 no-mod 的缓存项:与官方 star 预计算一致(基础值持久化/复用),modded 部分仍按需计算。 + // 关键:warmup 绝不阻塞可见项。 + // - 等待当前所有高优先级(可见项)计算完成后再启动 warmup。 + // - 启用持久化时,仅预热 SQLite,不把所有结果长期留在内存缓存里。 + maniaAnalysisCache.WarmupPersistentOnlyAsync(beatmap, CancellationToken.None) + .GetAwaiter() + .GetResult(); + + ++processedCount; + } + catch (Exception e) + { + Logger.Log($"Background mania analysis warmup failed on {beatmap}: {e}", logger_name, LogLevel.Important); + ++failedCount; + } + + if (processedCount % 50 == 0) + ((IWorkingBeatmapCache)beatmapManager).Invalidate(beatmap); + } + + completeNotification(notification, processedCount, needingRecompute.Count, failedCount); + } + + private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount) + { + if (notification == null) + return; + + Schedule(() => + { + notification.Text = notification.Text.ToString().Split('(').First().TrimEnd() + $" ({processedCount} of {totalCount})"; + notification.Progress = (float)processedCount / totalCount; + }); + + if (processedCount % 100 == 0) + Logger.Log($"Warmup progress: {processedCount} of {totalCount}"); + } + + private void completeNotification(ProgressNotification? notification, int processedCount, int totalCount, int failedCount) + { + if (notification == null) + return; + + Schedule(() => + { + if (processedCount == totalCount) + { + notification.CompletionText = $"{processedCount} {notification.CompletionText}"; + notification.Progress = 1; + notification.State = ProgressNotificationState.Completed; + } + else + { + notification.Text = $"{processedCount} of {totalCount} {notification.CompletionText}"; + + if (failedCount > 0) + notification.Text += $" Check logs for issues with {failedCount} failed items."; + + notification.State = ProgressNotificationState.Cancelled; + } + }); + } + + private ProgressNotification? showProgressNotification(int totalCount, string running, string completed) + { + if (notificationOverlay == null) + { + Logger.Log("INotificationOverlay is null; mania analysis warmup progress notification will not be shown.", logger_name, LogLevel.Important); + return null; + } + + if (totalCount <= 0) + return null; + + ProgressNotification notification = new ProgressNotification + { + Text = running, + CompletionText = completed, + State = ProgressNotificationState.Active + }; + + Schedule(() => + { + try + { + notificationOverlay?.Post(notification); + Logger.Log("Posted mania analysis warmup progress notification.", logger_name, LogLevel.Important); + } + catch (Exception e) + { + Logger.Error(e, "Failed to post mania analysis warmup notification."); + } + }); + return notification; + } + + private void sleepIfRequired() + { + while (localUserPlayInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying || highPerformanceSessionManager?.IsSessionActive == true) + { + Logger.Log("Mania analysis warmup sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/EzScoreGraph.cs b/osu.Game/LAsEzExtensions/Analysis/EzScoreGraph.cs new file mode 100644 index 0000000000..848615c1d1 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/EzScoreGraph.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + /// + /// 创建每列偏移分布 + /// + public partial class CreateRotatedColumnGraphs : CompositeDrawable + { + private const float horizontal_spacing_ratio = 0.015f; + private const float top_margin = 20; + private const float bottom_margin = 10; + private const float horizontal_margin = 10; + + private readonly List> hitEventsByColumn; + private float lastDrawWidth = -1; + private float lastDrawHeight = -1; + + public CreateRotatedColumnGraphs(List> hitEventsByColumn) + { + this.hitEventsByColumn = hitEventsByColumn; + RelativeSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load() + { + if (hitEventsByColumn.Count == 0) + return; + + // 创建所有UI元素 + for (int i = 0; i < hitEventsByColumn.Count; i++) + { + var column = hitEventsByColumn[i]; + + // 添加标题 + AddInternal(new OsuSpriteText + { + Text = $"Column {column.Key + 1}", + Font = OsuFont.GetFont(size: 14), + Anchor = Anchor.TopLeft, + Origin = Anchor.TopCentre + }); + + // 添加图表 + AddInternal(new HitEventTimingDistributionGraph(column.ToList()) + { + RelativeSizeAxes = Axes.None, + Anchor = Anchor.TopLeft, + Origin = Anchor.BottomLeft, + Rotation = 90 + }); + } + + updateLayout(); + } + + protected override void Update() + { + base.Update(); + + if (InternalChildren.Count == 0 || DrawWidth <= 0) + return; + + if (DrawWidth != lastDrawWidth || DrawHeight != lastDrawHeight) + { + updateLayout(); + lastDrawWidth = DrawWidth; + lastDrawHeight = DrawHeight; + } + } + + private void updateLayout() + { + int columnCount = hitEventsByColumn.Count; + if (columnCount == 0) return; + + float effectiveWidth = DrawWidth - (2 * horizontal_margin); + float totalSpacingWidth = horizontal_spacing_ratio * (columnCount - 1); + float columnWidthRatio = (1f - totalSpacingWidth) / columnCount; + float xPosition = horizontal_margin; + + for (int i = 0; i < columnCount; i++) + { + float columnWidth = columnWidthRatio * effectiveWidth; + float spacingWidth = horizontal_spacing_ratio * effectiveWidth; + + int titleIndex = i * 2; + int graphIndex = i * 2 + 1; + + // 更新标题位置 + if (titleIndex < InternalChildren.Count && InternalChildren[titleIndex] is OsuSpriteText titleText) + { + titleText.X = xPosition + columnWidth / 2; + titleText.Y = 0; + } + + // 更新图表位置和尺寸 + if (graphIndex < InternalChildren.Count && InternalChildren[graphIndex] is HitEventTimingDistributionGraph graph) + { + graph.Width = DrawHeight - top_margin - bottom_margin; + graph.Height = columnWidth; + graph.X = xPosition; + graph.Y = top_margin; + } + + xPosition += columnWidth + spacingWidth; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/GirdPoints.cs b/osu.Game/LAsEzExtensions/Analysis/GirdPoints.cs new file mode 100644 index 0000000000..9eac45000f --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/GirdPoints.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + /// + /// Batch-draw a set of coloured points (as small quads) to avoid creating thousands of Drawables. + /// + public partial class GirdPoints : Drawable + { + private readonly List<(Vector2 pos, Color4 colour)> points = new List<(Vector2, Color4)>(); + private readonly float size; + + private Texture texture = null!; + private IShader shader = null!; + + public GirdPoints(float size = 2f) + { + this.size = size; + RelativeSizeAxes = Axes.None; + } + + [BackgroundDependencyLoader] + private void load(IRenderer renderer, ShaderManager shaders) + { + texture = renderer.WhitePixel; + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); + } + + public void SetPoints(IEnumerable<(Vector2 pos, Color4 colour)> newPoints) + { + points.Clear(); + points.AddRange(newPoints); + + Invalidate(Invalidation.DrawNode); + Invalidate(Invalidation.DrawInfo); + + Schedule(() => Invalidate(Invalidation.DrawNode)); + } + + protected override DrawNode CreateDrawNode() => new ScorePointsDrawNode(this); + + private class ScorePointsDrawNode : DrawNode + { + protected new GirdPoints Source => (GirdPoints)base.Source; + + private Texture texture = null!; + private IShader shader = null!; + private float size; + + private (Vector2 pos, Color4 colour)[] localPoints = Array.Empty<(Vector2, Color4)>(); + private int localCount; + + private IVertexBatch? quadBatch; + + public ScorePointsDrawNode(GirdPoints source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + texture = Source.texture; + shader = Source.shader; + size = Source.size; + + localCount = Source.points.Count; + + if (localPoints.Length < localCount) + localPoints = new (Vector2 pos, Color4 colour)[localCount]; + + Source.points.CopyTo(localPoints, 0); + } + + protected override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + if (localCount == 0) + return; + + shader.Bind(); + + if (!renderer.BindTexture(texture)) + return; + + const int max_quads = 10922; // renderer limit for quad batches + int total = localCount; + + renderer.PushLocalMatrix(DrawInfo.Matrix); + + quadBatch ??= renderer.CreateQuadBatch(Math.Min(total, max_quads), 4); + RectangleF textureRect = texture.GetTextureRect(); + Vector4 textureRectangle = new Vector4(0, 0, 1, 1); + Vector2 blendRange = Vector2.One; + + for (int offset = 0; offset < total; offset += max_quads) + { + int chunk = Math.Min(max_quads, total - offset); + if (quadBatch.Size < chunk && quadBatch.Size != IRenderer.MAX_QUADS) + quadBatch = renderer.CreateQuadBatch(Math.Min(quadBatch.Size * 2, max_quads), 4); + + var add = quadBatch.AddAction; + + for (int i = 0; i < chunk; i++) + { + var p = localPoints[offset + i]; + float half = size / 2f; + + Vector2 tl = new Vector2(p.pos.X - half, p.pos.Y - half); + Vector2 tr = new Vector2(p.pos.X + half, p.pos.Y - half); + Vector2 br = new Vector2(p.pos.X + half, p.pos.Y + half); + Vector2 bl = new Vector2(p.pos.X - half, p.pos.Y + half); + + add(new TexturedVertex2D(renderer) + { + Position = tl, + TexturePosition = textureRect.TopLeft, + TextureRect = textureRectangle, + BlendRange = blendRange, + Colour = p.colour + }); + add(new TexturedVertex2D(renderer) + { + Position = tr, + TexturePosition = textureRect.TopRight, + TextureRect = textureRectangle, + BlendRange = blendRange, + Colour = p.colour + }); + add(new TexturedVertex2D(renderer) + { + Position = br, + TexturePosition = textureRect.BottomRight, + TextureRect = textureRectangle, + BlendRange = blendRange, + Colour = p.colour + }); + add(new TexturedVertex2D(renderer) + { + Position = bl, + TexturePosition = textureRect.BottomLeft, + TextureRect = textureRectangle, + BlendRange = blendRange, + Colour = p.colour + }); + } + + quadBatch.Draw(); + } + + renderer.PopLocalMatrix(); + + shader.Unbind(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + quadBatch?.Dispose(); + quadBatch = null; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/HitEventTimingDistributionDot.cs b/osu.Game/LAsEzExtensions/Analysis/HitEventTimingDistributionDot.cs new file mode 100644 index 0000000000..0277350eba --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/HitEventTimingDistributionDot.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public partial class HitEventTimingDistributionDot : CompositeDrawable + { + private const int time_bins = 50; + + private const float circle_size = 5f; + + private readonly IReadOnlyList hitEvents; + + private double binSize; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private readonly HitWindows hitWindows; + + public HitEventTimingDistributionDot(IReadOnlyList hitEvents, HitWindows hitWindows) + { + this.hitEvents = hitEvents.Where(e => e.HitObject.HitWindows != HitWindows.Empty && e.Result.IsBasic() && e.Result.IsHit()).ToList(); + this.hitWindows = hitWindows; + } + + [BackgroundDependencyLoader] + private void load() + { + if (hitEvents.Count == 0) + return; + + binSize = Math.Ceiling(hitEvents.Max(e => e.HitObject.StartTime) / time_bins); + binSize = Math.Max(1, binSize); + + Scheduler.AddOnce(updateDisplay); + } + + private void updateDisplay() + { + ClearInternal(); + + foreach (HitResult result in Enum.GetValues(typeof(HitResult)).Cast()) + { + if (!result.IsBasic() || !result.IsHit()) + continue; + + double boundary = hitWindows.WindowFor(result); + + if (boundary <= 0) + continue; + + drawBoundaryLine(boundary, result); + drawBoundaryLine(-boundary, result); + } + + const float left_margin = 45; + const float right_margin = 50; + + foreach (var e in hitEvents) + { + double time = e.HitObject.StartTime; + float xPosition = (float)(time / (time_bins * binSize)); + float yPosition = (float)(e.TimeOffset); + + AddInternal(new Circle + { + Size = new Vector2(circle_size), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = (xPosition * (DrawWidth - left_margin - right_margin)) - (DrawWidth / 2) + left_margin, + Y = yPosition, + Alpha = 0.8f, + Colour = colours.ForHitResult(e.Result), + }); + } + } + + private void drawBoundaryLine(double boundary, HitResult result) + { + const float margin = 30; + + AddInternal(new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 2, + Width = 1 - (2 * margin / DrawWidth), + Alpha = 0.1f, + Colour = Color4.Gray, + }); + + AddInternal(new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 2, + Width = 1 - (2 * margin / DrawWidth), + Alpha = 0.1f, + Colour = colours.ForHitResult(result), + Y = (float)(boundary), + }); + + AddInternal(new OsuSpriteText + { + Text = $"{boundary:+0.##;-0.##}", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Font = OsuFont.GetFont(size: 14), + Colour = Color4.White, + X = 25, + Y = (float)(boundary), + }); + } + } + + public partial class EzJudgementsItem : SimpleStatisticItem + { + public EzJudgementsItem(string display, string name = "Count", ColourInfo? colour = null) + : base(name) + { + Value = display; + Colour = colour ?? Colour4.White; + } + + protected override LocalisableString DisplayValue(string? value) + { + return value ?? "N/A"; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/IHitEventGenerator.cs b/osu.Game/LAsEzExtensions/Analysis/IHitEventGenerator.cs new file mode 100644 index 0000000000..8f7fe0a029 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/IHitEventGenerator.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public interface IHitEventGenerator + { + List? Generate(Score score, IBeatmap playableBeatmap, CancellationToken cancellationToken = default); + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/ManiaAnalysisCacheLookup.cs b/osu.Game/LAsEzExtensions/Analysis/ManiaAnalysisCacheLookup.cs new file mode 100644 index 0000000000..d8d633301a --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/ManiaAnalysisCacheLookup.cs @@ -0,0 +1,143 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public readonly struct ManiaAnalysisCacheLookup : IEquatable + { + public readonly BeatmapInfo BeatmapInfo; + public readonly RulesetInfo Ruleset; + public readonly Mod[] OrderedMods; + public readonly int ModsSignature; + + private static int modSnapshotFailCount; + + public ManiaAnalysisCacheLookup(BeatmapInfo beatmapInfo, RulesetInfo ruleset, IEnumerable? mods) + { + BeatmapInfo = beatmapInfo; + Ruleset = ruleset; + // IMPORTANT: mod application order matters for beatmap conversion. + // WorkingBeatmap.GetPlayableBeatmap() applies mods in the order provided. + // Do not reorder here (eg. by Acronym), otherwise analysis may run on a different + // playable beatmap than gameplay, which can cause incorrect results or crashes. + OrderedMods = createModSnapshot(mods); + // IMPORTANT: some custom mods (notably many YuLiangSSS mods) lazily assign a random seed during ApplyToBeatmap + // (eg. Seed.Value ??= RNG.Next()). Because our cache key includes mod settings, such mutation would change + // Mod.GetHashCode()/Equals() during computation and corrupt dictionary usage. + // Pre-fill missing seeds deterministically on the cloned snapshot to keep cache keys stable. + initialiseDeterministicSeedsIfRequired(OrderedMods, beatmapInfo); + ModsSignature = computeModsSignature(OrderedMods); + } + + private static int computeModsSignature(Mod[] orderedMods) + { + unchecked + { + var hash = new HashCode(); + + // Include order. Order matters for conversion & gameplay. + for (int i = 0; i < orderedMods.Length; i++) + { + var mod = orderedMods[i]; + hash.Add(mod.GetType()); + + // Mirror Mod.GetHashCode() semantics but decouple from mod instance mutation after signature is computed. + // Only settings exposed via [SettingSource] are included. + foreach (var setting in mod.SettingsBindables) + hash.Add(setting.GetUnderlyingSettingValue()); + } + + return hash.ToHashCode(); + } + } + + private static void initialiseDeterministicSeedsIfRequired(Mod[] orderedMods, BeatmapInfo beatmapInfo) + { + if (orderedMods.Length == 0) + return; + + unchecked + { + // Base seed derived from beatmap identity. + int baseSeed = 17; + baseSeed = baseSeed * 31 + beatmapInfo.ID.GetHashCode(); + baseSeed = baseSeed * 31 + (beatmapInfo.Hash.GetHashCode(StringComparison.Ordinal)); + + for (int i = 0; i < orderedMods.Length; i++) + { + if (orderedMods[i] is not IHasSeed hasSeed) + continue; + + if (hasSeed.Seed.Value != null) + continue; + + // Mix in the mod type to avoid all seeded mods sharing the same seed. + int seed = baseSeed; + seed = seed * 31 + orderedMods[i].GetType().FullName!.GetHashCode(StringComparison.Ordinal); + seed = seed * 31 + i; + + // Ensure non-null. + if (seed == 0) + seed = 1; + + hasSeed.Seed.Value = seed; + } + } + } + + private static Mod[] createModSnapshot(IEnumerable? mods) + { + if (mods == null) + return Array.Empty(); + + var list = new List(); + + foreach (var mod in mods) + { + try + { + list.Add(mod.DeepClone()); + } + catch + { + // If cloning fails, fall back to using the original instance. + // This is not ideal for caching, but is better than breaking analysis entirely. + if (Interlocked.Increment(ref modSnapshotFailCount) <= 10) + Logger.Log($"[EzBeatmapManiaAnalysisCache] Mod.DeepClone() failed for {mod.GetType().FullName}. Falling back to original instance.", LoggingTarget.Runtime, LogLevel.Important); + + list.Add(mod); + } + } + + return list.ToArray(); + } + + public bool Equals(ManiaAnalysisCacheLookup other) => BeatmapInfo.ID.Equals(other.BeatmapInfo.ID) + && string.Equals(BeatmapInfo.Hash, other.BeatmapInfo.Hash, StringComparison.Ordinal) + && Ruleset.Equals(other.Ruleset) + && ModsSignature == other.ModsSignature; + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + hashCode.Add(BeatmapInfo.ID); + hashCode.Add(BeatmapInfo.Hash); + hashCode.Add(Ruleset.ShortName); + + // Use precomputed signature rather than mod instances to avoid key mutation during analysis. + hashCode.Add(ModsSignature); + + return hashCode.ToHashCode(); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/ManiaBeatmapAnalysisCache.cs b/osu.Game/LAsEzExtensions/Analysis/ManiaBeatmapAnalysisCache.cs new file mode 100644 index 0000000000..2ec5f7cdaa --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/ManiaBeatmapAnalysisCache.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public static class ManiaBeatmapAnalysisCache + { + // 选歌界面快速滚动/拖动时会瞬间触发大量面板 PrepareForUse。 + // 这里做三件事: + // 1) 全局共享缓存,避免同一谱面被多个面板重复解析/计算。 + // 2) 并发限流,避免线程池被打爆导致 UI 卡死。 + // 3) 同一 key 的计算去重(in-flight 去重)。 + private static readonly SemaphoreSlim concurrency_limiter = new SemaphoreSlim(2, 2); + + // LRU缓存:限制内存中最大存储N个谱面数据,新计算缓存替换旧缓存 + private const int max_cache_entries = 20; + private static readonly ConcurrentDictionary result_cache = new ConcurrentDictionary(); + private static readonly ConcurrentQueue access_order = new ConcurrentQueue(); + + // Lazy> 用于去重同一 key 的并发计算。 + private static readonly ConcurrentDictionary>> in_flight = + new ConcurrentDictionary>>(); + + public static string CreateCacheKey(BeatmapInfo beatmapInfo, RulesetInfo ruleset, IReadOnlyList mods) + => $"{beatmapInfo.Hash}_{ruleset.OnlineID}_{createModsKey(mods)}"; + + private static string createModsKey(IReadOnlyList mods) + { + // 不能只用 Acronym:很多 mod 有可调参数(例如自定义倍率/范围)。 + // 星级会随着设置变化而重算,因此这里也必须把设置纳入 key。 + // Mod.GetHashCode() 已包含 type + setting values。 + return string.Join(",", mods + .OrderBy(m => m.GetType().FullName, StringComparer.Ordinal) + .Select(m => $"{m.GetType().FullName}:{unchecked((uint)m.GetHashCode()):x8}")); + } + + public static bool TryGet(string cacheKey, out ManiaBeatmapAnalysisResult result) + { + if (result_cache.TryGetValue(cacheKey, out result)) + { + // 更新LRU访问顺序 + updateLRUAccess(cacheKey); + return true; + } + + return false; + } + + /// + /// 更新LRU访问顺序,将指定key移到队列末尾(最近访问) + /// + private static void updateLRUAccess(string cacheKey) + { + // 注意:ConcurrentQueue不支持移除中间元素,这里我们简单地将key重新入队 + // 这样可能会导致同一个key在队列中出现多次,但这不会影响LRU逻辑的正确性 + access_order.Enqueue(cacheKey); + } + + /// + /// 确保缓存大小不超过限制,移除最老的条目 + /// + private static void ensureCacheSize() + { + while (result_cache.Count > max_cache_entries && access_order.TryDequeue(out string? oldestKey)) + { + // 移除最老的缓存条目 + result_cache.TryRemove(oldestKey, out _); + } + } + + public static Task GetOrComputeAsync(BeatmapManager beatmapManager, + BeatmapInfo beatmapInfo, + RulesetInfo ruleset, + IReadOnlyList mods, + int keyCount) + { + string cacheKey = CreateCacheKey(beatmapInfo, ruleset, mods); + + if (TryGet(cacheKey, out var cached)) + return Task.FromResult(cached); + + var lazyTask = in_flight.GetOrAdd(cacheKey, _ => new Lazy>(() => computeAsync( + beatmapManager, + beatmapInfo, + ruleset, + mods, + keyCount, + cacheKey))); + + return lazyTask.Value; + } + + private static async Task computeAsync(BeatmapManager beatmapManager, + BeatmapInfo beatmapInfo, + RulesetInfo ruleset, + IReadOnlyList mods, + int keyCount, + string cacheKey) + { + bool acquired = false; + + try + { + // 这里不要使用面板的 CancellationToken: + // 同一 key 的计算可能被多个面板共享,单个面板被回收/取消 不应导致共享计算被取消, + // 否则会出现“某次滑动取消后,这张谱面永远算不出来”的问题。 + await concurrency_limiter.WaitAsync(CancellationToken.None).ConfigureAwait(false); + acquired = true; + + var workingBeatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo); + var playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset, mods, CancellationToken.None); + + var (averageKps, maxKps, kpsList, columnCounts, holdNoteCounts) = OptimizedBeatmapCalculator.GetAllDataOptimized(playableBeatmap); + + double? xxySr = null; + + if (playableBeatmap.HitObjects.Count == 0) + { + string modsStr = mods.Count == 0 ? "(none)" : string.Join(',', mods.Select(m => m.Acronym)); + Logger.Log($"xxy_SR aborted: playableBeatmap has 0 hitobjects. beatmapId={beatmapInfo.ID} diff=\"{beatmapInfo.DifficultyName}\" ruleset={ruleset.ShortName} mods={modsStr}", "xxy_sr", LogLevel.Error); + } + else if (XxySrCalculatorBridge.TryCalculate(playableBeatmap, out double sr)) + { + if (double.IsNaN(sr) || double.IsInfinity(sr)) + { + Logger.Log($"xxy_SR returned invalid value (NaN/Infinity). beatmapId={beatmapInfo.ID} ruleset={ruleset.ShortName}", "xxy_sr", LogLevel.Error); + } + else + { + xxySr = sr; + + if (sr < 0 || sr > 1000) + { + string modsStr = mods.Count == 0 ? "(none)" : string.Join(',', mods.Select(m => m.Acronym)); + Logger.Log($"xxy_SR abnormal value: {sr}. hitobjects={playableBeatmap.HitObjects.Count} beatmapId={beatmapInfo.ID} diff=\"{beatmapInfo.DifficultyName}\" ruleset={ruleset.ShortName} mods={modsStr}", "xxy_sr", LogLevel.Error); + } + } + } + + // 复用已算出的 columnCounts/kpsList,避免 GetScratch() 再次遍历/计算。 + string scratchText = EzBeatmapCalculator.GetScratchFromPrecomputed(columnCounts, maxKps, kpsList, keyCount); + + var result = new ManiaBeatmapAnalysisResult( + averageKps, + maxKps, + kpsList, + columnCounts, + holdNoteCounts, + scratchText, + xxySr); + + // 添加到缓存并确保大小限制 + result_cache[cacheKey] = result; + updateLRUAccess(cacheKey); + ensureCacheSize(); + + return result; + } + finally + { + if (acquired) + concurrency_limiter.Release(); + + // 清理 in-flight,避免字典无限增长。 + in_flight.TryRemove(cacheKey, out _); + } + } + } + + public readonly record struct ManiaBeatmapAnalysisResult(double AverageKps, + double MaxKps, + List KpsList, + Dictionary ColumnCounts, + Dictionary HoldNoteCounts, + string ScratchText, + double? XxySr); + + public static class ManiaBeatmapAnalysisDefaults + { + public static readonly ManiaBeatmapAnalysisResult EMPTY = + new ManiaBeatmapAnalysisResult( + 0, + 0, + new List(), + new Dictionary(), + new Dictionary(), + string.Empty, + null); + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/OptimizedBeatmapCalculator.cs b/osu.Game/LAsEzExtensions/Analysis/OptimizedBeatmapCalculator.cs new file mode 100644 index 0000000000..89fb9b1eab --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/OptimizedBeatmapCalculator.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + public static class OptimizedBeatmapCalculator + { + public const int DEFAULT_KPS_GRAPH_POINTS = 256; + + public static List DownsampleToFixedCount(IReadOnlyList source, int targetCount) + { + if (source.Count == 0) + return new List(); + + if (targetCount <= 0) + return new List(); + + if (source.Count <= targetCount) + return source as List ?? source.ToList(); + + // Uniform re-sampling by index. Keeps endpoints stable. + var result = new List(targetCount); + + if (targetCount == 1) + { + result.Add(source[0]); + return result; + } + + int lastIndex = source.Count - 1; + + for (int i = 0; i < targetCount; i++) + { + int index = (int)((long)i * lastIndex / (targetCount - 1)); + result.Add(source[index]); + } + + return result; + } + + /// + /// 高性能KPS计算,结合了缓存和优化的算法 + /// + public static (double averageKps, double maxKps, List kpsList) GetKpsOptimized(IBeatmap beatmap) + { + var hitObjects = beatmap.HitObjects; + if (hitObjects.Count == 0) + return (0, 0, new List()); + + // 使用更高效的interval计算 + double bpm = beatmap.BeatmapInfo.BPM; + double interval = 240000.0 / bpm; // 4拍的时间间隔(毫秒) + double songEndTime = hitObjects[^1].StartTime; + + // 预分配List容量以避免频繁扩容 + int estimatedIntervals = (int)((songEndTime / interval) + 1); + var kpsList = new List(estimatedIntervals); + + // 缓存hitObjects数组以提高访问性能 + var hitObjectsArray = hitObjects as HitObject[] ?? hitObjects.ToArray(); + + double currentTime = 0; + int currentIndex = 0; + + while (currentTime < songEndTime) + { + double endTime = currentTime + interval; + + // 优化的区间计算:从上一个位置开始搜索 + int startIdx = currentIndex; + while (startIdx < hitObjectsArray.Length && hitObjectsArray[startIdx].StartTime < currentTime) + startIdx++; + + int endIdx = startIdx; + while (endIdx < hitObjectsArray.Length && hitObjectsArray[endIdx].StartTime < endTime) + endIdx++; + + int hits = endIdx - startIdx; + double kps = hits / (interval / 1000.0); // 转换为每秒 + kpsList.Add(kps); + + currentTime += interval; + currentIndex = startIdx; // 下次从这个位置开始 + } + + if (kpsList.Count == 0) + return (0, 0, kpsList); + + // 使用LINQ的高性能版本 + double average = kpsList.Sum() / kpsList.Count; + double max = kpsList.Max(); + + return (average, max, kpsList); + } + + /// + /// 高性能列音符计数,使用数组代替Dictionary以提高性能 + /// + public static Dictionary GetColumnNoteCountsOptimized(IBeatmap beatmap) + { + // 预过滤非Duration类型的Column对象 + var columnObjects = new List(beatmap.HitObjects.Count); + + foreach (var obj in beatmap.HitObjects) + { + if (obj is IHasColumn columnObj && !(obj is IHasDuration)) + { + columnObjects.Add(columnObj); + } + } + + if (columnObjects.Count == 0) + return new Dictionary(); + + // 找出列的范围以使用数组优化 + int minColumn = columnObjects[0].Column; + int maxColumn = minColumn; + + for (int i = 1; i < columnObjects.Count; i++) + { + int column = columnObjects[i].Column; + if (column < minColumn) minColumn = column; + if (column > maxColumn) maxColumn = column; + } + + int columnRange = maxColumn - minColumn + 1; + + // 如果列范围较小,使用数组;否则使用Dictionary + if (columnRange <= 32) // 合理的阈值 + { + int[] countsArray = new int[columnRange]; + + foreach (var obj in columnObjects) + { + countsArray[obj.Column - minColumn]++; + } + + var result = new Dictionary(); + + for (int i = 0; i < columnRange; i++) + { + if (countsArray[i] > 0) + { + result[i + minColumn] = countsArray[i]; + } + } + + return result; + } + else + { + // 列范围太大,使用Dictionary + var counts = new Dictionary(); + + foreach (var obj in columnObjects) + { + counts[obj.Column] = counts.GetValueOrDefault(obj.Column) + 1; + } + + return counts; + } + } + + /// + /// 快速仅统计列计数与长按计数(单次遍历,避免 KPS 相关开销)。 + /// + public static (Dictionary columnCounts, Dictionary holdNoteCounts) GetCountsOnly(IBeatmap beatmap) + { + var columnCounts = new Dictionary(); + var holdNoteCounts = new Dictionary(); + + foreach (var obj in beatmap.HitObjects) + { + if (obj is IHasColumn columnObj) + { + columnCounts[columnObj.Column] = columnCounts.GetValueOrDefault(columnObj.Column) + 1; + if (obj is IHasDuration) + holdNoteCounts[columnObj.Column] = holdNoteCounts.GetValueOrDefault(columnObj.Column) + 1; + } + } + + return (columnCounts, holdNoteCounts); + } + + /// + /// 计算粗略 KPS 曲线并返回平均/最大值。使用固定较少的桶(bucket)以快速得到可展示的基线数据。 + /// + public static (double averageKps, double maxKps, List kpsList) GetKpsCoarse(IBeatmap beatmap, int buckets = 64) + { + var hitObjects = beatmap.HitObjects; + if (hitObjects.Count == 0) + return (0, 0, new List()); + + double songStart = hitObjects[0].StartTime; + double songEnd = hitObjects[^1].StartTime; + double duration = Math.Max(1, songEnd - songStart); + + long[] bucketsCounts = new long[buckets]; + + foreach (var obj in hitObjects) + { + int idx = (int)((obj.StartTime - songStart) * buckets / duration); + if (idx < 0) idx = 0; + if (idx >= buckets) idx = buckets - 1; + bucketsCounts[idx]++; + } + + var kpsList = new List(buckets); + double intervalMs = duration / buckets; + for (int i = 0; i < buckets; i++) + kpsList.Add(bucketsCounts[i] / (intervalMs / 1000.0)); + + double avg = kpsList.Sum() / kpsList.Count; + double max = kpsList.Max(); + + return (avg, max, kpsList); + } + + /// + /// 一次性计算所有需要的数据,避免重复遍历 + /// + public static ( + double averageKps, + double maxKps, + List kpsList, + Dictionary columnCounts, + Dictionary holdNoteCounts + ) GetAllDataOptimized(IBeatmap beatmap) + { + var hitObjects = beatmap.HitObjects; + if (hitObjects.Count == 0) + return (0, 0, new List(), new Dictionary(), new Dictionary()); + + // 一次遍历完成KPS和列统计 + double bpm = beatmap.BeatmapInfo.BPM; + double interval = 240000.0 / bpm; + double songEndTime = hitObjects[^1].StartTime; + + int estimatedIntervals = (int)((songEndTime / interval) + 1); + var kpsList = new List(estimatedIntervals); + var columnCounts = new Dictionary(); + var holdNoteCounts = new Dictionary(); + + // 同时处理KPS和列统计 + var hitObjectsArray = hitObjects as HitObject[] ?? hitObjects.ToArray(); + + // 预处理列统计 + foreach (var obj in hitObjectsArray) + { + if (obj is IHasColumn columnObj) + { + // 统计所有note(包括普通note和长按note) + columnCounts[columnObj.Column] = columnCounts.GetValueOrDefault(columnObj.Column) + 1; + + // 单独统计长按note + if (obj is IHasDuration) + { + holdNoteCounts[columnObj.Column] = holdNoteCounts.GetValueOrDefault(columnObj.Column) + 1; + } + } + } + + // KPS计算 + double currentTime = 0; + int currentIndex = 0; + + while (currentTime < songEndTime) + { + double endTime = currentTime + interval; + + int startIdx = currentIndex; + while (startIdx < hitObjectsArray.Length && hitObjectsArray[startIdx].StartTime < currentTime) + startIdx++; + + int endIdx = startIdx; + while (endIdx < hitObjectsArray.Length && hitObjectsArray[endIdx].StartTime < endTime) + endIdx++; + + int hits = endIdx - startIdx; + kpsList.Add(hits / (interval / 1000.0)); + + currentTime += interval; + currentIndex = startIdx; + } + + if (kpsList.Count == 0) + return (0, 0, kpsList, columnCounts, holdNoteCounts); + + double average = kpsList.Sum() / kpsList.Count; + double max = kpsList.Max(); + + return (average, max, kpsList, columnCounts, holdNoteCounts); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/Persistence/EzManiaAnalysisPersistentStore.cs b/osu.Game/LAsEzExtensions/Analysis/Persistence/EzManiaAnalysisPersistentStore.cs new file mode 100644 index 0000000000..0807cc65c8 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/Persistence/EzManiaAnalysisPersistentStore.cs @@ -0,0 +1,896 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Beatmaps; + +namespace osu.Game.LAsEzExtensions.Analysis.Persistence +{ + /// + /// 本地持久化的 mania analysis 存储。 + /// + /// 目标:对齐官方“有版本号、可增量补齐”的后台预处理体验,但不写回主谱面库的 Realm(client.realm), + /// 以降低误操作/迁移导致主库损坏的风险。 + /// + /// 存储键:BeatmapInfo.ID(Guid)+ BeatmapInfo.Hash(SHA-256)。 + /// - 只要 beatmap 内容变化(Hash 变化),对应条目会自动失效并重算。 + /// - AnalysisVersion 用于你这边算法变更时整体失效。 + /// + /// 注意:此处使用 SQLite(而不是额外 Realm 文件),因为向 osu.Game 程序集新增 RealmObject 类型 + /// 会改变 client.realm 的 schema 并要求迁移;而 SQLite 独立文件更安全、易恢复。 + /// + public class EzManiaAnalysisPersistentStore + { + /// + /// 持久化总开关(默认关闭):未来考虑是否允许用户通过配置关闭此功能以避免额外的磁盘读写。 + /// + public static bool Enabled = true; + + public static readonly string DATABASE_FILENAME = $@"mania-analysis_v{ANALYSIS_VERSION}.sqlite"; + + // 手动维护:算法/序列化格式变更时递增。版本发生变化时,会强制重算所有已存条目。 + // 注意:此版本号与 osu! 官方服务器端的版本号无关,仅用于本地持久化存储的失效控制。 + // 注意:更新版本号后,务必通过注释保存旧版本的变更记录,方便日后排查问题。 + // v2: 初始版本,包含 kps_list_json, column_counts_json + // v3: 添加 hold_note_counts_json 字段,分离普通note和长按note统计 + // v4: 添加 beatmap_md5 校验字段;kps_list_json 仅保存用于 UI 的下采样曲线(<=256 点)。 + // v5: 删除scratchText存储,改为动态计算。数据库可兼容,不升版。 + public const int ANALYSIS_VERSION = 5; + + private static readonly string[] allowed_columns = + { + "beatmap_id", + "beatmap_hash", + "beatmap_md5", + "analysis_version", + "average_kps", + "max_kps", + "kps_list_json", + "xxy_sr", + "column_counts_json", + "hold_note_counts_json" + }; + + private readonly Storage storage; + private readonly object initLock = new object(); + + private bool initialised; + private string dbPath = string.Empty; + + // Old versions earlier than v3 may not have sufficient data to safely upgrade without recomputation. + // v3 introduced hold note counts, which are relied upon by parts of the UI. + private const int min_inplace_upgrade_version = 3; + + public EzManiaAnalysisPersistentStore(Storage storage) + { + this.storage = storage; + } + + public void Initialise() + { + if (!Enabled) + return; + + lock (initLock) + { + if (initialised) + return; + + dbPath = storage.GetFullPath(DATABASE_FILENAME, true); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + + // If this is a new versioned DB file, attempt to clone from the latest previous version to avoid + // forcing a full recompute (when changes are only schema/serialization related). + tryClonePreviousDatabaseIfMissing(); + + Logger.Log($"EzManiaAnalysisPersistentStore path: {dbPath}", "mania_analysis", LogLevel.Important); + + using var connection = openConnection(); + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @" +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; +PRAGMA temp_store=MEMORY; +"; + cmd.ExecuteNonQuery(); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @" +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mania_analysis ( + beatmap_id TEXT PRIMARY KEY, + beatmap_hash TEXT NOT NULL, + beatmap_md5 TEXT NOT NULL, + analysis_version INTEGER NOT NULL, + average_kps REAL NOT NULL, + max_kps REAL NOT NULL, + kps_list_json TEXT NOT NULL, + xxy_sr REAL NULL, + column_counts_json TEXT NOT NULL, + hold_note_counts_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_mania_analysis_version ON mania_analysis(analysis_version); +"; + cmd.ExecuteNonQuery(); + } + + // 从旧版本平滑升级:如果缺少列则补齐(SQLite 不支持 IF NOT EXISTS 语法的 ADD COLUMN)。 + if (!hasColumn(connection, "mania_analysis", "kps_list_json")) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE mania_analysis ADD COLUMN kps_list_json TEXT NOT NULL DEFAULT '[]';"; + cmd.ExecuteNonQuery(); + } + + if (!hasColumn(connection, "mania_analysis", "hold_note_counts_json")) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE mania_analysis ADD COLUMN hold_note_counts_json TEXT NOT NULL DEFAULT '{}';"; + cmd.ExecuteNonQuery(); + } + + if (!hasColumn(connection, "mania_analysis", "beatmap_md5")) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE mania_analysis ADD COLUMN beatmap_md5 TEXT NOT NULL DEFAULT '';"; + cmd.ExecuteNonQuery(); + } + + // Store the current analysis version as meta (informational). + setMeta(connection, "analysis_version", ANALYSIS_VERSION.ToString(CultureInfo.InvariantCulture)); + + // 检查并清理不需要的列(处理版本升级时删除的字段) + cleanupUnrecognizedColumns(connection); + + initialised = true; + } + catch (Exception e) + { + // 如果数据库损坏/无法打开:不影响游戏运行;尝试备份并重新创建。 + Logger.Error(e, "EzManiaAnalysisPersistentStore failed to initialise; recreating database."); + + try + { + if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) + { + string backup = dbPath + ".bak"; + File.Copy(dbPath, backup, overwrite: true); + File.Delete(dbPath); + } + } + catch + { + // ignored + } + + // Second attempt. + initialised = false; + Initialise(); + } + } + } + + public bool TryGet(BeatmapInfo beatmap, out ManiaBeatmapAnalysisResult result) + { + result = ManiaBeatmapAnalysisDefaults.EMPTY; + // missingRequiredXxySr = false; + + if (!Enabled) + return false; + + try + { + Initialise(); + + using var connection = openConnection(); + + string storedHash; + string storedMd5; + int storedVersion; + double averageKps; + double maxKps; + string kpsListJson; + double? xxySr; + string columnCountsJson; + string holdNoteCountsJson; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @" +SELECT beatmap_hash, beatmap_md5, analysis_version, average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json +FROM mania_analysis +WHERE beatmap_id = $id +LIMIT 1; +"; + cmd.Parameters.AddWithValue("$id", beatmap.ID.ToString()); + + using var reader = cmd.ExecuteReader(); + + if (!reader.Read()) + return false; + + storedHash = reader.GetString(0); + storedMd5 = reader.GetString(1); + storedVersion = reader.GetInt32(2); + averageKps = reader.GetDouble(3); + maxKps = reader.GetDouble(4); + kpsListJson = reader.GetString(5); + xxySr = reader.IsDBNull(6) ? null : reader.GetDouble(6); + columnCountsJson = reader.GetString(7); + holdNoteCountsJson = reader.GetString(8); + } + + if (!string.Equals(storedHash, beatmap.Hash, StringComparison.Ordinal)) + { + Logger.Log($"[EzManiaAnalysisPersistentStore] stored_hash mismatch for {beatmap.ID}: stored={storedHash} runtime={beatmap.Hash}", "mania_analysis", LogLevel.Debug); + return false; + } + + // md5 validation: + // - If stored md5 is empty (older versions), accept hash match and upgrade in-place. + // - If stored md5 is present, require it to match. + if (!string.IsNullOrEmpty(storedMd5) && !string.Equals(storedMd5, beatmap.MD5Hash, StringComparison.Ordinal)) + { + Logger.Log($"[EzManiaAnalysisPersistentStore] stored_md5 mismatch for {beatmap.ID}: stored={storedMd5} runtime={beatmap.MD5Hash}", "mania_analysis", LogLevel.Debug); + return false; + } + + // If the stored version is newer than this build, ignore and let caller recompute. + if (storedVersion > ANALYSIS_VERSION) + return false; + + var columnCounts = JsonSerializer.Deserialize>(columnCountsJson) ?? new Dictionary(); + var holdNoteCounts = JsonSerializer.Deserialize>(holdNoteCountsJson) ?? new Dictionary(); + var kpsList = JsonSerializer.Deserialize>(kpsListJson) ?? new List(); + + // Allow in-place upgrade of compatible older entries to avoid full recompute. + // If an older version is not compatible, treat it as a miss. + if (storedVersion != ANALYSIS_VERSION) + { + if (!canUpgradeInPlace(storedVersion)) + return false; + + bool mutated = string.IsNullOrEmpty(storedMd5); + + // v4: store md5 for extra safety (hash already guards real content). + + // v4: kps_list_json is UI graph only; keep it capped for perf. + if (kpsList.Count > OptimizedBeatmapCalculator.DEFAULT_KPS_GRAPH_POINTS) + { + kpsList = OptimizedBeatmapCalculator.DownsampleToFixedCount(kpsList, OptimizedBeatmapCalculator.DEFAULT_KPS_GRAPH_POINTS); + mutated = true; + } + + if (mutated) + { + // Persist the upgraded row (without recomputing analysis). + writeUpgradedRow(connection, beatmap, averageKps, maxKps, kpsList, xxySr, columnCounts, holdNoteCounts); + } + } + + // Compute scratchText since it's not stored + int keyCount = columnCounts.Count; + string computedScratchText = EzBeatmapCalculator.GetScratchFromPrecomputed(columnCounts, maxKps, kpsList, keyCount); + + result = new ManiaBeatmapAnalysisResult( + averageKps, + maxKps, + kpsList, + columnCounts, + holdNoteCounts, + computedScratchText, + xxySr); + + // Validate the analysis result to ensure it's reasonable + if (!isValidAnalysisResult(result)) + { + Logger.Log($"[EzManiaAnalysisPersistentStore] Invalid analysis result for {beatmap.ID}, ignoring cached data.", "mania_analysis", LogLevel.Debug); + return false; + } + + // missingRequiredXxySr = requireXxySr && xxySr == null; + + return true; + } + catch (Exception e) + { + Logger.Error(e, "EzManiaAnalysisPersistentStore TryGet failed."); + return false; + } + } + + /// + /// 对比新计算结果和 SQLite 中的旧数据,如果有差异则更新。 + /// 主要场景: + /// - xxysr 从 null 补算成有值(mania 模式的谱面被重新计算) + /// - KPS 数据有显著变化(算法修复等) + /// 工作机制: + /// - 如果 stored 数据不存在,直接存储新数据 + /// - 如果 stored xxysr == null 而 computed 有值,说明需要补充 xxysr,更新 + /// - 如果都是 xxysr == null,说明是非 mania 模式数据,比较 KPS 数据是否相同 + /// + public void StoreIfDifferent(BeatmapInfo beatmap, ManiaBeatmapAnalysisResult analysis) + { + if (!Enabled) + return; + + // Validate the analysis result before storing + if (!isValidAnalysisResult(analysis)) + { + Logger.Log($"[EzManiaAnalysisPersistentStore] Refusing to store invalid analysis result for {beatmap.ID}", "mania_analysis", LogLevel.Debug); + return; + } + + // 跳过空谱面(no notes)- 不需要存储和管理 + if (analysis.ColumnCounts.Count == 0) + return; + + try + { + Initialise(); + + using var connection = openConnection(); + + // 尝试从 SQLite 读取旧数据 + if (!tryGetRawData(connection, beatmap, out var storedAnalysis)) + { + // 缓存不存在,直接存储 + Store(beatmap, analysis); + return; + } + + // 对比两个结果是否有差异 + if (hasDifference(storedAnalysis, analysis)) + { + Logger.Log($"[EzManiaAnalysisPersistentStore] Data difference detected for {beatmap.ID}, updating SQLite.", "mania_analysis", LogLevel.Debug); + Store(beatmap, analysis); + } + } + catch (Exception e) + { + Logger.Error(e, "EzManiaAnalysisPersistentStore StoreIfDifferent failed."); + } + } + + /// + /// 从数据库读取原始数据(不验证 hash/version)。 + /// + private bool tryGetRawData(SqliteConnection connection, BeatmapInfo beatmap, out ManiaBeatmapAnalysisResult result) + { + result = ManiaBeatmapAnalysisDefaults.EMPTY; + + try + { + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @" +SELECT average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json +FROM mania_analysis +WHERE beatmap_id = $id +LIMIT 1; +"; + cmd.Parameters.AddWithValue("$id", beatmap.ID.ToString()); + + using var reader = cmd.ExecuteReader(); + + if (!reader.Read()) + return false; + + double averageKps = reader.GetDouble(0); + double maxKps = reader.GetDouble(1); + string kpsListJson = reader.GetString(2); + double? xxySr = reader.IsDBNull(3) ? null : reader.GetDouble(3); + string columnCountsJson = reader.GetString(4); + string holdNoteCountsJson = reader.GetString(5); + + var columnCounts = JsonSerializer.Deserialize>(columnCountsJson) ?? new Dictionary(); + var holdNoteCounts = JsonSerializer.Deserialize>(holdNoteCountsJson) ?? new Dictionary(); + var kpsList = JsonSerializer.Deserialize>(kpsListJson) ?? new List(); + + int keyCount = columnCounts.Count; + string scratchText = EzBeatmapCalculator.GetScratchFromPrecomputed(columnCounts, maxKps, kpsList, keyCount); + + result = new ManiaBeatmapAnalysisResult( + averageKps, + maxKps, + kpsList, + columnCounts, + holdNoteCounts, + scratchText, + xxySr); + + return true; + } + } + catch + { + return false; + } + } + + /// + /// 比较两个分析结果是否有差异。 + /// 关键字段:xxysr, averageKps, maxKps, ColumnCounts, HoldNoteCounts + /// + private bool hasDifference(ManiaBeatmapAnalysisResult stored, ManiaBeatmapAnalysisResult computed) + { + // 检查 xxysr 差异(最重要) + // 如果 stored 是 null 而 computed 有值,必须更新 + if (!stored.XxySr.HasValue && computed.XxySr.HasValue) + return true; + + // 如果都有值,比较数值是否相同 + if (stored.XxySr.HasValue && computed.XxySr.HasValue) + { + if (!stored.XxySr.Value.Equals(computed.XxySr.Value)) + return true; + } + + // 检查 KPS 相关数据 + if (!stored.AverageKps.Equals(computed.AverageKps) || !stored.MaxKps.Equals(computed.MaxKps)) + return true; + + // 检查列统计 + if (stored.ColumnCounts.Count != computed.ColumnCounts.Count) + return true; + + foreach (var kvp in computed.ColumnCounts) + { + if (!stored.ColumnCounts.TryGetValue(kvp.Key, out int storedCount) || storedCount != kvp.Value) + return true; + } + + // 检查长按统计 + if (stored.HoldNoteCounts.Count != computed.HoldNoteCounts.Count) + return true; + + foreach (var kvp in computed.HoldNoteCounts) + { + if (!stored.HoldNoteCounts.TryGetValue(kvp.Key, out int storedCount) || storedCount != kvp.Value) + return true; + } + + return false; + } + + public void Store(BeatmapInfo beatmap, ManiaBeatmapAnalysisResult analysis) + { + if (!Enabled) + return; + + // Validate the analysis result before storing + if (!isValidAnalysisResult(analysis)) + { + Logger.Log($"[EzManiaAnalysisPersistentStore] Refusing to store invalid analysis result for {beatmap.ID}", "mania_analysis", LogLevel.Debug); + return; + } + + // 跳过空谱面(no notes)- 不需要存储和管理 + if (analysis.ColumnCounts.Count == 0) + return; + + try + { + Initialise(); + + using var connection = openConnection(); + using var cmd = connection.CreateCommand(); + + string kpsListJson = JsonSerializer.Serialize(analysis.KpsList); + string columnCountsJson = JsonSerializer.Serialize(analysis.ColumnCounts); + string holdNoteCountsJson = JsonSerializer.Serialize(analysis.HoldNoteCounts); + + cmd.CommandText = @" +INSERT INTO mania_analysis( + beatmap_id, + beatmap_hash, + beatmap_md5, + analysis_version, + average_kps, + max_kps, + kps_list_json, + xxy_sr, + column_counts_json, + hold_note_counts_json +) +VALUES( + $id, + $hash, + $md5, + $version, + $avg, + $max, + $kps, + $xxy, + $cols, + $holds +) +ON CONFLICT(beatmap_id) DO UPDATE SET + beatmap_hash = excluded.beatmap_hash, + beatmap_md5 = excluded.beatmap_md5, + analysis_version = excluded.analysis_version, + average_kps = excluded.average_kps, + max_kps = excluded.max_kps, + kps_list_json = excluded.kps_list_json, + xxy_sr = excluded.xxy_sr, + column_counts_json = excluded.column_counts_json, + hold_note_counts_json = excluded.hold_note_counts_json; +"; + + cmd.Parameters.AddWithValue("$id", beatmap.ID.ToString()); + cmd.Parameters.AddWithValue("$hash", beatmap.Hash); + cmd.Parameters.AddWithValue("$md5", beatmap.MD5Hash); + cmd.Parameters.AddWithValue("$version", ANALYSIS_VERSION); + cmd.Parameters.AddWithValue("$avg", analysis.AverageKps); + cmd.Parameters.AddWithValue("$max", analysis.MaxKps); + cmd.Parameters.AddWithValue("$kps", kpsListJson); + + if (analysis.XxySr.HasValue) + cmd.Parameters.AddWithValue("$xxy", analysis.XxySr.Value); + else + cmd.Parameters.AddWithValue("$xxy", DBNull.Value); + + cmd.Parameters.AddWithValue("$cols", columnCountsJson); + cmd.Parameters.AddWithValue("$holds", holdNoteCountsJson); + + cmd.ExecuteNonQuery(); + } + catch (Exception e) + { + Logger.Error(e, "EzManiaAnalysisPersistentStore Store failed."); + } + } + + public IReadOnlyList GetBeatmapsNeedingRecompute(IEnumerable<(Guid id, string hash)> beatmaps) + => GetBeatmapsNeedingRecompute(beatmaps, progress: null); + + public IReadOnlyList GetBeatmapsNeedingRecompute(IEnumerable<(Guid id, string hash)> beatmaps, Action? progress) + { + if (!Enabled) + return Array.Empty(); + + try + { + Initialise(); + + var beatmapList = beatmaps as IList<(Guid id, string hash)> ?? beatmaps.ToList(); + + // 读出已有条目(id -> (hash, version))。 + Dictionary existing = new Dictionary(); + + using (var connection = openConnection()) + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @"SELECT beatmap_id, beatmap_hash, analysis_version FROM mania_analysis;"; + + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) + { + if (!Guid.TryParse(reader.GetString(0), out var id)) + continue; + + string storedHash = reader.GetString(1); + int storedVersion = reader.GetInt32(2); + existing[id] = (storedHash, storedVersion); + } + } + + List needing = new List(); + + int processed = 0; + int total = beatmapList.Count; + + foreach (var (id, hash) in beatmapList) + { + processed++; + + if (processed == 1 || processed % 200 == 0) + progress?.Invoke(processed, total); + + if (!existing.TryGetValue(id, out var row)) + { + needing.Add(id); + continue; + } + + // Only force recompute on: + // - missing entries + // - hash mismatch (beatmap changed) + // - versions which cannot be upgraded in-place. + // Version bumps which are only schema/serialization should be upgraded lazily on TryGet(). + if (!string.Equals(row.hash, hash, StringComparison.Ordinal) || !canUpgradeInPlace(row.version) || row.version > ANALYSIS_VERSION) + needing.Add(id); + } + + progress?.Invoke(total, total); + + // 可选清理:删除已不存在的条目(避免无限增长)。 + // 这里用 HashSet 做 membership 判断,避免每条都查库。 + HashSet live = beatmapList.Select(b => b.id).ToHashSet(); + var toDelete = existing.Keys.Where(id => !live.Contains(id)).ToList(); + + if (toDelete.Count > 0) + { + using var connection = openConnection(); + using var transaction = connection.BeginTransaction(); + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = "DELETE FROM mania_analysis WHERE beatmap_id = $id;"; + + var idParam = cmd.CreateParameter(); + idParam.ParameterName = "$id"; + cmd.Parameters.Add(idParam); + + foreach (var id in toDelete) + { + idParam.Value = id.ToString(); + cmd.ExecuteNonQuery(); + } + + transaction.Commit(); + } + + return needing; + } + catch (Exception e) + { + Logger.Error(e, "EzManiaAnalysisPersistentStore GetBeatmapsNeedingRecompute failed."); + return Array.Empty(); + } + } + + private SqliteConnection openConnection() + { + // 这里每次操作打开一个连接,避免跨线程复用连接导致的问题。 + var connection = new SqliteConnection($"Data Source={dbPath};Cache=Shared;Mode=ReadWriteCreate"); + connection.Open(); + return connection; + } + + private void setMeta(SqliteConnection connection, string key, string value) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO meta(key, value) +VALUES($k, $v) +ON CONFLICT(key) DO UPDATE SET value = excluded.value; +"; + cmd.Parameters.AddWithValue("$k", key); + cmd.Parameters.AddWithValue("$v", value); + cmd.ExecuteNonQuery(); + } + + private static bool canUpgradeInPlace(int storedVersion) + => storedVersion >= min_inplace_upgrade_version && storedVersion <= ANALYSIS_VERSION; + + private void tryClonePreviousDatabaseIfMissing() + { + if (string.IsNullOrEmpty(dbPath)) + return; + + if (File.Exists(dbPath)) + return; + + string? dir = Path.GetDirectoryName(dbPath); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return; + + // Find the latest previous DB file (by version suffix) which we can potentially upgrade from. + // Even if it contains older rows, we will still validate per-row and decide upgrade vs recompute. + string? bestCandidate = null; + int bestVersion = -1; + + foreach (string file in Directory.EnumerateFiles(dir, "mania-analysis_v*.sqlite", SearchOption.TopDirectoryOnly)) + { + if (string.Equals(file, dbPath, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!tryParseDatabaseVersion(file, out int version)) + continue; + + if (version >= ANALYSIS_VERSION) + continue; + + if (version > bestVersion) + { + bestVersion = version; + bestCandidate = file; + } + } + + if (bestCandidate == null) + return; + + try + { + File.Copy(bestCandidate, dbPath); + Logger.Log($"[EzManiaAnalysisPersistentStore] Cloned DB from v{bestVersion} to v{ANALYSIS_VERSION}: {Path.GetFileName(bestCandidate)} -> {Path.GetFileName(dbPath)}", LoggingTarget.Database); + } + catch (Exception e) + { + // If cloning fails, we simply fall back to creating a fresh DB and recomputing as needed. + Logger.Error(e, "[EzManiaAnalysisPersistentStore] Failed to clone previous DB; falling back to fresh database."); + } + } + + private static bool tryParseDatabaseVersion(string filePath, out int version) + { + version = 0; + + string name = Path.GetFileName(filePath); + const string prefix = "mania-analysis_v"; + const string suffix = ".sqlite"; + + if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || !name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + return false; + + string number = name.Substring(prefix.Length, name.Length - prefix.Length - suffix.Length); + return int.TryParse(number, NumberStyles.Integer, CultureInfo.InvariantCulture, out version); + } + + private static void writeUpgradedRow(SqliteConnection connection, + BeatmapInfo beatmap, + double averageKps, + double maxKps, + IReadOnlyList kpsList, + double? xxySr, + IReadOnlyDictionary columnCounts, + IReadOnlyDictionary holdNoteCounts) + { + string kpsListJson = JsonSerializer.Serialize(kpsList); + string columnCountsJson = JsonSerializer.Serialize(columnCounts); + string holdNoteCountsJson = JsonSerializer.Serialize(holdNoteCounts); + + using var update = connection.CreateCommand(); + update.CommandText = @" +UPDATE mania_analysis +SET beatmap_md5 = $md5, + analysis_version = $version, + kps_list_json = $kps_list_json, + xxy_sr = $xxy_sr, + column_counts_json = $column_counts_json, + hold_note_counts_json = $hold_note_counts_json +WHERE beatmap_id = $id; +"; + update.Parameters.AddWithValue("$id", beatmap.ID.ToString()); + update.Parameters.AddWithValue("$md5", beatmap.MD5Hash); + update.Parameters.AddWithValue("$version", ANALYSIS_VERSION); + update.Parameters.AddWithValue("$kps_list_json", kpsListJson); + update.Parameters.AddWithValue("$xxy_sr", xxySr is null ? DBNull.Value : xxySr.Value); + update.Parameters.AddWithValue("$column_counts_json", columnCountsJson); + update.Parameters.AddWithValue("$hold_note_counts_json", holdNoteCountsJson); + + update.ExecuteNonQuery(); + } + + private void cleanupUnrecognizedColumns(SqliteConnection connection) + { + var existingColumns = getTableColumns(connection, "mania_analysis"); + var unrecognizedColumns = existingColumns.Where(c => !allowed_columns.Contains(c, StringComparer.OrdinalIgnoreCase)).ToList(); + + if (unrecognizedColumns.Count == 0) + return; + + // 重建表,删除不识别的列 + Logger.Log($"[EzManiaAnalysisPersistentStore] Found unrecognized columns: {string.Join(", ", unrecognizedColumns)}; rebuilding table.", LoggingTarget.Database); + + rebuildTableWithoutUnrecognizedColumns(connection, unrecognizedColumns); + } + + private List getTableColumns(SqliteConnection connection, string tableName) + { + var columns = new List(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"PRAGMA table_info({tableName});"; + + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) + { + columns.Add(reader.GetString(1)); // name + } + + return columns; + } + + private void rebuildTableWithoutUnrecognizedColumns(SqliteConnection connection, List unrecognizedColumns) + { + // 创建临时表,只包含允许的列 + using var createTempCmd = connection.CreateCommand(); + createTempCmd.CommandText = @" +CREATE TABLE mania_analysis_temp ( + beatmap_id TEXT PRIMARY KEY, + beatmap_hash TEXT NOT NULL, + beatmap_md5 TEXT NOT NULL, + analysis_version INTEGER NOT NULL, + average_kps REAL NOT NULL, + max_kps REAL NOT NULL, + kps_list_json TEXT NOT NULL, + xxy_sr REAL NULL, + column_counts_json TEXT NOT NULL, + hold_note_counts_json TEXT NOT NULL +); +"; + createTempCmd.ExecuteNonQuery(); + + // 复制数据,只复制允许的列 + using var insertCmd = connection.CreateCommand(); + insertCmd.CommandText = @" +INSERT INTO mania_analysis_temp (beatmap_id, beatmap_hash, beatmap_md5, analysis_version, average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json) +SELECT beatmap_id, beatmap_hash, beatmap_md5, analysis_version, average_kps, max_kps, kps_list_json, xxy_sr, column_counts_json, hold_note_counts_json +FROM mania_analysis; +"; + insertCmd.ExecuteNonQuery(); + + // 删除旧表 + using var dropCmd = connection.CreateCommand(); + dropCmd.CommandText = "DROP TABLE mania_analysis;"; + dropCmd.ExecuteNonQuery(); + + // 重命名临时表 + using var renameCmd = connection.CreateCommand(); + renameCmd.CommandText = "ALTER TABLE mania_analysis_temp RENAME TO mania_analysis;"; + renameCmd.ExecuteNonQuery(); + + // 重新创建索引 + using var indexCmd = connection.CreateCommand(); + indexCmd.CommandText = "CREATE INDEX IF NOT EXISTS idx_mania_analysis_version ON mania_analysis(analysis_version);"; + indexCmd.ExecuteNonQuery(); + + // 清理数据库文件大小 + using var vacuumCmd = connection.CreateCommand(); + vacuumCmd.CommandText = "VACUUM;"; + vacuumCmd.ExecuteNonQuery(); + } + + private bool hasColumn(SqliteConnection connection, string tableName, string columnName) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"PRAGMA table_info({tableName});"; + + using var reader = cmd.ExecuteReader(); + + while (reader.Read()) + { + // PRAGMA table_info: cid, name, type, notnull, dflt_value, pk + string name = reader.GetString(1); + if (string.Equals(name, columnName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + /// + /// Validates that the analysis result contains reasonable values. + /// + private static bool isValidAnalysisResult(ManiaBeatmapAnalysisResult result) + { + if (result.XxySr.HasValue && (double.IsNaN(result.XxySr.Value) || double.IsInfinity(result.XxySr.Value))) + return false; + + return true; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/XxySrCalculatorBridge.cs b/osu.Game/LAsEzExtensions/Analysis/XxySrCalculatorBridge.cs new file mode 100644 index 0000000000..ba282ec812 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/XxySrCalculatorBridge.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Reflection; +using System.Threading; +using osu.Framework.Logging; +using osu.Game.Beatmaps; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + internal static class XxySrCalculatorBridge + { + private const string logger_name = "xxy_sr"; + + private const string calculator_type_name = "osu.Game.Rulesets.Mania.LAsEZMania.Analysis.SRCalculator"; + private const string calculator_method_name = "CalculateSR"; + private const string mania_assembly_name = "osu.Game.Rulesets.Mania"; + + private static readonly Lazy calculate_method = new Lazy(resolveCalculateMethod, LazyThreadSafetyMode.ExecutionAndPublication); + + private static int resolveFailLogged; + private static int invokeFailCount; + + public static bool TryCalculate(IBeatmap beatmap, out double sr) + { + return TryCalculate(beatmap, 1.0, out sr); + } + + public static bool TryCalculate(IBeatmap beatmap, double clockRate, out double sr) + { + sr = 0; + + double cs = beatmap.BeatmapInfo.Difficulty.CircleSize; + int keyCount = Math.Max(1, (int)Math.Round(cs)); + + if (keyCount >= 11 && (keyCount % 2 == 1)) + { + return false; + } + + var method = calculate_method.Value; + + if (method != null) + { + try + { + object? result = method.Invoke(null, new object?[] { beatmap, clockRate }); + + if (result is double d) + { + sr = d; + return true; + } + + return false; + } + catch (Exception ex) + { + if (Interlocked.Increment(ref invokeFailCount) <= 10) + Logger.Error(ex, $"xxy_SR bridge invoke exception with clockRate. beatmapType={beatmap.GetType().FullName}, clockRate={clockRate}", logger_name); + } + } + + return false; + } + + private static MethodInfo? resolveCalculateMethod() + { + try + { + var type = findType(calculator_type_name); + + return type?.GetMethod(calculator_method_name, BindingFlags.Public | BindingFlags.Static, binder: null, types: new[] { typeof(IBeatmap), typeof(double) }, modifiers: null); + } + catch (Exception ex) + { + if (Interlocked.Exchange(ref resolveFailLogged, 1) == 0) + Logger.Error(ex, $"xxy_SR bridge resolve exception for {calculator_type_name}.{calculator_method_name}.", logger_name); + + return null; + } + } + + private static Type? findType(string fullName) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + var t = asm.GetType(fullName, throwOnError: false); + if (t != null) + return t; + } + + try + { + // 尝试显式加载 mania 程序集。 + var asm = Assembly.Load(mania_assembly_name); + return asm.GetType(fullName, throwOnError: false); + } + catch + { + return null; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Analysis/XxySrDebugJson.cs b/osu.Game/LAsEzExtensions/Analysis/XxySrDebugJson.cs new file mode 100644 index 0000000000..188fda47dc --- /dev/null +++ b/osu.Game/LAsEzExtensions/Analysis/XxySrDebugJson.cs @@ -0,0 +1,113 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Buffers; +using System.Text; +using System.Text.Json; +using osu.Framework.Logging; +using osu.Game.Beatmaps; + +namespace osu.Game.LAsEzExtensions.Analysis +{ + internal static class XxySrDebugJson + { + // Performance/debugging note: + // This logging can be very spammy during song select scrolling and may impact frame times. + // Keep disabled unless actively investigating xxy_SR correctness. + private const bool enabled = false; + + public static string FormatAbnormalSr(BeatmapInfo beatmap, string eventType, double? star = null, double? xxySr = null) + { + var buffer = new ArrayBufferWriter(256); + + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false })) + { + writer.WriteStartObject(); + + // Fixed property order for stable diffs. + writer.WriteString("event", eventType); + + writer.WriteString("beatmap_id", beatmap.ID.ToString()); + writer.WriteString("beatmap_hash", beatmap.Hash); + + // In this codebase OnlineID is an int; treat non-positive values as "no online id". + if (beatmap.OnlineID > 0) + writer.WriteNumber("beatmap_online_id", beatmap.OnlineID); + else + writer.WriteNull("beatmap_online_id"); + + writer.WriteString("difficulty_name", beatmap.DifficultyName); + + if (beatmap.BeatmapSet != null) + { + writer.WriteString("beatmapset_id", beatmap.BeatmapSet.ID.ToString()); + + if (beatmap.BeatmapSet.OnlineID > 0) + writer.WriteNumber("beatmapset_online_id", beatmap.BeatmapSet.OnlineID); + else + writer.WriteNull("beatmapset_online_id"); + } + else + { + writer.WriteNull("beatmapset_id"); + writer.WriteNull("beatmapset_online_id"); + } + + writer.WriteNumber("ruleset_online_id", 3); + writer.WriteStartArray("mods"); + writer.WriteEndArray(); + + if (star.HasValue) + writer.WriteNumber("star", star.Value); + else + writer.WriteNull("star"); + + if (xxySr.HasValue) + writer.WriteNumber("xxy_sr", xxySr.Value); + else + writer.WriteNull("xxy_sr"); + + if (star.HasValue && xxySr.HasValue) + { + double absDiff = Math.Abs(star.Value - xxySr.Value); + writer.WriteNumber("abs_diff", absDiff); + } + + writer.WriteEndObject(); + writer.Flush(); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + public static void LogAbnormalSr(BeatmapInfo? beatmap, double? star, double? xxySr, Guid beatmapId, ref Guid? loggedAbnormalId) + { + if (!enabled) + return; + + if (beatmap == null || star == null) + return; + + if (loggedAbnormalId == beatmapId) + return; + + loggedAbnormalId = beatmapId; + + if (xxySr == null) + { + Logger.Log( + FormatAbnormalSr(beatmap, "xxySR_null", null, xxySr), + "xxy_sr", + LogLevel.Error); + } + else if (Math.Abs(star.Value - xxySr.Value) > 3) + { + Logger.Log( + FormatAbnormalSr(beatmap, "xxySR_large_diff", star, xxySr), + "xxy_sr", + LogLevel.Error); + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Audio/AudioExtensions.cs b/osu.Game/LAsEzExtensions/Audio/AudioExtensions.cs new file mode 100644 index 0000000000..1ad8e30f5d --- /dev/null +++ b/osu.Game/LAsEzExtensions/Audio/AudioExtensions.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using osu.Framework; +using osu.Framework.Audio; +using osu.Framework.Logging; + +namespace osu.Game.LAsEzExtensions.Audio +{ + public static class AudioExtensions + { + // 固定采样率列表,优先使用48kHz + public static readonly int[] COMMON_SAMPLE_RATES = { 48000, 44100, 96000, 192000 }; + + // 扩展方法:设置ASIO设备初始化事件监听器 + public static void SetupAsioSampleRateSync(this AudioManager audioManager, Action onSampleRateChanged) + { + audioManager.OnAsioDeviceInitialized += sampleRate => + { + int intSampleRate = (int)sampleRate; + Logger.Log($"ASIO device initialized with sample rate {intSampleRate}Hz", LoggingTarget.Runtime, LogLevel.Debug); + onSampleRateChanged(intSampleRate); + }; + } + + // 解析设备选择字符串,返回输出模式 + private static (AudioOutputMode mode, string deviceName, int? asioDeviceIndex) parseSelection(string selection, bool useExperimentalWasapi) + { + // 默认设备 + if (string.IsNullOrEmpty(selection)) + { + return (useExperimentalWasapi && RuntimeInfo.OS == RuntimeInfo.Platform.Windows + ? AudioOutputMode.WasapiShared + : AudioOutputMode.Default, string.Empty, null); + } + + // 检查是否是WASAPI独占模式 + if (tryParseSuffixed(selection, "(WASAPI Exclusive)", out string baseName)) + return (AudioOutputMode.WasapiExclusive, baseName, null); + + // 检查是否是ASIO模式 + if (tryParseSuffixed(selection, "(ASIO)", out baseName)) + { + int? index = null; + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + { + try + { + // 通过反射获取AsioDeviceManager.AvailableDevices + var asioDeviceManagerType = typeof(AudioManager).Assembly.GetType("osu.Framework.Audio.Asio.AsioDeviceManager"); + var availableDevicesProperty = asioDeviceManagerType?.GetProperty("AvailableDevices", BindingFlags.Public | BindingFlags.Static); + + if (availableDevicesProperty != null) + { + if (availableDevicesProperty.GetValue(null) is IEnumerable<(int Index, string Name)> devices) + { + foreach (var device in devices) + { + if (device.Name == baseName) + { + index = device.Index; + break; + } + } + } + } + } + catch (Exception ex) + { + // 如果反射失败,记录错误并继续 + Logger.Log($"Reflection failed when getting ASIO devices: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + } + + return (AudioOutputMode.Asio, baseName, index); + } + + // 其他情况默认为默认模式 + return (AudioOutputMode.Default, selection, null); + } + + // 尝试从后缀解析设备名称 + private static bool tryParseSuffixed(string value, string suffix, out string baseName) + { + baseName = string.Empty; + + if (value.EndsWith(suffix, StringComparison.Ordinal)) + { + baseName = value.Substring(0, value.Length - suffix.Length); + return true; + } + + return false; + } + + // 音频输出模式枚举(从osu-framework复制) + private enum AudioOutputMode + { + Default, + WasapiShared, + WasapiExclusive, + Asio + } + } +} \ No newline at end of file diff --git a/osu.Game/LAsEzExtensions/Audio/InputAudioLatencyTracker.cs b/osu.Game/LAsEzExtensions/Audio/InputAudioLatencyTracker.cs new file mode 100644 index 0000000000..0a673e431f --- /dev/null +++ b/osu.Game/LAsEzExtensions/Audio/InputAudioLatencyTracker.cs @@ -0,0 +1,223 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.EzLatency; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Logging; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK.Input; + +namespace osu.Game.LAsEzExtensions.Audio +{ + /// + /// Unified latency measurement manager that coordinates between game input events and the EzLatency system. + /// Acts as the central bridge between osu! game events and framework-level latency tracking. + /// + public partial class InputAudioLatencyTracker : IDisposable + { + [Resolved(canBeNull: true)] + private INotificationOverlay? notificationOverlay { get; set; } + + [Resolved] + private AudioManager? audioManager { get; set; } + + private Ez2ConfigManager ezConfig { get; set; } + + private ScoreProcessor? scoreProcessor; + + private EzLatencyManager latencyManager; + + /// + /// Global instance for unified access + /// + public static InputAudioLatencyTracker? Instance { get; private set; } + + public InputAudioLatencyTracker(Ez2ConfigManager ez2ConfigManager) + { + ezConfig = ez2ConfigManager; + Instance = this; + + // 使用全局的 EzLatencyManager 实例以与框架层的全局插桩一致 + latencyManager = EzLatencyManager.GLOBAL; + } + + public void Initialize(ScoreProcessor processor) + { + Logger.Log($"InputAudioLatencyTracker.Initialize called", LoggingTarget.Runtime, LogLevel.Debug); + scoreProcessor = processor; + + // 将 Ez2Setting 的启用状态绑定到 EzLatencyManager + var configBindable = ezConfig.GetBindable(Ez2Setting.InputAudioLatencyTracker); + latencyManager.Enabled.BindTo(configBindable); + + // 订阅延迟记录事件,用于日志输出 + latencyManager.OnNewRecord += OnLatencyRecordGenerated; + + // 绑定启用状态变化,控制生命周期 + latencyManager.Enabled.BindValueChanged(enabled => + { + if (enabled.NewValue) + Start(); + else + Stop(); + }, true); + } + + private bool started; + + public void Start() + { + if (started) return; + + started = true; + + Logger.Log($"InputAudioLatencyTracker.Start called", LoggingTarget.Runtime, LogLevel.Debug); + + if (scoreProcessor != null) + scoreProcessor.NewJudgement += OnNewJudgement; + } + + public void Stop() + { + if (!started) return; + + started = false; + + if (scoreProcessor != null) + scoreProcessor.NewJudgement -= OnNewJudgement; + } + + /// + /// Records a key press event for latency measurement. + /// Call this when the player presses a key. + /// + /// The key that was pressed + public void RecordKeyPress(Key key) + { + if (latencyManager.Enabled.Value) + { + // 记录输入事件 + latencyManager.RecordInputEvent(key); + } + } + + /// + /// Records a column press event for mania ruleset. + /// + /// The column that was pressed + public void RecordColumnPress(int column) + { + if (latencyManager.Enabled.Value) + { + // 记录输入事件 (使用 column 作为标识) + latencyManager.RecordInputEvent(column); + } + } + + /// + /// Call this when the game exits to generate latency statistics. + /// + public void GenerateLatencyReport() + { + if (!latencyManager.Enabled.Value) + return; + + // 停止收集新数据 + Stop(); + + // 从 EzLatencyManager 获取统计数据 + var stats = latencyManager.GetStatistics(); + + if (!stats.HasData) + { + Logger.Log($"[EzOsuLatency] No latency data available for analysis", LoggingTarget.Runtime, LogLevel.Debug); + return; + } + + // 输出统计日志 + string message1 = $"Input→Judgement: {stats.AvgInputToJudge:F2}ms, Input→Audio: {stats.AvgInputToPlayback:F2}ms, Audio→Judgement: {stats.AvgPlaybackToJudge:F2}ms (based on {stats.RecordCount} complete records)"; + string message2 = $"Input→Judgement: {stats.AvgInputToJudge:F2}ms, \nInput→Audio: {stats.AvgInputToPlayback:F2}ms, \nAudio→Judgement: {stats.AvgPlaybackToJudge:F2}ms \n(based on {stats.RecordCount} complete records)"; + + Logger.Log($"[EzOsuLatency] Latency Analysis: {message1}"); + Logger.Log($"[EzOsuLatency] Latency Analysis: \n{message2}", LoggingTarget.Runtime, LogLevel.Important); + + // 显示通知 + if (notificationOverlay != null) + { + notificationOverlay.Post(new SimpleNotification + { + Text = $"Latency analysis complete!\nInput→Judge: {stats.AvgInputToJudge:F1}ms\nInput→Audio: {stats.AvgInputToPlayback:F1}ms\nAudio→Judge: {stats.AvgPlaybackToJudge:F1}ms\nRecords: {stats.RecordCount}", + Icon = FontAwesome.Solid.ChartLine, + }); + } + } + + private void OnNewJudgement(JudgementResult result) + { + if (!latencyManager.Enabled.Value) + return; + + if (result.Type.IsScorable()) + { + // 检查是否为普通note且判定为Perfect + bool isNote = result.HitObject.GetType().Name.EndsWith("Note", StringComparison.Ordinal) || + result.HitObject.GetType().Name == "Fruit" || + result.HitObject.GetType().Name == "HitCircle" || + result.HitObject.GetType().Name == "Hit"; + + // 记录所有可计分的 note 判定,以便收集判定时间戳(不局限于 Perfect) + if (isNote) + { + latencyManager.RecordJudgeEvent(); + } + } + } + + public void Dispose() + { + Stop(); + + // 解绑事件 + if (latencyManager != null) + { + latencyManager.OnNewRecord -= OnLatencyRecordGenerated; + latencyManager.Dispose(); + } + + Instance = null; + } + + /// + /// 处理从 framework 层传来的延迟记录,输出详细日志 + /// + private void OnLatencyRecordGenerated(EzLatencyRecord r) + { + try + { + var inputData = r.InputData; + var hw = r.HardwareData; + + string keyVal = inputData.KeyValue?.ToString() ?? "-"; + + string line = $"[EzOsuLatency] {r.Timestamp:O} | {r.MeasuredMs:F2} ms | note={r.Note} | in={r.InputTime:F2} | key={keyVal} | play={r.PlaybackTime:F2} | judge={r.JudgeTime:F2} | driver={r.DriverTime:F2} | out_hw={r.OutputHardwareTime:F2} | in_hw={r.InputHardwareTime:F2} | diff={r.LatencyDifference:F2}"; + + // extra low-level structs + string extra = $" | input_struct=(in={inputData.InputTime:F2}, key={inputData.KeyValue ?? "-"}, judge={inputData.JudgeTime:F2}, play={inputData.PlaybackTime:F2})" + + $" | hw_struct=(driver={hw.DriverTime:F2}, out_hw={hw.OutputHardwareTime:F2}, in_hw={hw.InputHardwareTime:F2}, diff={hw.LatencyDifference:F2})"; + + Logger.Log(line + extra, LoggingTarget.Runtime, LogLevel.Debug); + } + catch (Exception ex) + { + Logger.Log($"InputAudioLatencyTracker: failed to handle new record: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Background/VideoBackgroundScreen.cs b/osu.Game/LAsEzExtensions/Background/VideoBackgroundScreen.cs new file mode 100644 index 0000000000..9fbd553c41 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Background/VideoBackgroundScreen.cs @@ -0,0 +1,66 @@ +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Video; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Screens; + +namespace osu.Game.LAsEzExtensions.Background +{ + public partial class VideoBackgroundScreen : Graphics.Backgrounds.Background + { + private readonly string videoPath; + + public VideoBackgroundScreen(string videoPath) + { + this.videoPath = videoPath; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, Ez2ConfigManager ezSkinConfig) + { + var video = new Video(videoPath) + { + RelativeSizeAxes = Axes.Both, + Loop = true, + }; + + video.FillMode = FillMode.Fill; + video.FillAspectRatio = 1.0f * video.DrawSize.X / video.DrawSize.Y; + + AddInternal(video); + + // //下面只用于注册全局设置 + // GlobalConfigStore.Config = config; + // GlobalConfigStore.EzConfig = ezSkinConfig; + } + } + + public static class GlobalConfigStore + { + public static OsuConfigManager? Config { get; set; } + public static Ez2ConfigManager? EzConfig { get; set; } + } + + public partial class StreamVideoBackgroundScreen : Graphics.Backgrounds.Background + { + private readonly Stream videoStream; + + public StreamVideoBackgroundScreen(Stream videoStream) + { + this.videoStream = videoStream; + } + + [BackgroundDependencyLoader] + private void load() + { + var video = new Video(videoStream) + { + RelativeSizeAxes = Axes.Both, + Loop = true + }; + AddInternal(video); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/AnalysisSettings.cs b/osu.Game/LAsEzExtensions/Configuration/AnalysisSettings.cs new file mode 100644 index 0000000000..80bc81d06e --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/AnalysisSettings.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Game.Overlays.Settings; +using osu.Game.Screens; + +namespace osu.Game.LAsEzExtensions.Configuration +{ + public partial class AnalysisSettings : SettingsSubsection + { + protected override LocalisableString Header => "Analysis"; + + [BackgroundDependencyLoader] + private void load(Ez2ConfigManager ezConfig, OsuGameBase game, GameHost host, IPerformFromScreenRunner? performer) + { + AddRange(new Drawable[] + { + new SettingsCheckbox + { + LabelText = EzLocalizationManager.InputAudioLatencyTracker, + Current = ezConfig.GetBindable(Ez2Setting.InputAudioLatencyTracker), + TooltipText = EzLocalizationManager.InputAudioLatencyTrackerTooltip, + Keywords = new[] { "latency", "audio", "input" } + } + }); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/EnumHealthMode.cs b/osu.Game/LAsEzExtensions/Configuration/EnumHealthMode.cs new file mode 100644 index 0000000000..66ec18d10e --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/EnumHealthMode.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.LAsEzExtensions.Configuration +{ + public enum EnumHealthMode + { + [Description("Lazer")] + Lazer = 0, + + [Description("O2Jam Easy")] + O2JamEasy = 1, + + [Description("O2Jam Normal")] + O2JamNormal = 2, + + [Description("O2Jam Hard")] + O2JamHard = 3, + + [Description("Ez2Ac(NoActive)")] + Ez2Ac = 4, + + [Description("IIDX Hard(Testing)")] + IIDX_HD = 5, + + [Description("LR2 Hard(Testing)")] + LR2_HD = 6, + + [Description("raja normal(Testing)")] + Raja_NM = 7, + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/Ez2ConfigManager.cs b/osu.Game/LAsEzExtensions/Configuration/Ez2ConfigManager.cs new file mode 100644 index 0000000000..b715bd9790 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/Ez2ConfigManager.cs @@ -0,0 +1,521 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.LAsEzExtensions.Configuration +{ + public class Ez2ConfigManager : IniConfigManager, IGameplaySettings + { + protected override string Filename => "EzSkinSettings.ini"; + private readonly int[] commonKeyModes = { 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18 }; + public float DefaultHitPosition = 180f; + + public static readonly Dictionary COLUMN_TYPE_CACHE = new Dictionary(); + public static readonly Dictionary IS_SPECIAL_CACHE = new Dictionary(); + + private static readonly Dictionary key_mode_to_column_color_setting = new Dictionary + { + { 4, Ez2Setting.ColumnTypeOf4K }, + { 5, Ez2Setting.ColumnTypeOf5K }, + { 6, Ez2Setting.ColumnTypeOf6K }, + { 7, Ez2Setting.ColumnTypeOf7K }, + { 8, Ez2Setting.ColumnTypeOf8K }, + { 9, Ez2Setting.ColumnTypeOf9K }, + { 10, Ez2Setting.ColumnTypeOf10K }, + { 12, Ez2Setting.ColumnTypeOf12K }, + { 14, Ez2Setting.ColumnTypeOf14K }, + { 16, Ez2Setting.ColumnTypeOf16K }, + { 18, Ez2Setting.ColumnTypeOf18K }, + }; + + private static readonly Dictionary column_type_to_setting = new Dictionary + { + [EzColumnType.A] = Ez2Setting.ColumnTypeA, + [EzColumnType.B] = Ez2Setting.ColumnTypeB, + [EzColumnType.S] = Ez2Setting.ColumnTypeS, + [EzColumnType.E] = Ez2Setting.ColumnTypeE, + [EzColumnType.P] = Ez2Setting.ColumnTypeP, + }; + + public Ez2ConfigManager(Storage storage) + : base(storage) + { + initializeEvents(); + } + + protected override void InitialiseDefaults() + { + #region 皮肤类 + + SetDefault(Ez2Setting.LastSelectForColumnsType, 4); + SetDefault(Ez2Setting.ColumnWidthStyle, ColumnWidthStyle.EzStyleProOnly); + SetDefault(Ez2Setting.GlobalHitPosition, false); + SetDefault(Ez2Setting.GlobalTextureName, 4); + + SetDefault(Ez2Setting.ColumnWidth, 60, 5, 400.0, 1.0); + SetDefault(Ez2Setting.SpecialFactor, 1.2, 0.5, 2.0, 0.1); + SetDefault(Ez2Setting.HitPosition, DefaultHitPosition, 0, 500, 1.0); + SetDefault(Ez2Setting.VisualHitPosition, 0.0, -100, 100, 1.0); + SetDefault(Ez2Setting.HitTargetFloatFixed, 6, 0, 10, 0.1); + SetDefault(Ez2Setting.HitTargetAlpha, 0.6, 0, 1, 0.01); + + SetDefault(Ez2Setting.NoteSetName, "lucenteclat"); + SetDefault(Ez2Setting.StageName, "Celeste_Lumiere"); + SetDefault(Ez2Setting.GameThemeName, EzEnumGameThemeName.Celeste_Lumiere); + SetDefault(Ez2Setting.NoteHeightScaleToWidth, 1, 0.1, 10, 0.1); + SetDefault(Ez2Setting.NoteTrackLineHeight, 300, 0, 1000, 5.0); + + #endregion + + #region 列类型、着色系统 + + SetDefault(Ez2Setting.ColorSettingsEnabled, true); + SetDefault(Ez2Setting.ColumnBlur, 0.7, 0.0, 1, 0.01); + SetDefault(Ez2Setting.ColumnDim, 0.7, 0.0, 1, 0.01); + + SetDefault(Ez2Setting.ColorSettingsEnabled, true); + SetDefault(Ez2Setting.ColumnTypeA, Colour4.FromHex("#F5F5F5")); + SetDefault(Ez2Setting.ColumnTypeB, Colour4.FromHex("#648FFF")); + SetDefault(Ez2Setting.ColumnTypeS, Colour4.FromHex("#FF4A4A")); + SetDefault(Ez2Setting.ColumnTypeE, Colour4.FromHex("#FF4A4A")); + SetDefault(Ez2Setting.ColumnTypeP, Colour4.FromHex("#72FF72")); + + initializeColumnTypeDefaults(); + + // Pre-populate caches for all common key modes to avoid lazy loading delays + foreach (int keyMode in commonKeyModes) + { + GetColumnTypes(keyMode); + GetSpecialColumnsBools(keyMode); + } + + #endregion + + SetDefault(Ez2Setting.AccuracyCutoffS, 0.95, 0.95, 1, 0.005); + SetDefault(Ez2Setting.AccuracyCutoffA, 0.9, 0.9, 1, 0.005); + + SetDefault(Ez2Setting.ScalingGameMode, ScalingGameMode.Mania); + + SetDefault(Ez2Setting.GameplayDisableCmdSpace, true); + SetDefault(Ez2Setting.AsioSampleRate, 48000); + SetDefault(Ez2Setting.InputAudioLatencyTracker, false); + + initializeManiaDefaults(); + } + + private void initializeManiaDefaults() + { + SetDefault(Ez2Setting.KpcDisplayMode, EzKpcDisplay.KpcDisplayMode.Numbers); + SetDefault(Ez2Setting.XxySRFilter, false); + SetDefault(Ez2Setting.KeySoundPreview, false); + SetDefault(Ez2Setting.EzSelectCsMode, ""); + + SetDefault(Ez2Setting.HitMode, EzMUGHitMode.EZ2AC); + SetDefault(Ez2Setting.CustomHealthMode, EnumHealthMode.Lazer); + SetDefault(Ez2Setting.CustomPoorHitResultBool, true); + SetDefault(Ez2Setting.ManiaBarLinesBool, true); + + SetDefault(Ez2Setting.ManiaHoldTailAlpha, 0.0, 0.0, 1.0, 0.01); + SetDefault(Ez2Setting.ManiaHoldTailMaskGradientHeight, 0.0, 0.0, 100.0, 1.0); + } + + #region 列类型管理 + + private void initializeColumnTypeDefaults() + { + foreach (int keyMode in commonKeyModes) + { + if (key_mode_to_column_color_setting.TryGetValue(keyMode, out var setting)) + { + EzColumnType[] defaultTypes = getDefaultColumnTypes(keyMode); + SetDefault(setting, string.Join(",", defaultTypes)); + } + } + } + + private static EzColumnType[] getDefaultColumnTypes(int keyMode) + { + return Enumerable.Range(0, keyMode) + .Select(i => EzColumnTypeManager.GetColumnType(keyMode, i)) + .ToArray(); + } + + public void SetColumnType(int keyMode, int columnIndex, EzColumnType colorType) + { + SetColumnType(keyMode, columnIndex, colorType.ToString()); + } + + public void SetColumnType(int keyMode, int columnIndex, string colorType) + { + try + { + var setting = getColumnTypeListSetting(keyMode); + string? currentConfig = Get(setting); + string[] types = !string.IsNullOrEmpty(currentConfig) + ? currentConfig.Split(',') + : new string[keyMode]; + + if (types.Length <= columnIndex) + { + Array.Resize(ref types, Math.Max(keyMode, columnIndex + 1)); + } + + types[columnIndex] = colorType.Trim(); + SetValue(setting, string.Join(",", types)); + + COLUMN_TYPE_CACHE.Remove(keyMode); + IS_SPECIAL_CACHE.Remove(keyMode); + } + catch (NotSupportedException) + { + } + } + + private static Ez2Setting getColumnTypeListSetting(int keyMode) + { + if (key_mode_to_column_color_setting.TryGetValue(keyMode, out var setting)) + return setting; + + throw new NotSupportedException($"不支持 {keyMode} 键位模式"); + } + + #endregion + + #region 公共方法 + + public float GetTotalWidth(int keyMode) + { + double baseWidth = GetBindable(Ez2Setting.ColumnWidth).Value; + double specialFactor = GetBindable(Ez2Setting.SpecialFactor).Value; + float totalWidth = 0; + int forMode = keyMode == 14 ? keyMode - 1 : keyMode; + bool[] isSpecials = GetSpecialColumnsBools(keyMode); + + for (int i = 0; i < forMode; i++) + { + bool isSpecial = isSpecials[i]; + totalWidth += (float)(baseWidth * (isSpecial ? specialFactor : 1.0)); + } + + return totalWidth; + } + + public Colour4 GetColumnColor(int keyMode, int columnIndex) + { + EzColumnType colorType = GetColumnType(keyMode, columnIndex); + + if (column_type_to_setting.TryGetValue(colorType, out var setting)) + return Get(setting); + + return Get(Ez2Setting.ColumnTypeA); + } + + public IBindable GetColumnColorBindable(int keyMode, int columnIndex) + { + EzColumnType colorType = GetColumnType(keyMode, columnIndex); + + if (column_type_to_setting.TryGetValue(colorType, out var setting)) + return GetBindable(setting); + + return GetBindable(Ez2Setting.ColumnTypeA); + } + + public bool IsSpecialColumn(int keyMode, int columnIndex) + { + return GetColumnType(keyMode, columnIndex) == EzColumnType.S; + } + + public bool[] GetSpecialColumnsBools(int keyMode) + { + if (IS_SPECIAL_CACHE.TryGetValue(keyMode, out bool[]? specials)) + return specials; + + EzColumnType[] types = GetColumnTypes(keyMode); + bool[] result = new bool[keyMode]; + + for (int i = 0; i < keyMode; i++) + { + result[i] = types[i] == EzColumnType.S; + } + + IS_SPECIAL_CACHE[keyMode] = result; + return result; + } + + public EzColumnType GetColumnType(int keyMode, int columnIndex) + { + EzColumnType[] types = GetColumnTypes(keyMode); + if (columnIndex < types.Length) + return types[columnIndex]; + + return EzColumnTypeManager.GetColumnType(keyMode, columnIndex); + } + + public EzColumnType[] GetColumnTypes(int keyMode) + { + if (COLUMN_TYPE_CACHE.TryGetValue(keyMode, out EzColumnType[]? types)) + return types; + + types = new EzColumnType[keyMode]; + + try + { + var setting = getColumnTypeListSetting(keyMode); + string? columnColors = Get(setting); + + if (!string.IsNullOrEmpty(columnColors)) + { + int start = 0; + int index = 0; + + for (int i = 0; i <= columnColors.Length && index < keyMode; i++) + { + if (i == columnColors.Length || columnColors[i] == ',') + { + string part = columnColors.Substring(start, i - start).Trim(); + if (!string.IsNullOrEmpty(part) && Enum.TryParse(part, out var t)) + types[index] = t; + else + types[index] = EzColumnTypeManager.GetColumnType(keyMode, index); + index++; + start = i + 1; + } + } + + // Fill remaining with defaults + for (int i = index; i < keyMode; i++) + types[i] = EzColumnTypeManager.GetColumnType(keyMode, i); + } + else + { + for (int i = 0; i < keyMode; i++) + types[i] = EzColumnTypeManager.GetColumnType(keyMode, i); + } + } + catch (NotSupportedException) + { + for (int i = 0; i < keyMode; i++) + types[i] = EzColumnTypeManager.GetColumnType(keyMode, i); + } + + COLUMN_TYPE_CACHE[keyMode] = types; + return types; + } + + #endregion + + // public Bindable GetNoteSize(int keyMode, int columnIndex) + // { + // var result = new Bindable(); + // + // var columnWidthBindable = GetBindable(EzSkinSetting.ColumnWidth); + // var specialFactorBindable = GetBindable(EzSkinSetting.SpecialFactor); + // var heightScaleBindable = GetBindable(EzSkinSetting.NoteHeightScaleToWidth); + // + // void updateNoteSize() + // { + // bool isSpecialColumn = GetColumnType(keyMode, columnIndex) == "S"; + // double baseWidth = columnWidthBindable.Value; + // double specialFactor = specialFactorBindable.Value; + // double heightScale = heightScaleBindable.Value; + // + // float x = (float)(baseWidth * (isSpecialColumn ? specialFactor : 1.0)); + // float y = (float)(heightScale); + // result.Value = new Vector2(x, y); + // } + // + // columnWidthBindable.BindValueChanged(e => + // { + // Logger.Log($"ColumnWidth changed: {e.NewValue}"); + // updateNoteSize(); + // }); + // specialFactorBindable.BindValueChanged(_ => updateNoteSize()); + // heightScaleBindable.BindValueChanged(_ => updateNoteSize()); + // + // updateNoteSize(); + // + // return result; + // } + + #region 事件发布 + + // public event Action? OnPositionChanged; + public event Action? OnNoteSizeChanged; + public event Action? OnNoteColourChanged; + + private void initializeEvents() + { + var columnWidthBindable = GetBindable(Ez2Setting.ColumnWidth); + var specialFactorBindable = GetBindable(Ez2Setting.SpecialFactor); + var columnWidthStyleBindable = GetBindable(Ez2Setting.ColumnWidthStyle); + + columnWidthBindable.BindValueChanged(_ => OnNoteSizeChanged?.Invoke()); + specialFactorBindable.BindValueChanged(_ => OnNoteSizeChanged?.Invoke()); + columnWidthStyleBindable.BindValueChanged(_ => OnNoteSizeChanged?.Invoke()); + + var colorSettingsEnabledBindable = GetBindable(Ez2Setting.ColorSettingsEnabled); + var colorABindable = GetBindable(Ez2Setting.ColumnTypeA); + var colorBBindable = GetBindable(Ez2Setting.ColumnTypeB); + var colorSBindable = GetBindable(Ez2Setting.ColumnTypeS); + var colorEBindable = GetBindable(Ez2Setting.ColumnTypeE); + var colorPBindable = GetBindable(Ez2Setting.ColumnTypeP); + + colorSettingsEnabledBindable.BindValueChanged(_ => OnNoteColourChanged?.Invoke()); + colorABindable.BindValueChanged(_ => OnNoteColourChanged?.Invoke()); + colorBBindable.BindValueChanged(_ => OnNoteColourChanged?.Invoke()); + colorSBindable.BindValueChanged(_ => OnNoteColourChanged?.Invoke()); + colorEBindable.BindValueChanged(_ => OnNoteColourChanged?.Invoke()); + colorPBindable.BindValueChanged(_ => OnNoteColourChanged?.Invoke()); + } + + #endregion + + public new Bindable GetBindable(Ez2Setting setting) + { + return base.GetBindable(setting); + } + + public new void SetValue(Ez2Setting lookup, T value) + { + base.SetValue(lookup, value); + } + + public new void Save() + { + base.Save(); + } + + IBindable IGameplaySettings.ComboColourNormalisationAmount => null!; + IBindable IGameplaySettings.PositionalHitsoundsLevel => null!; + + public int KeyMode; + + public int GetKeyMode() + { + return KeyMode; + } + + public double ColumnTotalWidth; + + public double GetColumnTotalWidth() + { + return ColumnTotalWidth; + } + } + + public enum ColumnWidthStyle + { + [Description("EzStylePro Only")] + EzStyleProOnly, + + [Description("Global (全局)")] + GlobalWidth, + + [Description("Global Total (全局总宽度)")] + GlobalTotalWidth, + } + + public enum Ez2Setting + { + // 界面设置 + LastSelectForColumnsType, + KeySoundPreview, + EzSelectCsMode, + ScalingGameMode, + AccuracyCutoffS, + AccuracyCutoffA, + XxySRFilter, + KpcDisplayMode, + + // 全局开关 + ColumnWidthStyle, + GlobalHitPosition, //TODO:未来改成下拉栏,补充虚拟判定线 + + // 全局设置 + ColumnWidth, + SpecialFactor, + + // Ez专属皮肤设置 + HitPosition, + HitTargetFloatFixed, + HitTargetAlpha, + VisualHitPosition, + NoteHeightScaleToWidth, + NoteTrackLineHeight, + + // Mania 长按尾部相关(EzSkinEditor 用) + ManiaHoldTailAlpha, + ManiaHoldTailMaskGradientHeight, + + GlobalTextureName, + NoteSetName, + StageName, + GameThemeName, + + // 着色系统 + ColorSettingsEnabled, + ColumnTypeOf4K, + ColumnTypeOf5K, + ColumnTypeOf6K, + ColumnTypeOf7K, + ColumnTypeOf8K, + ColumnTypeOf9K, + ColumnTypeOf10K, + ColumnTypeOf12K, + ColumnTypeOf14K, + ColumnTypeOf16K, + ColumnTypeOf18K, + + // 列类型 + // ColumnTypeBase = 500, + ColumnTypeA, + ColumnTypeB, + ColumnTypeS, + ColumnTypeE, + ColumnTypeP, + ColumnBlur, + ColumnDim, + + // 音频相关 + AsioSampleRate, + InputAudioLatencyTracker, + + // 来自拉取 + GameplayDisableCmdSpace, + + // Mania游戏专属设置 + HitMode, + CustomHealthMode, + CustomPoorHitResultBool, + ManiaBarLinesBool + } + + public enum EzColumnType + { + A, + B, + S, + E, + P + } + + public static class EzConstants + { + public const string COLUMN_TYPE_A = nameof(EzColumnType.A); + public const string COLUMN_TYPE_B = nameof(EzColumnType.B); + public const string COLUMN_TYPE_S = nameof(EzColumnType.S); + public const string COLUMN_TYPE_E = nameof(EzColumnType.E); + public const string COLUMN_TYPE_P = nameof(EzColumnType.P); + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/EzColumnTypeManager.cs b/osu.Game/LAsEzExtensions/Configuration/EzColumnTypeManager.cs new file mode 100644 index 0000000000..4fd92c2778 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/EzColumnTypeManager.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Screens; + +namespace osu.Game.LAsEzExtensions.Configuration +{ + public static class EzColumnTypeManager + { + public static EzColumnType GetColumnType(int keyMode, int columnIndex) + { + if (columnIndex < 0 || columnIndex >= keyMode) + return EzColumnType.A; + + if (isSpecialColumn(keyMode, columnIndex)) + return EzColumnType.S; + + if (isEffectColumn(keyMode, columnIndex)) + return EzColumnType.E; + + if (isPanelColumn(keyMode, columnIndex)) + return EzColumnType.P; + + int normalKeyIndex = 0; + + for (int i = 0; i < columnIndex; i++) + { + if (!isSpecialColumn(keyMode, i) && !isPanelColumn(keyMode, i)) + normalKeyIndex++; + } + + return getNormalColumnType(keyMode, normalKeyIndex, columnIndex); + } + + private static EzColumnType getNormalColumnType(int keyMode, int normalKeyIndex, int columnIndex) + { + if (keyMode % 2 == 0) + { + int halfKey = keyMode / 2; + return columnIndex < halfKey + ? (normalKeyIndex % 2 == 0 ? EzColumnType.A : EzColumnType.B) + : (normalKeyIndex % 2 == 0 ? EzColumnType.B : EzColumnType.A); + } + + return normalKeyIndex % 2 == 0 ? EzColumnType.A : EzColumnType.B; + } + + private static bool isSpecialColumn(int keyMode, int columnIndex) + { + return keyMode switch + { + 12 when columnIndex is 0 or 11 => true, + 14 when columnIndex is 0 or 12 => true, + 16 when columnIndex is 0 or 15 => true, + _ => false + }; + } + + private static bool isEffectColumn(int keyMode, int columnIndex) + { + return keyMode switch + { + 16 when columnIndex is 6 or 7 or 8 or 9 => true, + _ => false + }; + } + + private static bool isPanelColumn(int keyMode, int columnIndex) + { + return keyMode switch + { + 5 when columnIndex == 2 => true, + 7 when columnIndex == 3 => true, + 9 when columnIndex == 4 => true, + 14 when columnIndex is 6 => true, + _ => false + }; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/EzLocalizationManager.cs b/osu.Game/LAsEzExtensions/Configuration/EzLocalizationManager.cs new file mode 100644 index 0000000000..bf17d934f7 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/EzLocalizationManager.cs @@ -0,0 +1,252 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using System.Reflection; +using osu.Framework.Localisation; + +namespace osu.Game.LAsEzExtensions.Configuration +{ + public class EzLocalizationManager + { + static EzLocalizationManager() + { + // 使用反射为未设置英文的属性自动生成英文(属性名替换_为空格) + var fields = typeof(EzLocalizationManager).GetFields(BindingFlags.Public | BindingFlags.Static); + + foreach (var field in fields) + { + if (field.FieldType == typeof(EzLocalisableString)) + { + if (field.GetValue(null) is EzLocalisableString instance && instance.English == null) + { + instance.English = field.Name.Replace("_", " "); + } + } + } + } + + /// + /// 本地化字符串类,直接持有中文和英文文本。 + /// 支持隐式转换为字符串,根据当前 UI 文化自动返回相应语言的文本。 + /// + /// + /// 便捷用法:如果不提供英文参数,系统会自动从属性名生成英文(将 '_' 替换为空格)。 + /// + /// public static readonly EzLocalisableString My_Button = new EzLocalisableString("我的按钮"); + /// // 中文: "我的按钮" + /// // 英文: "My Button" (自动生成) + /// + /// 手动提供英文: + /// + /// public static readonly EzLocalisableString My_Button = new EzLocalisableString("我的按钮", "Custom English"); + /// + /// + public class EzLocalisableString : ILocalisableStringData + { + public string Chinese { get; } + + /// + /// 英文文本。如果为 null,英文时显示中文,或通过反射自动生成。 + /// + public string? English { get; set; } + + /// + /// 初始化本地化字符串。 + /// + /// 中文文本。 + /// 英文文本。如果为 null,英文时显示中文,或自动生成。 + public EzLocalisableString(string chinese, string? english = null) + { + Chinese = chinese; + English = english; + } + + /// + /// 便捷构造函数:只提供中文,英文稍后自动生成或显示中文。 + /// + /// 中文文本。 + public EzLocalisableString(string chinese) + : this(chinese, null) { } + + /// + /// 隐式转换为字符串,根据当前语言返回相应文本。 + /// + /// 本地化字符串实例。 + /// 当前语言的文本。 + public static implicit operator string(EzLocalisableString s) => s.getString(); + + /// + /// 隐式转换为 LocalisableString,用于与 osu.Framework 兼容。 + /// + /// 本地化字符串实例。 + /// LocalisableString 实例。 + public static implicit operator LocalisableString(EzLocalisableString s) => new LocalisableString((ILocalisableStringData)s); + + /// + /// 支持格式化,返回格式化后的字符串。 + /// + /// 格式化参数。 + /// 格式化后的文本。 + public string Format(params object[] args) => string.Format(getString(), args); + + /// + /// 返回当前语言的文本。 + /// + /// 文本字符串。 + public override string ToString() => getString(); + + private string getString() + { + string lang = CultureInfo.CurrentUICulture.Name.StartsWith("zh", StringComparison.Ordinal) ? "zh" : "en"; + return lang == "zh" ? Chinese : (English ?? Chinese); + } + + /// + /// 实现ILocalisableStringData接口的方法。 + /// + public string GetLocalised(LocalisationParameters parameters) => getString(); + + /// + /// 实现ILocalisableStringData接口的Equals方法。 + /// + public bool Equals(ILocalisableStringData? other) + { + if (other is not EzLocalisableString ezOther) + return false; + + return Chinese == ezOther.Chinese && English == ezOther.English; + } + } + + // 公共属性定义本地化字符串,直接指定中文和英文 + public static readonly EzLocalisableString SettingsTitle = new EzLocalisableString("设置", "Settings"); + public static readonly EzLocalisableString SaveButton = new EzLocalisableString("保存", "Save"); + public static readonly EzLocalisableString CancelButton = new EzLocalisableString("取消", "Cancel"); + + public static readonly EzLocalisableString GlobalTextureName = new EzLocalisableString("全局纹理名称", "Global Texture Name"); + public static readonly EzLocalisableString GlobalTextureNameTooltip = new EzLocalisableString("(全局纹理名称)统一修改当前皮肤中所有组件的纹理名称", "Set a global texture name for all components in the current skin"); + + public static readonly EzLocalisableString StageSet = new EzLocalisableString("Stage套图", "Stage Set"); + + public static readonly EzLocalisableString StageSetTooltip = new EzLocalisableString( + "统一指定主面板, 如果有动效,则关联实时BPM。" + + "\n支持在本地EzResources/Stage中增减子文件夹来自定义,选项会在重载时重新读取文件夹名称。" + + "\n子文件夹可以自己改名,但内容文件夹及文件的名称必须完全一致。", + "Set a stage set for Stage Bottom, related to real-time BPM" + + "\nSupport adding or removing subfolders in the local EzResources/Stage for customization. Options will be reloaded when reloading." + + "\nSubfolders can be renamed, but the names of content folders and files must be exactly the same."); + + public static readonly EzLocalisableString NoteSet = new EzLocalisableString("Note套图", "Note Set Sprite"); + + public static readonly EzLocalisableString NoteSetTooltip = new EzLocalisableString( + "统一指定整组note套图, 含note和打击光效。" + + "\n支持在本地EzResources/Stage中增减子文件夹来自定义,选项会在重载时重新读取文件夹名称。" + + "\n子文件夹可以自己改名,但内容文件夹及文件的名称必须完全一致。", + "Set a note set for all notes and hit effects. " + + "\nSupport adding or removing subfolders in the local EzResources/Stage for customization. Options will be reloaded when reloading." + + "\nSubfolders can be renamed, but the names of content folders and files must be exactly the same."); + + public static readonly EzLocalisableString ColumnWidthStyle = new EzLocalisableString("列宽计算风格", "Column Width Calculation Style"); + + public static readonly EzLocalisableString ColumnWidthStyleTooltip = new EzLocalisableString( + "全局设置可以用在所有皮肤上。" + + "\n全局总列宽=设置值×10,单列宽度=key数/总列宽。" + + "\n其他是字面意思(功能不完善!)", + "Global is can be applied to all skins. " + + "\nGlobal Total Column Width = Configured Value × 10" + + "\nOther styles are literal meaning (functionality not perfect!)"); + + public static readonly EzLocalisableString ColumnWidth = new EzLocalisableString("单轨宽度", "Column Width"); + public static readonly EzLocalisableString ColumnWidthTooltip = new EzLocalisableString("设置每列轨道的宽度", "Set the width of each column"); + + public static readonly EzLocalisableString SpecialFactor = new EzLocalisableString("特殊轨宽度倍率", "Special Column Width Factor"); + + public static readonly EzLocalisableString SpecialFactorTooltip = new EzLocalisableString( + "关联ColumnType设置,S列类型为特殊列,以此实现两种宽度的区分。", + "The S column type are Special columns, achieving a distinction between two widths."); + + public static readonly EzLocalisableString GlobalHitPosition = new EzLocalisableString("全局判定线位置", "Global HitPosition"); + public static readonly EzLocalisableString GlobalHitPositionTooltip = new EzLocalisableString("全局判定线位置开关", "Global HitPosition Toggle"); + + public static readonly EzLocalisableString HitPosition = new EzLocalisableString("判定线位置", "Hit Position"); + public static readonly EzLocalisableString HitPositionTooltip = new EzLocalisableString("设置可视的判定线位置", "Set the visible hit position"); + + public static readonly EzLocalisableString HitTargetAlpha = new EzLocalisableString("note命中靶透明度(EzPro专用)", "Hit Target Alpha"); + + public static readonly EzLocalisableString HitTargetAlphaTooltip = new EzLocalisableString( + "设置Ez Style Pro皮肤中note命中靶的透明度,可见判定线上与note一样的判定板", + "Set the transparency of the note Hit Target in Ez Style Pro skin, making the hit plate on the hit position visible like the note"); + + public static readonly EzLocalisableString HitTargetFloatFixed = new EzLocalisableString("命中靶的浮动修正(EzPro专用)", "Hit Target Float Fixed"); + + public static readonly EzLocalisableString HitTargetFloatFixedTooltip = new EzLocalisableString( + "设置Ez Style Pro皮肤中note命中靶,修改浮动效果的正弦函数运动范围", + "Set the note Hit Target in Ez Style Pro skin, modifying the sine function motion range of the floating effect"); + + public static readonly EzLocalisableString NoteHeightScale = new EzLocalisableString("note 高度比例", "Note Height Scale"); + public static readonly EzLocalisableString NoteHeightScaleTooltip = new EzLocalisableString("统一修改note的高度的比例", "Fixed Height for square notes"); + + public static readonly EzLocalisableString ManiaHoldTailAlpha = new EzLocalisableString("Tail面尾透明度(未实装)", "Mania Hold Tail Alpha"); + public static readonly EzLocalisableString ManiaHoldTailAlphaTooltip = new EzLocalisableString("Mania Tail面尾的透明度", "Modify the transparency of the Mania hold tail"); + + public static readonly EzLocalisableString ManiaHoldTailMaskGradientHeight = new EzLocalisableString("调整缩短面尾的距离(投)", "Adjust LN Tail Length (Opportunistic)"); + + public static readonly EzLocalisableString ManiaHoldTailMaskGradientHeightTooltip = new EzLocalisableString( + "(投皮) 缩短面条中部实现,不改变面尾形状", + "(Opportunistic) Shorten the middle of the hold tail without changing its shape"); + + public static readonly EzLocalisableString NoteTrackLine = new EzLocalisableString("Note辅助线", "Note Track Line"); + public static readonly EzLocalisableString NoteTrackLineTooltip = new EzLocalisableString("(Ez风格)note两侧辅助轨道线的高度", "(Ez Style)note side auxiliary track line height"); + + public static readonly EzLocalisableString RefreshSaveSkin = new EzLocalisableString("强制刷新、保存皮肤", "Refresh & Save Skin"); + public static readonly EzLocalisableString SwitchToAbsolute = new EzLocalisableString("强制刷新, 并切换至 绝对位置(不稳定)", "Refresh, Switch to Absolute(Unstable)"); + public static readonly EzLocalisableString SwitchToRelative = new EzLocalisableString("强制刷新, 并切换至 相对位置(不稳定)", "Refresh, Switch to Relative(Unstable)"); + + public static readonly EzLocalisableString DisableCmdSpace = new EzLocalisableString("游戏时禁用 Cmd+Space(聚焦搜索)", "Disable Cmd+Space (Spotlight) during gameplay"); + + public static readonly EzLocalisableString HitMode = new EzLocalisableString("Mania 判定系统", "(Mania) Hit Mode"); + + public static readonly EzLocalisableString HitModeTooltip = new EzLocalisableString( + "Mania 判定系统, 获得不同音游的打击体验, 但是不保证所有模式都完全一比一复刻", + "(Mania) Hit Mode, get different rhythm game hit experiences, but not guaranteed to perfectly replicate all modes"); + + public static readonly EzLocalisableString HealthMode = new EzLocalisableString("Mania 血量系统", "(Mania) Health Mode"); + public static readonly EzLocalisableString HealthModeTooltip = new EzLocalisableString("目前主要用于O2Jam,其他模式图一乐", "Mainly used for O2Jam, other mode charts are for fun"); + + public static readonly EzLocalisableString PoorHitResult = new EzLocalisableString("Mania Poor 判定系统", "(Mania) Poor HitResult Mode"); + + public static readonly EzLocalisableString PoorHitResultTooltip = new EzLocalisableString( + "Mania增加Pool判定,范围是比Miss提前150ms范围内时出现,动态严格扣血(连续累积将加剧,最大10%)", + "Mania add the Poor HitResult, which appears within 150ms before Miss, with dynamic and strict health deduction (continuous accumulation will worsen, up to 10%)"); + + public static readonly EzLocalisableString AccuracyCutoffS = new EzLocalisableString("Acc S评级线", "Accuracy Cutoff S"); + public static readonly EzLocalisableString AccuracyCutoffA = new EzLocalisableString("Acc A评级线", "Accuracy Cutoff A"); + + // Storage folder messages + public static readonly EzLocalisableString StorageFolder_Created = new EzLocalisableString("已创建目录:{0}\n请将文件放入该目录", "Created folder: {0}\nAdd files to the folder"); + public static readonly EzLocalisableString StorageFolder_Empty = new EzLocalisableString("目录为空:{0}", "Folder is empty: {0}"); + + public static readonly EzLocalisableString InputAudioLatencyTracker = new EzLocalisableString("输入音频延迟追踪器", "Input Audio Latency Tracker"); + + public static readonly EzLocalisableString InputAudioLatencyTrackerTooltip = new EzLocalisableString( + "(测试功能)启用后可追踪按键输入与音频的延迟,用于调试和优化打击音效的同步性。在游戏结束后会弹出一个统计窗口。更详细的内容可以查看runtime.log文件。" + + "\n延迟检测管线:按键 → 检查打击并应用 → 应用判定结果 → 播放note音频", + "(Testing feature) When enabled, it can track the latency between key input and audio, used for debugging and optimizing the synchronization of hit sound effects. " + + "A statistics window will pop up after the game ends. More detailed information can be found in the runtime.log file." + + "\nLatency detection pipeline: Key Press → Check Hit and Apply → Apply Hit Result → Play Note Audio"); + + public static readonly LocalisableString ManiaBarLinesBool = new EzLocalisableString("Mania 强制小节线显示开关", "(Mania) BarLines Boolean Toggle"); + public static readonly LocalisableString ManiaBarLinesBoolTooltip = new EzLocalisableString("强制显示Mania小节线功能的开关,关闭后仅由皮肤控制", "(Mania) Toggle to force display of bar lines, when off only controlled by skin"); + } + + public static class EzLocalizationExtensions + { + public static string Localize(this string key) + { + // 由于不再使用字典,这个扩展方法可能不再需要,但保留兼容性 + return key; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/EzMUGHitMode.cs b/osu.Game/LAsEzExtensions/Configuration/EzMUGHitMode.cs new file mode 100644 index 0000000000..283920bd44 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/EzMUGHitMode.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.LAsEzExtensions.Configuration +{ + public enum EzMUGHitMode + { + [Description("Lazer Style")] + Lazer = 0, + + [Description("EZ2AC Style")] + EZ2AC = 1, + + [Description("O2JAM Style")] + O2Jam = 2, + + [Description("IIDX Hard Style(Testing)")] + IIDX_HD = 3, + + [Description("LR2 Hard Style(Testing)")] + LR2_HD = 4, + + [Description("Raja Hard Style(Testing)")] + Raja_NM = 5, + + [Description("/(NotAction)")] + Malody = 6, + + [Description("/(NotAction)")] + Classic = 7, + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/LoopTimeRangeStore.cs b/osu.Game/LAsEzExtensions/Configuration/LoopTimeRangeStore.cs new file mode 100644 index 0000000000..f6e3d35ff4 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/LoopTimeRangeStore.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; + +namespace osu.Game.LAsEzExtensions.Configuration +{ + /// + /// Stores an A/B loop time range for the current application session. + /// Values are in milliseconds and are not persisted after exiting the game. + /// + public static class LoopTimeRangeStore + { + public static readonly Bindable START_TIME_MS = new Bindable(); + public static readonly Bindable END_TIME_MS = new Bindable(); + + public static void Set(double startTimeMs, double endTimeMs) + { + if (endTimeMs <= startTimeMs) + return; + + START_TIME_MS.Value = startTimeMs; + END_TIME_MS.Value = endTimeMs; + } + + public static bool TryGet(out double startTimeMs, out double endTimeMs) + { + startTimeMs = START_TIME_MS.Value ?? 0; + endTimeMs = END_TIME_MS.Value ?? 0; + + return START_TIME_MS.Value.HasValue && END_TIME_MS.Value.HasValue && endTimeMs > startTimeMs; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Configuration/ScalingGameMode.cs b/osu.Game/LAsEzExtensions/Configuration/ScalingGameMode.cs new file mode 100644 index 0000000000..fdbb0731fa --- /dev/null +++ b/osu.Game/LAsEzExtensions/Configuration/ScalingGameMode.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.LAsEzExtensions.Configuration +{ + public enum ScalingGameMode + { + Standard, + + Taiko, + + Mania, + + Catch, + } +} diff --git a/osu.Game/LAsEzExtensions/Extensions/OsuSpriteTextExtensions.cs b/osu.Game/LAsEzExtensions/Extensions/OsuSpriteTextExtensions.cs new file mode 100644 index 0000000000..51721f8208 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Extensions/OsuSpriteTextExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Extensions +{ + public static class OsuSpriteTextExtensions + { + public static Container WithUnderline(this OsuSpriteText text, Color4? lineColor = null) + { + Color4 color = lineColor ?? Color4.DodgerBlue; + + return new Container + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Bottom = 5 }, + Children = new Drawable[] + { + text, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Width = 25, + Height = 2, + CornerRadius = 1, + Masking = true, + Margin = new MarginPadding { Top = 2 }, + Colour = color.Opacity(0.8f), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + } + }; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Extensions/SettingsColourExtensions.cs b/osu.Game/LAsEzExtensions/Extensions/SettingsColourExtensions.cs new file mode 100644 index 0000000000..01150577da --- /dev/null +++ b/osu.Game/LAsEzExtensions/Extensions/SettingsColourExtensions.cs @@ -0,0 +1,96 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Screens; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Extensions +{ + public static partial class SettingsColourExtensions + { + public static Container CreateStyledSettingsColour(string label, BindableColour4 current) + { + var backgroundBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.05f) + }; + var hoverContainer = new HoverContainer + { + RelativeSizeAxes = Axes.X, + // AutoSizeAxes = Axes.Y, + Height = 25, + Margin = new MarginPadding { Top = 2, Bottom = 2 }, + Masking = true, + CornerRadius = 6, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 3f, + Colour = Color4.Black.Opacity(0.2f), + Offset = new Vector2(0, 1), + }, + BackgroundBox = backgroundBox, + Children = new Drawable[] + { + backgroundBox, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Position = new Vector2(-85, 0), + // Margin = new MarginPadding { Left = 10f }, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 16), + Colour = Color4.DodgerBlue, + Text = label, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 6, + Child = new EzSettingsColour + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Scale = new Vector2(0.8f), + Current = current, + } + } + } + }; + + return hoverContainer; + } + + private partial class HoverContainer : Container, IHasTooltip + { + public Box BackgroundBox { get; set; } = null!; + + public LocalisableString TooltipText { get; set; } = "全局列颜色方案设置"; + + protected override bool OnHover(osu.Framework.Input.Events.HoverEvent e) + { + BackgroundBox.FadeColour(Color4.White.Opacity(0.1f), 200, Easing.OutQuint); + return false; // 允许事件继续传递 + } + + protected override void OnHoverLost(osu.Framework.Input.Events.HoverLostEvent e) + { + BackgroundBox.FadeColour(Color4.Black.Opacity(0.05f), 200, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/EzLocalTextureFactory.Preload.cs b/osu.Game/LAsEzExtensions/EzLocalTextureFactory.Preload.cs new file mode 100644 index 0000000000..a1a68e096f --- /dev/null +++ b/osu.Game/LAsEzExtensions/EzLocalTextureFactory.Preload.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using osu.Framework.Logging; + +namespace osu.Game.LAsEzExtensions +{ + public partial class EzLocalTextureFactory + { + #region 预加载系统 + + private static readonly string[] preload_components = + { + "whitenote", "bluenote", "greennote", + "noteflare", "noteflaregood", "longnoteflare", + }; + + private static volatile bool isPreloading; + private static volatile bool preloadCompleted; + + public async Task PreloadGameTextures() + { + if (preloadCompleted || isPreloading) return; + + isPreloading = true; + + try + { + string currentNoteSetName = noteSetName.Value; + Logger.Log($"[EzLocalTextureFactory] Starting preload for note set: {currentNoteSetName}", + LoggingTarget.Runtime, LogLevel.Debug); + + var preloadTasks = new List(); + + foreach (string component in preload_components) + { + preloadTasks.Add(Task.Run(() => preloadComponent(component, currentNoteSetName))); + } + + // preloadTasks.Add(Task.Run(preloadStageTextures)); + + await Task.WhenAll(preloadTasks).ConfigureAwait(false); + + preloadCompleted = true; + Logger.Log($"[EzLocalTextureFactory] Preload completed for {preload_components.Length} components", + LoggingTarget.Runtime, LogLevel.Debug); + + Logger.Log($"[EzLocalTextureFactory] Cache stats after preload: {global_cache.Count} frame sets", + LoggingTarget.Runtime, LogLevel.Debug); + } + catch (Exception ex) + { + Logger.Log($"[EzLocalTextureFactory] Preload failed: {ex.Message}", + LoggingTarget.Runtime, LogLevel.Error); + } + finally + { + isPreloading = false; + } + } + + private void preloadComponent(string component, string noteSetName) + { + try + { + string cacheKey = $"{noteSetName}_{component}"; + + if (global_cache.ContainsKey(cacheKey)) return; + + var frames = loadNotesFrames(component, noteSetName); + + if (frames.Count > 0) + { + var newEntry = new CacheEntry(frames, true); + global_cache.TryAdd(cacheKey, newEntry); + } + } + catch (Exception ex) + { + Logger.Log($"[EzLocalTextureFactory] Failed to preload {component}: {ex.Message}", + LoggingTarget.Runtime, LogLevel.Error); + } + } + + // private async Task preloadStageTextures() + // { + // try + // { + // string currentStageName = stageName.Value; + // Logger.Log($"[EzLocalTextureFactory] Preloading stage textures for: {currentStageName}", + // LoggingTarget.Runtime, LogLevel.Debug); + // + // var stagePaths = new List + // { + // $"Stage/{currentStageName}/Stage/fivekey/Body", + // $"Stage/{currentStageName}/Stage/GrooveLight", + // $"Stage/{currentStageName}/Stage/eightkey/keybase/KeyBase", + // $"Stage/{currentStageName}/Stage/eightkey/keypress/KeyBase", + // $"Stage/{currentStageName}/Stage/eightkey/keypress/KeyPress", + // }; + // + // foreach (string path in stagePaths) + // { + // // For stage textures, skip preloading to avoid conflicts with runtime loading + // // var texture = largeTextureStore.Get($"{path}.png"); + // // if (texture != null) + // // loadedCount++; + // + // Logger.Log($"[EzLocalTextureFactory] Skipping preload for stage texture {path}", + // LoggingTarget.Runtime, LogLevel.Debug); + // + // // Simulate loading delay if needed + // // await Task.Delay(10).ConfigureAwait(false); + // } + // } + // catch (Exception ex) + // { + // Logger.Log($"[EzLocalTextureFactory] Stage texture preload failed: {ex.Message}", + // LoggingTarget.Runtime, LogLevel.Error); + // } + // } + + private void resetPreloadState() + { + preloadCompleted = false; + isPreloading = false; + } + + #endregion + } +} diff --git a/osu.Game/LAsEzExtensions/EzLocalTextureFactory.cs b/osu.Game/LAsEzExtensions/EzLocalTextureFactory.cs new file mode 100644 index 0000000000..62f28083ed --- /dev/null +++ b/osu.Game/LAsEzExtensions/EzLocalTextureFactory.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Screens; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.LAsEzExtensions +{ + [Cached] + public partial class EzLocalTextureFactory : CompositeDrawable + { + // private const int max_stage_frames = 120; + private const int max_frames_to_load = 240; + private const double default_frame_length = 1000.0 / 60.0 * 4; + private const float default_stage_body_height = 247f; + private const float square_ratio_threshold = 0.75f; + + private static readonly object cleanup_lock = new object(); + private static readonly ConcurrentDictionary global_cache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary note_ratio_cache = new ConcurrentDictionary(); + + private readonly Dictionary loaderStoreCache = new Dictionary(); + private readonly Ez2ConfigManager ezSkinConfig; + + private readonly LargeTextureStore stageTextureStore; + private readonly TextureStore textureStore; + + private readonly Bindable noteSetName; + private readonly Bindable stageName; + private readonly Bindable columnWidth; + private readonly Bindable specialFactor; + private readonly Bindable hitPositonBindable; + private readonly Bindable noteHeightScaleToWidth; + + private Vector2 tempNoteSize; + + private readonly struct CacheEntry : IEquatable + { + public readonly List? Textures; + public readonly bool HasComponent; + public readonly DateTime LastAccess; + + public CacheEntry(List? textures, bool hasComponent) + { + Textures = textures; + HasComponent = hasComponent; + LastAccess = DateTime.UtcNow; + } + + public CacheEntry UpdateAccess() => new CacheEntry(Textures, HasComponent); + + public bool Equals(CacheEntry other) => + ReferenceEquals(Textures, other.Textures) && + HasComponent == other.HasComponent && + LastAccess.Equals(other.LastAccess); + + public override bool Equals(object? obj) => obj is CacheEntry other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Textures, HasComponent, LastAccess); + } + + public EzLocalTextureFactory(Ez2ConfigManager ezSkinConfig, + IRenderer renderer, + Storage hostStorage) + { + this.ezSkinConfig = ezSkinConfig; + + const string base_path = "EzResources/"; + + if (!loaderStoreCache.TryGetValue(base_path, out var baseTextureLoaderStore)) + { + var baseStorage = hostStorage.GetStorageForDirectory(base_path); + var baseFileStore = new StorageBackedResourceStore(baseStorage); + baseTextureLoaderStore = new TextureLoaderStore(baseFileStore); + loaderStoreCache[base_path] = baseTextureLoaderStore; + } + + // 创建尺寸限制的纹理加载器(使用官方的 MaxDimensionLimitedTextureLoaderStore) + var limitedLoader = new MaxDimensionLimitedTextureLoaderStore(baseTextureLoaderStore); + textureStore = new TextureStore(renderer, limitedLoader); + textureStore.AddTextureSource(baseTextureLoaderStore); + + stageTextureStore = new LargeTextureStore(renderer, limitedLoader); + stageTextureStore.AddTextureSource(baseTextureLoaderStore); + + noteSetName = ezSkinConfig.GetBindable(Ez2Setting.NoteSetName); + stageName = ezSkinConfig.GetBindable(Ez2Setting.StageName); + columnWidth = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + specialFactor = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + hitPositonBindable = ezSkinConfig.GetBindable(Ez2Setting.HitPosition); + noteHeightScaleToWidth = ezSkinConfig.GetBindable(Ez2Setting.NoteHeightScaleToWidth); + + // noteSetName.BindValueChanged(e => + // { + // clearRelatedCache(e.OldValue); + // }); + + // stageName.BindValueChanged(e => + // { + // }); + } + + #region 工具方法 + + // public bool IsSquareNote(string component) => GetRatio(component) >= square_ratio_threshold; + + public float GetRatio() + { + string noteSet = noteSetName.Value; + + float ratio = note_ratio_cache.GetOrAdd(noteSet, ns => + { + string path = getComponentPath(ns, "whitenote"); // Use a representative component + float calculatedRatio = calculateRatio(path); + return calculatedRatio >= square_ratio_threshold ? 1.0f : calculatedRatio; + }); + + return ratio; + } + + public Bindable GetNoteSize(int keyMode, int columnIndex, bool? noSpecial = null) + { + var result = new Bindable(); + float ratio = GetRatio(); + bool isSpecialColumn = noSpecial != true && ezSkinConfig.IsSpecialColumn(keyMode, columnIndex); + + void updateNoteSize() + { + float x = (float)(columnWidth.Value * (isSpecialColumn ? specialFactor.Value : 1.0)); + float y = (float)noteHeightScaleToWidth.Value * ratio * x; + tempNoteSize.X = x; + tempNoteSize.Y = y; + result.Value = tempNoteSize; + } + + columnWidth.BindValueChanged(_ => updateNoteSize()); + specialFactor.BindValueChanged(_ => updateNoteSize()); + noteHeightScaleToWidth.BindValueChanged(_ => updateNoteSize()); + + updateNoteSize(); + return result; + } + + private string getComponentPath(string noteName, string component) + { + return $"note/{noteName}/{component}"; + } + + private float calculateRatio(string path) + { + try + { + if (global_cache.TryGetValue(path, out var cacheEntry) && + cacheEntry.Textures is not null && cacheEntry.Textures.Count > 0) + { + var firstFrame = cacheEntry.Textures[0]; + return firstFrame.Height / (float)firstFrame.Width; + } + + var sb = new StringBuilder(path.Length + 8); + Texture? texture = textureStore.Get(sb.Append(path).Append("/000.png").ToString()) ?? + textureStore.Get(sb.Clear().Append(path).Append("/001.png").ToString()); + + return texture?.Height / (texture?.Width ?? 1f) ?? 1.0f; + } + catch (Exception ex) + { + Logger.Log($"Error calculating ratio for {path}: {ex.Message}", + LoggingTarget.Runtime, LogLevel.Error); + return 1.0f; + } + } + + private static bool isStageTexturePath(string texturePath) + { + return texturePath.Contains("Stage/") || + texturePath.Contains("/Body") || + texturePath.Contains("/GrooveLight") || + texturePath.Contains("keybase") || + texturePath.Contains("keypress") || + texturePath.Contains("_OverObject"); + } + + #endregion + + #region 组件构造 + + /// + /// 构造Note、光效等动画组件。 + /// + /// + /// 是否为光效 + /// + public virtual TextureAnimation CreateAnimation(string component, bool? isFlare = null) + { + bool isHit = isFlare is true; + + var animation = new TextureAnimation + { + Anchor = isHit ? Anchor.BottomCentre : Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = isHit ? Axes.None : Axes.Both, + FillMode = isHit ? FillMode.Fit : FillMode.Stretch, + Loop = !isHit + }; + + if (!isHit) + { + animation.DefaultFrameLength = default_frame_length; + // animation.Blending = BlendingParameters.Inherit; + } + + if (component == "JudgementLine") + FillMode = FillMode.Fill; + + var frames = getCachedTextureFrames(component); + animation.AddFrames(frames); + + return animation; + } + + private List getCachedTextureFrames(string component) + { + string currentNoteSetName = noteSetName.Value; + string cacheKey = $"{currentNoteSetName}_{component}"; + return getOrAddCachedFrames(cacheKey, () => loadNotesFrames(component, currentNoteSetName)); + } + + private List loadNotesFrames(string component, string noteSetName) + { + var frames = new List(); + string basePath = $"note/{noteSetName}/{component}"; + + if (component != "JudgementLine") + { + for (int i = 0; i < max_frames_to_load; i++) + { + string frameFile = $"{basePath}/{i:D3}.png"; + var texture = textureStore.Get(frameFile); + + if (texture == null) break; + + if (texture.Width < 500) texture.ScaleAdjust = 0.5f; // 大纹理缩小加载,防止内存暴涨 + + frames.Add(texture); + } + } + else + { + string frameFile = $"{basePath}.png"; + Logger.Log($"[EzLocalTextureFactory] Loading JudgementLine Frame: {frameFile}", + LoggingTarget.Runtime, LogLevel.Debug); + var texture = textureStore.Get(frameFile); + + frames.Add(texture); + } + + return new List(frames); + } + + #endregion + + #region Stage Creation + + public virtual Container CreateStage(string component) + { + var container = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + + // AutoSizeAxes = Axes.X, + // Height = default_stage_body_height, + // Masking = true, + }; + // hitPositonBindable.BindValueChanged(_ => + // { + // container.Y = ezSkinConfig.DefaultHitPosition - (float)hitPositonBindable.Value; + // }, true); + + string basePath = $"Stage/{stageName.Value}/Stage"; + + addStageComponent(container, $"{basePath}/eightkey/{component}"); + addStageComponent(container, $"{basePath}/GrooveLight"); //此纹理需要修改正片叠底 + addStageComponent(container, $"{basePath}/{stageName.Value}_OverObject/{stageName.Value}_OverObject"); + + return container; + } + + private void addStageComponent(Container container, string basePath) + { + var frames = loadStageComponentFrames(basePath); + var animation = new TextureAnimation + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Y = 384f + 247f, + // RelativeSizeAxes = Axes.None, + // FillMode = FillMode.Fill, + }; + if (basePath.Contains("GrooveLight")) + animation.Blending = BlendingParameters.Additive; + + animation.Loop = frames.Count > 1; + animation.Scale = frames.Count > 1 + ? new Vector2(2f) + : Vector2.One; + + animation.AddFrames(frames); + container.Add(animation); + } + + private List loadStageComponentFrames(string basePath) + { + var frames = new List(); + + for (int i = 0;; i++) + { + Texture? texture = textureStore.Get($"{basePath}_{i}.png"); + + if (texture == null) break; + + Logger.Log($"[EzLocalTextureFactory] Added Stage Frames: {basePath}_{i}.png", + LoggingTarget.Runtime, LogLevel.Debug); + + frames.Add(texture); + } + + if (frames.Count == 0) + { + Texture? texture = stageTextureStore.Get($"{basePath}.png"); + + if (texture != null) + { + Logger.Log($"[EzLocalTextureFactory] Added Stage Frame: {basePath}", + LoggingTarget.Runtime, LogLevel.Debug); + frames.Add(texture); + } + } + + return frames; + } + + public virtual TextureAnimation CreateStageKeys(string component, string? keySuffix = null) + { + var frames = getCachedStageKeysFrames(component, keySuffix); + var animation = new TextureAnimation + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + FillMode = FillMode.Stretch, + DefaultFrameLength = default_frame_length * 4, + }; + animation.AddFrames(frames); + + return animation; + } + + private List getCachedStageKeysFrames(string component, string? keySuffix = null) + { + string currentStageName = stageName.Value; + string cacheKey = $"{currentStageName}_{component}_{keySuffix}"; + return getOrAddCachedFrames(cacheKey, () => loadStageKeysFrames(component, keySuffix)); + } + + private List loadStageKeysFrames(string component, string? keySuffix = null) + { + var frames = new List(); + string currentStageName = stageName.Value; + + string[] pathsToTry = + { + $"Stage/{currentStageName}/Stage/eightkey/keybase/{component}", + $"Stage/{currentStageName}/Stage/eightkey/keypress/{component}", + $"Stage/{currentStageName}/Stage/eightkey/keybase/{component}_{keySuffix}", + $"Stage/{currentStageName}/Stage/eightkey/keypress/{component}_{keySuffix}", + }; + + foreach (string basePath in pathsToTry) + { + for (int i = 0;; i++) + { + Texture? texture = textureStore.Get($"{basePath}_frame{i}.png"); + + if (texture == null) break; + + Logger.Log($"[EzLocalTextureFactory] Added Keys Frames: {basePath}_{i}", + LoggingTarget.Runtime, LogLevel.Debug); + + frames.Add(texture); + } + + // 如果没有帧,加载单个纹理作为单帧 + if (frames.Count == 0) + { + Texture? texture = textureStore.Get($"{basePath}.png"); + + if (texture != null) + { + Logger.Log($"[EzLocalTextureFactory] Added Keys Frame: {basePath}", + LoggingTarget.Runtime, LogLevel.Debug); + frames.Add(texture); + } + } + } + + return frames; + } + + #endregion + + #region 缓存管理 + + private List getOrAddCachedFrames(string cacheKey, Func> factory) + { + // 双重检查锁定以确保线程安全,获取或添加缓存纹理 + lock (cleanup_lock) + { + if (global_cache.TryGetValue(cacheKey, out var cachedEntry)) + { + if (cachedEntry.Textures != null && cachedEntry.Textures.Count > 0) + { + global_cache.TryUpdate(cacheKey, cachedEntry.UpdateAccess(), cachedEntry); + return cachedEntry.Textures; + } + + global_cache.TryRemove(cacheKey, out _); + } + + var frames = factory(); + + if (frames.Count > 0) + { + Logger.Log($"[EzLocalTextureFactory] global_cache Caching {frames.Count} frames for {cacheKey}", + LoggingTarget.Runtime, LogLevel.Debug); + + var newEntry = new CacheEntry(frames, true); + global_cache.TryAdd(cacheKey, newEntry); + } + + return frames; + } + } + + public static void ClearGlobalCache() + { + int count1 = note_ratio_cache.Count; + + if (count1 > 0) + { + Logger.Log($"[EzLocalTextureFactory] Clearing note_ratio_cache ({count1})", + LoggingTarget.Runtime, LogLevel.Debug); + + note_ratio_cache.Clear(); + } + + int count2 = global_cache.Count; + + if (count2 > 0) + { + Logger.Log($"[EzLocalTextureFactory] Clearing global_cache ({count2})", + LoggingTarget.Runtime, LogLevel.Debug); + global_cache.Clear(); + } + } + + #endregion + + protected override void Dispose(bool isDisposing) + { + if (isDisposing) + { + // 只清理实例级别的缓存,全局缓存留给 ClearGlobalCache 处理 + foreach (var loader in loaderStoreCache.Values) + loader.Dispose(); + + loaderStoreCache.Clear(); + note_ratio_cache.Clear(); + } + + base.Dispose(isDisposing); + } + } + + public enum EzAnimationType + { + Note, + Hit, + Stage, + Key, + Health, + } +} diff --git a/osu.Game/LAsEzExtensions/EzToCollection.cs b/osu.Game/LAsEzExtensions/EzToCollection.cs new file mode 100644 index 0000000000..223c23fccf --- /dev/null +++ b/osu.Game/LAsEzExtensions/EzToCollection.cs @@ -0,0 +1,276 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK; +using Realms; + +namespace osu.Game.LAsEzExtensions +{ + public partial class EzToCollection : OsuDropdown + { + protected virtual bool ShowManageCollectionsItem => true; + + public Action? RequestFilter { private get; set; } + + private readonly BindableList filters = new BindableList(); + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IDisposable? realmSubscription; + + private readonly CollectionFilterMenuItem allBeatmapsItem = new AllBeatmapsCollectionFilterMenuItem(); + + public EzToCollection() + { + ItemSource = filters; + + Current.Value = allBeatmapsItem; + AlwaysShowSearchBar = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); + + Current.BindValueChanged(selectionChanged); + } + + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) + { + if (changes == null) + { + filters.Clear(); + filters.Add(allBeatmapsItem); + filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); + if (ShowManageCollectionsItem) + filters.Add(new ManageCollectionsFilterMenuItem()); + } + else + { + foreach (int i in changes.DeletedIndices.OrderDescending()) + filters.RemoveAt(i + 1); + + foreach (int i in changes.InsertedIndices) + filters.Insert(i + 1, new CollectionFilterMenuItem(collections[i].ToLive(realm))); + + var selectedItem = SelectedItem?.Value; + + foreach (int i in changes.NewModifiedIndices) + { + var updatedItem = collections[i]; + + // This is responsible for updating the state of the +/- button and the collection's name. + // TODO: we can probably make the menu items update with changes to avoid this. + filters.RemoveAt(i + 1); + filters.Insert(i + 1, new CollectionFilterMenuItem(updatedItem.ToLive(realm))); + + if (updatedItem.ID == selectedItem?.Collection?.ID) + { + // This current update and schedule is required to work around dropdown headers not updating text even when the selected item + // changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue + // a warning that it's going to be a frustrating journey. + Current.Value = allBeatmapsItem; + Schedule(() => + { + // current may have changed before the scheduled call is run. + if (Current.Value != allBeatmapsItem) + return; + + Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0]; + }); + + // Trigger an external re-filter if the current item was in the change set. + RequestFilter?.Invoke(); + break; + } + } + } + } + + private Live? lastFiltered; + + private void selectionChanged(ValueChangedEvent filter) + { + // May be null during .Clear(). + if (filter.NewValue.IsNull()) + return; + + // Never select the manage collection filter - rollback to the previous filter. + // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. + if (filter.NewValue is ManageCollectionsFilterMenuItem) + { + Current.Value = filter.OldValue; + manageCollectionsDialog?.Show(); + return; + } + + var newCollection = filter.NewValue.Collection; + + // This dropdown be weird. + // We only care about filtering if the actual collection has changed. + if (newCollection != lastFiltered) + { + RequestFilter?.Invoke(); + lastFiltered = newCollection; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + realmSubscription?.Dispose(); + } + + protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName; + + protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader(); + + protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); + + protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); + + protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); + + public partial class CollectionDropdownHeader : OsuDropdownHeader + { + public CollectionDropdownHeader() + { + Height = 25; + Chevron.Size = new Vector2(12); + Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 8 }; + } + } + + protected partial class CollectionDropdownMenu : OsuDropdownMenu + { + public CollectionDropdownMenu() + { + MaxHeight = 200; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownDrawableMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; + } + + protected partial class CollectionDropdownDrawableMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem + { + private IconButton addOrRemoveButton = null!; + + private bool beatmapInCollection; + + private readonly Live? collection; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public CollectionDropdownDrawableMenuItem(MenuItem item) + : base(item) + { + collection = ((DropdownMenuItem)item).Value.Collection; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(addOrRemoveButton = new NoFocusChangeIconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -OsuScrollContainer.SCROLL_BAR_WIDTH, + Scale = new Vector2(0.65f), + Action = addOrRemove, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (collection != null) + { + beatmap.BindValueChanged(_ => + { + beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash)); + + addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; + addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; + addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; + + updateButtonVisibility(); + }, true); + } + + updateButtonVisibility(); + } + + protected override bool OnHover(HoverEvent e) + { + updateButtonVisibility(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateButtonVisibility(); + base.OnHoverLost(e); + } + + protected override void OnSelectChange() + { + base.OnSelectChange(); + updateButtonVisibility(); + } + + private void updateButtonVisibility() + { + if (collection == null) + addOrRemoveButton.Alpha = 0; + else + addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; + } + + private void addOrRemove() + { + Debug.Assert(collection != null); + + collection.PerformWrite(c => + { + if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) + c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); + }); + } + + protected override Drawable CreateContent() => (Content)base.CreateContent(); + + private partial class NoFocusChangeIconButton : IconButton + { + public override bool ChangeFocusOnClick => false; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzComHitResultScore.cs b/osu.Game/LAsEzExtensions/HUD/EzComHitResultScore.cs new file mode 100644 index 0000000000..b6afe0e8fc --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzComHitResultScore.cs @@ -0,0 +1,532 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.JudgementCounter; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzComHitResultScore : CompositeDrawable, ISerialisableDrawable //, IPreviewable //, IAnimatableJudgement + { + public bool UsesFixedAnchor { get; set; } + + [SettingSource("Playback FPS", "The FPS value of this Animation")] + public BindableNumber PlaybackFps { get; } = new BindableNumber(60) + { + MinValue = 1, + MaxValue = 240, + Precision = 1f + }; + + [SettingSource("HitResult Text Font", "HitResult Text Font", SettingControlType = typeof(EzSelectorEnumList))] + public Bindable NameDropdown { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + // private EzComsPreviewOverlay previewOverlay = null!; + // private IconButton previewButton = null!; + + // 预览纹理基础路径 + public string TextureBasePath => @"EzResources/enumBase/enumJudgement"; + + // [SettingSource("Effect Type", "Effect Type")] + // public Bindable Effect { get; } = new Bindable(EzComEffectType.Scale); + // + // [SettingSource("Effect Origin", "Effect Origin", SettingControlType = typeof(AnchorDropdown))] + // public Bindable EffectOrigin { get; } = new Bindable(Anchor.TopCentre) + // { + // Default = Anchor.TopCentre, + // Value = Anchor.TopCentre + // }; + + private Vector2 dragStartPosition; + private bool isDragging; + public Sprite? StaticSprite; + private Container? fullComboSprite; + private Sample? fullComboSound; + + [Resolved] + private TextureStore textures { get; set; } = null!; + + [Resolved] + private ScoreProcessor processor { get; set; } = null!; + + [Resolved(canBeNull: true)] + private GameplayClockContainer gameplayClockContainer { get; set; } = null!; + + [Resolved] + private JudgementCountController judgementCountController { get; set; } = null!; + + [Resolved] + private ISampleStore sampleStore { get; set; } = null!; + + public EzComHitResultScore() + { + Size = new Vector2(200, 50); + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + // Schedule(() => + // { + // AddInternal(previewButton = new IconButton + // { + // Anchor = Anchor.TopRight, + // Origin = Anchor.TopRight, + // Position = new Vector2(-5, 5), + // Icon = FontAwesome.Solid.Eye, + // Action = showPreview + // }); + // + // previewOverlay = new EzComsPreviewOverlay(this); + // game.Add(previewOverlay); + // }); + + AlwaysPresent = true; + + // Schedule(() => + // { + + // }); + } + + // private void showPreview() => previewOverlay.Show(); + + // public Drawable CreatePreviewDrawable(EzSelectorGameThemeSet name) + // { + // var container = new Container + // { + // AutoSizeAxes = Axes.Both, + // Child = new FillFlowContainer + // { + // AutoSizeAxes = Axes.Both, + // Direction = FillDirection.Horizontal, + // Spacing = new Vector2(10), + // Children = new[] + // { + // createPreviewSprite(name, HitResult.Perfect), + // createPreviewSprite(name, HitResult.Great), + // createPreviewSprite(name, HitResult.Good), + // createPreviewSprite(name, HitResult.Ok), + // createPreviewSprite(name, HitResult.Meh), + // createPreviewSprite(name, HitResult.Miss) + // } + // } + // }; + // + // return container; + // } + // + // private Sprite createPreviewSprite(EzSelectorGameThemeSet name, HitResult result) + // { + // string basePath = $@"EzResources/enumBase/enumJudgement/{name}"; + // var texture = textures.Get($"{basePath}.png"); + // + // return new Sprite + // { + // Texture = texture, + // Size = new Vector2(50), + // Alpha = 1 + // }; + // } + + protected override void LoadComplete() + { + base.LoadComplete(); + + gameplayClockContainer.OnSeek += Clear; + + processor.NewJudgement += processorNewJudgement; + + NameDropdown.BindValueChanged(_ => + { + ClearInternal(true); + + StaticSprite?.Invalidate(); + }, true); + } + + private void processorNewJudgement(JudgementResult j) + { + Schedule(() => + { + OnNewJudgement(j); + + if (processor.JudgedHits == processor.MaximumCombo) + checkFullCombo(); + }); + } + + protected void OnNewJudgement(JudgementResult judgement) + { + if (!judgement.IsHit || judgement.HitObject.HitWindows?.WindowFor(HitResult.Miss) == 0) + return; + + if (!judgement.Type.IsScorable() || judgement.Type.IsBonus()) + return; + + if (judgement.Type == HitResult.Meh && + (judgement.HitObject.HitWindows?.WindowFor(HitResult.Meh) + == judgement.HitObject.HitWindows?.WindowFor(HitResult.Miss))) + return; + + // 清除内部元素前先结束所有变换 + StaticSprite?.FinishTransforms(); + + ClearInternal(); + StaticSprite = null; + + var judgementText = CreateJudgementTexture(judgement.Type); + AddInternal(judgementText); + } + + protected Drawable CreateJudgementTexture(HitResult result) + { + string resultName = getHitResultPath(result); + string baseDir = $@"EzResources/GameTheme/{NameDropdown.Value}/judgement"; + + // 尝试多种大小写变体以处理文件名大小写不确定性 + // 由于 TextureStore 的 Get 方法区分大小写,我们尝试常见变体:小写(默认)和大写 + string[] possibleResultNames = { resultName, resultName.ToUpperInvariant() }; + Texture? singleTexture = null; + + foreach (string rn in possibleResultNames) + { + string path = $"{baseDir}/{rn}"; + singleTexture = textures.Get($"{path}.png"); + if (singleTexture != null) + break; + } + + if (singleTexture != null) + { + StaticSprite = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.5f), + Texture = singleTexture, + Alpha = 0, + // 设置混合模式 + Blending = new BlendingParameters + { + // 1. 标准透明混合(最常用) + // Source = BlendingType.SrcAlpha, + // Destination = BlendingType.OneMinusSrcAlpha, + + // 2. 加法混合(发光效果) + Source = BlendingType.SrcAlpha, + Destination = BlendingType.One + + // 3. 减法混合(暗色透明) + // Source = BlendingType.Zero, + // Destination = BlendingType.OneMinusSrcColor, + + // 4. 纯色叠加(忽略黑色) + // Source = BlendingType.One, + // Destination = BlendingType.One, + + // 5. 柔和混合 + // Source = BlendingType.DstColor, + // Destination = BlendingType.One, + } + }; + + Schedule(() => + { + PlayAnimation(result, StaticSprite); + }); + + return StaticSprite; + } + + // 不存在单张图片时,尝试加载动画帧 + var animation = new TextureAnimation + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + DefaultFrameLength = 1000 / PlaybackFps.Value, + Loop = false + }; + + // 对于动画帧,也尝试多种大小写变体 + foreach (string rn in possibleResultNames) + { + string path = $"{baseDir}/{rn}"; + bool foundFrames = false; + + for (int i = 0;; i++) + { + var texture = textures.Get($@"{path}/frame_{i}"); + if (texture == null) + break; + + animation.AddFrame(texture); + foundFrames = true; + } + + if (foundFrames) + break; // 如果找到帧,使用这个路径 + } + + PlaybackFps.BindValueChanged(fps => + { + animation.DefaultFrameLength = 1000 / fps.NewValue; + }, true); + + PlayAnimationGif(result, animation); + + animation.OnUpdate += _ => + { + if (animation.CurrentFrameIndex == animation.FrameCount - 1) + animation.Expire(); + }; + + return animation; + } + + private string getHitResultPath(HitResult hitResult) + { + string resultName = hitResult switch + { + HitResult.Pool => "Miss", + HitResult.Miss => "Miss", + HitResult.Meh => "Fail", + HitResult.Ok => "Fail", + HitResult.Good => "Good", + HitResult.Great => "Cool", + HitResult.Perfect => "Kool", + _ => string.Empty + }; + + return resultName; + } + + private void checkFullCombo() + { + var missCounter = judgementCountController.Counters + .FirstOrDefault(counter => counter.Types.Contains(HitResult.Miss)); + + if (missCounter.ResultCount.Value == 0) + { + fullComboSprite = new Container + { + Child = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.5f), + Alpha = 1, + Texture = textures.Get("EzResources/AllCombo/ALL-COMBO2.png") + } + }; + + AddInternal(fullComboSprite); + + fullComboSound = sampleStore.Get("EzResources/AllCombo/full_combo_sound"); + fullComboSprite.FadeIn(50).Then().FadeOut(5000); + + fullComboSound?.Play(); + } + } + + public virtual void PlayAnimationGif(HitResult hitResult, Drawable drawable) + { + const float flash_speed = 60f; + applyFadeEffect(hitResult, drawable, flash_speed); + } + + private void applyFadeEffect(HitResult hitResult, Drawable drawable, double flashSpeed) + { + if (!drawable.IsLoaded) + return; + + var colors = hitResult switch + { + HitResult.Pool => new[] { Color4.Purple, Color4.MediumPurple }, + HitResult.Miss => new[] { Color4.Red, Color4.IndianRed }, + HitResult.Meh => new[] { Color4.Purple, Color4.MediumPurple }, + HitResult.Ok => new[] { Color4.ForestGreen, Color4.SeaGreen }, + HitResult.Good => new[] { Color4.Green, Color4.LightGreen }, + HitResult.Great => new[] { Color4.AliceBlue, Color4.LightSkyBlue }, + HitResult.Perfect => new[] { Color4.LightBlue, Color4.LightGreen }, + _ => new[] { Color4.White } + }; + + if (drawable is TextureAnimation) + { + drawable.FadeColour(colors[0], 0); + var sequence = drawable.FadeColour(colors[0], flashSpeed, Easing.OutQuint); + + for (int i = 1; i < colors.Length; i++) sequence = sequence.Then().FadeColour(colors[i], flashSpeed, Easing.OutQuint); + } + else + { + // // 保持原有透明度,只将颜色调整为 20% 强度 + // var fadedColors = colors.Select(c => new Color4( + // c.R * 0.5f + 0.5f, // 将颜色与白色混合,保持 20% 的原色 + // c.G * 0.5f + 0.5f, + // c.B * 0.5f + 0.5f, + // 1f)).ToArray(); + float[] weakerAlphas = new[] { 1f, 0.8f }; + + drawable.FadeTo(weakerAlphas[0], 0); + var sequence = drawable.FadeTo(weakerAlphas[0], flashSpeed, Easing.OutQuint); + + for (int i = 1; i < weakerAlphas.Length; i++) sequence = sequence.Then().FadeTo(weakerAlphas[i], flashSpeed, Easing.OutQuint); + } + } + + public virtual void PlayAnimation(HitResult hitResult, Drawable drawable) + { + double flashSpeed = PlaybackFps.Value * 2; + applyFadeEffect(hitResult, drawable, flashSpeed); + + switch (hitResult) + { + case HitResult.Perfect: + // 中心直接绘制最大状态,向上移动并拉长压扁消失 + applyEzStyleEffect(drawable, new Vector2(1.25f), 20); + break; + + case HitResult.Great: + // 中心绘制,稍微放大后拉长压扁消失 + applyEzStyleEffect(drawable, new Vector2(1.1f)); + break; + + case HitResult.Good: + // 中心小状态,向上放大并移动后拉长压扁消失 + applyEzStyleEffect(drawable, new Vector2(1f)); + break; + + case HitResult.Ok: + case HitResult.Meh: + // 中心小状态,放大并向下移动后拉长压扁消失 + applyEzStyleEffect(drawable, new Vector2(1f)); + break; + + case HitResult.Pool: + case HitResult.Miss: + // 中心小状态,放大后快速消失 + applyEzStyleEffect(drawable, new Vector2(1f)); + break; + + default: + applyEzStyleEffect(drawable, new Vector2(1.2f)); + break; + } + } + + private void applyEzStyleEffect(Drawable drawable, Vector2 scaleUp, float moveDistance = 0) + { + // 先结束之前的所有变换 + drawable.FinishTransforms(); + + var finalScale = new Vector2(1.5f, 0.05f); + const double scale_phase_duration = 125; // 缩放 + const double transform_phase_duration = 150; // 变形动画总时间 + + const double overlap_time = 5; + + // 按分配变形动画时间 + const double second_phase_duration = transform_phase_duration * 0.7; + const double third_phase_duration = transform_phase_duration * 0.3; + + // 计算第二步和第三步的开始时间 + const double second_phase_start = scale_phase_duration - overlap_time; + const double third_phase_start = second_phase_start + second_phase_duration - overlap_time; + + // 计算第二步的中间缩放值(完成70%的变形) + var midScale = new Vector2( + 1.0f + ((finalScale.X - 1.0f) * 0.7f), + 1.0f - ((1.0f - finalScale.Y) * 0.7f) + ); + + // 重置状态 + drawable.Alpha = 1; + drawable.Scale = scaleUp; + drawable.Position = Vector2.Zero; + + drawable + .Delay(2) + // 第一步:放大动画,同时执行位移 + .ScaleTo(new Vector2(1.0f), scale_phase_duration, Easing.OutQuint) + .MoveTo(new Vector2(0, -moveDistance), scale_phase_duration, Easing.OutQuint); + + using (drawable.BeginDelayedSequence(second_phase_start)) + { + drawable + // 第二步:完成70%的扁平化变形 + .TransformTo(nameof(Scale), midScale, second_phase_duration, Easing.InQuint); + } + + using (drawable.BeginDelayedSequence(third_phase_start)) + { + drawable + // 第三步:完成剩余30%变形并淡出 + .TransformTo(nameof(Scale), finalScale, third_phase_duration, Easing.InQuint) + .FadeOut(third_phase_duration - 5, Easing.InQuint); + } + } + + #region Other + + protected virtual void Clear() + { + FinishTransforms(true); + + ClearInternal(); + } + + protected override void Dispose(bool isDisposing) + { + PlaybackFps.UnbindAll(); + processor.NewJudgement -= processorNewJudgement; + gameplayClockContainer.OnSeek -= Clear; + base.Dispose(isDisposing); + } + + protected override bool OnDragStart(DragStartEvent e) + { + dragStartPosition = e.ScreenSpaceMousePosition; + isDragging = true; + return true; + } + + protected override void OnDrag(DragEvent e) + { + if (isDragging) + { + var delta = e.ScreenSpaceMousePosition - dragStartPosition; + Position += delta; + dragStartPosition = e.ScreenSpaceMousePosition; + } + } + + protected override void OnDragEnd(DragEndEvent e) + { + isDragging = false; + } + + #endregion + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzComRadarPanel.cs b/osu.Game/LAsEzExtensions/HUD/EzComRadarPanel.cs new file mode 100644 index 0000000000..e3c82e79d6 --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzComRadarPanel.cs @@ -0,0 +1,313 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Triangle = osu.Framework.Graphics.Primitives.Triangle; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzComRadarPanel : CompositeDrawable, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + private IBindable? difficultyBindable; + private CancellationTokenSource? difficultyCancellationSource; + private ModSettingChangeTracker? modSettingTracker; + + private readonly Bindable[] parameters = new Bindable[6]; + + private Hexagon hexagon = new Hexagon(); + private Hexagon parameterHexagon = new Hexagon(); + + // 定义各参数的最大值,用于归一化处理 + private const float max_bpm = 300f; + private const float max_star = 12f; + private const float max_cs = 10f; + private const float max_od = 10f; + private const float max_dr = 10f; + private const float max_ar = 10f; + + public EzComRadarPanel() + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = new Drawable[] + { + hexagon = new Hexagon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.LightYellow, + Size = new Vector2(200), + }, + parameterHexagon = new Hexagon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Gold, + Size = new Vector2(200), + Alpha = 0.5f, + } + }; + + // 调试用边框 + AddInternal(new Container + { + RelativeSizeAxes = Axes.Both, + BorderColour = Color4.White, + BorderThickness = 2, + Masking = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + hexagon.Invalidate(Invalidation.DrawNode); + parameterHexagon.Invalidate(Invalidation.DrawNode); + + for (int i = 0; i < parameters.Length; i++) + { + parameters[i] = new BindableFloat(); + parameters[i].ValueChanged += _ => updatePanel(); + } + + beatmap.BindValueChanged(b => + { + difficultyCancellationSource?.Cancel(); + difficultyCancellationSource = new CancellationTokenSource(); + + difficultyBindable?.UnbindAll(); + difficultyBindable = difficultyCache.GetBindableDifficulty(b.NewValue.BeatmapInfo, difficultyCancellationSource.Token); + difficultyBindable.BindValueChanged(d => + { + // 归一化参数到0-1 范围 + parameters[0].Value = (float)(beatmap.Value.BeatmapInfo.BPM / max_bpm); + parameters[1].Value = (float)beatmap.Value.BeatmapInfo.StarRating / max_star; + parameters[2].Value = beatmap.Value.BeatmapInfo.Difficulty.CircleSize / max_cs; + parameters[3].Value = beatmap.Value.BeatmapInfo.Difficulty.OverallDifficulty / max_od; + parameters[4].Value = beatmap.Value.BeatmapInfo.Difficulty.DrainRate / max_dr; + parameters[5].Value = beatmap.Value.BeatmapInfo.Difficulty.ApproachRate / max_ar; + }); + }, true); + + mods.BindValueChanged(m => + { + modSettingTracker?.Dispose(); + modSettingTracker = new ModSettingChangeTracker(m.NewValue) + { + SettingChanged = _ => updatePanel() + }; + updatePanel(); + }, true); + + ruleset.BindValueChanged(_ => updatePanel()); + + updatePanel(); + } + + private void updatePanel() + { + hexagon.UpdateVertices(true); // 基础六边形(固定最大范围) + parameterHexagon.UpdateVertices(false, parameters); // 参数六边形 + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + difficultyCancellationSource?.Cancel(); + modSettingTracker?.Dispose(); + } + } + + public partial class Hexagon : Container + { + private Vector2[] vertices = new Vector2[6]; + private Texture? whitePixel; + private bool isBase; + private Bindable[]? parameters; + private readonly string[] parameterNames = { "BPM", "Star", "CS", "OD", "DR", "AR" }; + + public Hexagon() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(IRenderer renderer) + { + whitePixel = createWhitePixelTexture(renderer); + } + + private static Texture createWhitePixelTexture(IRenderer renderer) + { + var texture = renderer.CreateTexture(1, 1, true); + var image = new Image(1, 1); + image[0, 0] = new Rgba32(255, 255, 255, 255); + texture.SetData(new TextureUpload(image)); + return texture; + } + + public void UpdateVertices(bool isBase, Bindable[]? parameters = null) + { + this.isBase = isBase; + this.parameters = parameters; + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new HexagonDrawNode(this); + + private class HexagonDrawNode : DrawNode + { + private readonly Hexagon source; + private readonly Vector2[] vertices = new Vector2[6]; + private Color4 color; + private Texture? texture; + + public HexagonDrawNode(Hexagon hexagon) + : base(hexagon) + { + source = hexagon; + } + + public override void ApplyState() + { + base.ApplyState(); + color = source.Colour; + texture = source.whitePixel; + + // 获取基于相对尺寸的实际绘制区域 + var drawSize = source.DrawSize; + + float radius = Math.Min(drawSize.X, drawSize.Y) / 2 * 0.8f; + Vector2 center = drawSize * 6; + + if (source.isBase) + { + // 基于本地坐标系(原点在中心) + for (int i = 0; i < 6; i++) + { + float angle = MathHelper.DegreesToRadians(60 * i - 90); + vertices[i] = new Vector2( + radius * (float)Math.Cos(angle) + center.X, + radius * (float)Math.Sin(angle) + center.Y + ); + } + } + else if (source.parameters != null) + { + // 参数六边形(本地坐标系) + for (int i = 0; i < 6; i++) + { + float value = Math.Clamp(source.parameters[i].Value, 0, 1); + float angle = MathHelper.DegreesToRadians(60 * i - 90); + vertices[i] = new Vector2( + radius * value * (float)Math.Cos(angle) + center.X, + radius * value * (float)Math.Sin(angle) + center.Y + ); + } + } + } + + protected override void Draw(IRenderer renderer) + { + if (texture == null) return; + + // 框架会自动应用以下变换矩阵: + // DrawInfo.Matrix = 位置矩阵 × 缩放矩阵 × 旋转矩阵 × 锚点偏移 + + // 绘制时直接使用本地坐标系顶点即可 + // 填充六边形 + for (int i = 1; i < vertices.Length - 1; i++) + { + renderer.DrawTriangle( + texture, + new Triangle( + vertices[0], + vertices[i], + vertices[(i + 1) % vertices.Length] + ), + color + ); + } + + // 边线绘制 + foreach (var quad in generateLineQuads()) + { + renderer.DrawQuad( + texture, + quad, + color + ); + } + } + + private IEnumerable generateLineQuads() + { + const float line_thickness = 2f; + + for (int i = 0; i < vertices.Length; i++) + { + Vector2 current = vertices[i]; + Vector2 next = vertices[(i + 1) % vertices.Length]; + + Vector2 dir = next - current; + float length = dir.Length; + if (length < float.Epsilon) continue; + + dir.Normalize(); + + Vector2 perpendicular = new Vector2(-dir.Y, dir.X) * line_thickness / 2; + + yield return new Quad( + current - perpendicular, + current + perpendicular, + next - perpendicular, + next + perpendicular + ); + } + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzComScoreCounter.cs b/osu.Game/LAsEzExtensions/HUD/EzComScoreCounter.cs new file mode 100644 index 0000000000..04aacbfcec --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzComScoreCounter.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzComScoreCounter : GameplayScoreCounter, ISerialisableDrawable + { + protected override double RollingDuration => 250; + + [SettingSource("Font", "Font", SettingControlType = typeof(EzSelectorEnumList))] + public Bindable FontNameDropdown { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] + public Bindable ShowLabel { get; } = new BindableBool(); + + [SettingSource("Alpha", "The alpha value of this box")] + public BindableNumber BoxAlpha { get; } = new BindableNumber(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.01f, + }; + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + + public bool UsesFixedAnchor { get; set; } + public EzScoreText Text = null!; + protected override LocalisableString FormatCount(long count) => count.ToString(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + BoxAlpha.BindValueChanged(alpha => Text.Alpha = alpha.NewValue, true); + AccentColour.BindValueChanged(_ => Text.Colour = AccentColour.Value, true); + + FontNameDropdown.BindValueChanged(e => + { + Text.FontName.Value = e.NewValue; + Text.Invalidate(); // **强制刷新 EzCounterText** + }, true); + + // Padding = new MarginPadding + // { + // Left = 1, + // Right = 1, + // }; + } + + protected override IHasText CreateText() + { + Text = new EzScoreText(); + return Text; + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzComboText.cs b/osu.Game/LAsEzExtensions/HUD/EzComboText.cs new file mode 100644 index 0000000000..a808576f4f --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzComboText.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Skinning.Components; +using osuTK; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzComboText : CompositeDrawable, IHasText + { + public readonly EzGetComboTexture TextPart; + public Bindable FontName { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + public Bindable UseLazerFont { get; } = new Bindable(false); + + public FillFlowContainer TextContainer { get; private set; } + + // public float DefaultWidth { get; set; } = 100; // 默认宽度 + + public LocalisableString Text + { + get => TextPart.Text; + set => TextPart.Text = value; + } + + // public object Spacing { get; set; } + + public EzComboText(Bindable? externalFontName = null) + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + if (externalFontName is not null) + FontName.BindTo(externalFontName); + + TextPart = new EzGetComboTexture(textLookup, FontName); + + InternalChildren = new Drawable[] + { + TextContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + + Children = new Drawable[] + { + TextPart + } + }, + }; + } + + private string textLookup(char c) + { + switch (c) + { + case '.': return @"dot"; + + case '%': return @"percentage"; + + case 'c': return @"Title"; + + case 'e': return @"Early"; + + case 'l': return @"Late"; + + case 'j': return @"judgement"; + + default: return c.ToString(); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + float scale = calculateScale(TextPart.Height); + TextPart.Scale = new Vector2(scale); + + FontName.BindValueChanged(e => + { + TextPart.FontName.Value = e.NewValue; + // textPart.LoadAsync(); // **强制重新加载字体** + scale = calculateScale(TextPart.Height); + TextPart.Scale = new Vector2(scale); + TextPart.Invalidate(); // **确保 UI 立即刷新** + }, true); + } + + private float calculateScale(float textureHeight, float targetHeight = 25f) + { + if (textureHeight <= 0) + return 1; + + return targetHeight / textureHeight; + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzComsPreviewOverlay.cs b/osu.Game/LAsEzExtensions/HUD/EzComsPreviewOverlay.cs new file mode 100644 index 0000000000..09a072a16c --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzComsPreviewOverlay.cs @@ -0,0 +1,146 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.Screens; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzComsPreviewOverlay : OverlayContainer + { + private FillFlowContainer previewList = null!; + private readonly IPreviewable source; + + [Resolved] + private TextureStore textures { get; set; } = null!; + + public EzComsPreviewOverlay(IPreviewable source) + { + this.source = source; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.8f + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20), + Child = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = previewList = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) + } + } + } + }; + + loadPreviews(); + } + + private void loadPreviews() + { + var textureFiles = textures.GetAvailableResources() + .Where(path => path.StartsWith(source.TextureBasePath, StringComparison.Ordinal)) + .Where(path => path.EndsWith(".png", StringComparison.Ordinal)); + + foreach (string texturePath in textureFiles) + { + // 从完整路径中提取纹理名称 + string textureName = texturePath.Replace(source.TextureBasePath + "/", "").Replace(".png", ""); + previewList.Add(new PreviewContainer(textureName, texturePath, source)); + } + } + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + private partial class PreviewContainer : Container + { + private readonly string textureName; + private readonly string texturePath; + private readonly IPreviewable source; + + public PreviewContainer(string textureName, string texturePath, IPreviewable source) + { + this.textureName = textureName; + this.texturePath = texturePath; + this.source = source; + + RelativeSizeAxes = Axes.X; + Height = 120; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White.Opacity(0.1f) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = textureName, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold) + }, + new Sprite + { + Texture = textures.Get(texturePath), + Size = new Vector2(80), + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + } + } + }; + } + + protected override bool OnClick(ClickEvent e) + { + source.TextureNameBindable.Value = textureName; + return true; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzGetComboTexture.cs b/osu.Game/LAsEzExtensions/HUD/EzGetComboTexture.cs new file mode 100644 index 0000000000..d1cf8d6162 --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzGetComboTexture.cs @@ -0,0 +1,148 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Framework.Text; +using osu.Game.Graphics.Sprites; +using osu.Game.Skinning.Components; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzGetComboTexture : OsuSpriteText + { + public Bindable FontName { get; } + + private readonly Func getLookup; + private GlyphStore glyphStore = null!; + + protected override char FixedWidthReferenceCharacter => '6'; + + public EzGetComboTexture(Func getLookup, Bindable fontName) + { + this.getLookup = getLookup; + FontName = fontName; + + Shadow = false; + UseFullGlyphHeight = false; + } + + [BackgroundDependencyLoader] + private void load(GameHost host, IRenderer renderer) + { + // TODO: 需要测试资源管理,以及退回方案 + Storage gameStorage = host.Storage; + + var userResourceStore = new StorageBackedResourceStore(gameStorage); + var textureLoader = new TextureLoaderStore(userResourceStore); + var localSkinStore = new TextureStore(renderer, textureLoader); + + FontName.BindValueChanged(e => + { + Font = new FontUsage(FontName.Value.ToString(), 1); + glyphStore = new GlyphStore(localSkinStore, getLookup); + + foreach (char c in new[] { '.', '%', 'c', 'e', 'l', 'j' }) + glyphStore.Get(FontName.Value.ToString(), c); + for (int i = 0; i < 10; i++) + glyphStore.Get(FontName.Value.ToString(), (char)('0' + i)); + }, true); + } + + protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); + + private class GlyphStore : ITexturedGlyphLookupStore + { + // private readonly string fontFolder; + private readonly TextureStore textures; + private readonly Func getLookup; + + private readonly Dictionary cache = new Dictionary(); + + public GlyphStore(TextureStore textures, Func getLookup) + { + // this.fontFolder = fontFolder; + this.textures = textures; + this.getLookup = getLookup; + } + + public ITexturedCharacterGlyph? Get(string? textureName, char character) + { + if (cache.TryGetValue(character, out var cached)) + return cached; + + string lookup = getLookup(character); + TexturedCharacterGlyph? glyph = null; + + if (textureName != null) + { + string textureNameReplace = textureName.Replace(" ", "_"); + + string[] possiblePaths; + string themeRoot = Path.Combine("EzResources", "GameTheme", textureNameReplace); + + switch (character) + { + case '.': + case '%': + case 'e': + case 'l': + possiblePaths = new[] + { + Path.Combine(themeRoot, lookup) + }; + break; + + case 'c': + possiblePaths = new[] + { + Path.Combine(themeRoot, "combo", lookup) + }; + break; + + case 'j': + possiblePaths = new[] + { + Path.Combine(themeRoot, "judgement") + }; + break; + + default: + possiblePaths = new[] + { + Path.Combine(themeRoot, "combo", "number", lookup), + Path.Combine(themeRoot, "judgement", lookup) + }; + break; + } + + foreach (string path in possiblePaths) + { + var texture = textures.Get(path); + + if (texture != null) + { + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), + texture, 0.125f); + break; + } + } + } + + cache[character] = glyph; + return glyph; + } + + public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzGetScoreTexture.cs b/osu.Game/LAsEzExtensions/HUD/EzGetScoreTexture.cs new file mode 100644 index 0000000000..eae3381624 --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzGetScoreTexture.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Framework.Text; +using osu.Game.Graphics.Sprites; +using osu.Game.Skinning.Components; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzGetScoreTexture : OsuSpriteText + { + public Bindable FontName { get; } + + private readonly Func getLookup; + private GlyphStore glyphStore = null!; + + protected override char FixedWidthReferenceCharacter => '5'; + + public EzGetScoreTexture(Func getLookup, Bindable fontName) + { + this.getLookup = getLookup; + FontName = fontName; + + Shadow = false; + UseFullGlyphHeight = false; + } + + [BackgroundDependencyLoader] + private void load(GameHost host, IRenderer renderer) + { + Storage gameStorage = host.Storage; + + var userResourceStore = new StorageBackedResourceStore(gameStorage); + var textureLoader = new TextureLoaderStore(userResourceStore); + var localSkinStore = new TextureStore(renderer, textureLoader); + // Spacing = new Vector2(-2f, 0f); + FontName.BindValueChanged(e => + { + Font = new FontUsage(FontName.Value.ToString(), 1); + glyphStore = new GlyphStore(localSkinStore, getLookup); + + foreach (char c in new[] { '.', '%' }) + glyphStore.Get(FontName.Value.ToString(), c); + for (int i = 0; i < 10; i++) + glyphStore.Get(FontName.Value.ToString(), (char)('0' + i)); + }, true); + } + + protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); + + private class GlyphStore : ITexturedGlyphLookupStore + { + // private readonly string fontFolder; + private readonly TextureStore textures; + private readonly Func getLookup; + + private readonly Dictionary cache = new Dictionary(); + + public GlyphStore(TextureStore textures, Func getLookup) + { + // this.fontFolder = fontFolder; + this.textures = textures; + this.getLookup = getLookup; + } + + public ITexturedCharacterGlyph? Get(string? textureName, char character) + { + if (cache.TryGetValue(character, out var cached)) + return cached; + + string lookup = getLookup(character); + TexturedCharacterGlyph? glyph = null; + + if (textureName != null) + { + string textureNameReplace = textureName.Replace(" ", "_"); + + string[] possiblePaths; + + string themeRoot = Path.Combine("EzResources", "GameTheme", textureNameReplace); + + switch (character) + { + case '.': + case '%': + default: + possiblePaths = new[] + { + // 对应:.../number/score/{lookup} + Path.Combine(themeRoot, "number", "score", lookup), + + // 对应:.../number/combo/{lookup} + Path.Combine(themeRoot, "number", "combo", lookup), + + // 对应:.../number/{lookup} + Path.Combine(themeRoot, "number", lookup), + }; + break; + } + + foreach (string path in possiblePaths) + { + var texture = textures.Get(path); + + if (texture != null) + { + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), + texture, 0.125f); + break; + } + } + } + + cache[character] = glyph; + return glyph; + } + + public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzHUDAccuracyCounter.cs b/osu.Game/LAsEzExtensions/HUD/EzHUDAccuracyCounter.cs new file mode 100644 index 0000000000..963605a5a8 --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzHUDAccuracyCounter.cs @@ -0,0 +1,237 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation.HUD; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzHUDAccuracyCounter : HitErrorMeter + { + // [SettingSource("Wireframe opacity", "Controls the opacity of the wireframes behind the digits.")] + // public BindableFloat WireframeOpacity { get; } = new BindableFloat(0.25f) + // { + // Precision = 0.01f, + // MinValue = 0, + // MaxValue = 1, + // }; + + // [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.ShowLabel))] + // public Bindable ShowLabel { get; } = new BindableBool(true); + + // [SettingSource("Font", "Font", SettingControlType = typeof(EzSelectorEnumList))] + // public Bindable FontNameDropdown { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font))] + public Bindable Font { get; } = new Bindable(Typeface.Torus); + + [SettingSource("Fill Direction", "排列方向")] + public Bindable FlowDirection { get; } = new Bindable(Direction.Vertical); + + [SettingSource("Accuracy1 display mode")] + public Bindable AccuracyDisplay1 { get; } = new Bindable(EzAccuracyDisplayMode.Standard); + + [SettingSource("Accuracy2 display mode")] + public Bindable AccuracyDisplay2 { get; } = new Bindable(EzAccuracyDisplayMode.Classic); + + [SettingSource("Accuracy3 display mode")] + public Bindable AccuracyDisplay3 { get; } = new Bindable(EzAccuracyDisplayMode.None); + + [SettingSource("Accuracy4 display mode")] + public Bindable AccuracyDisplay4 { get; } = new Bindable(EzAccuracyDisplayMode.None); + + // [Resolved] + // private IBindable beatmap { get; set; } = null!; + + [Resolved] + private ScoreProcessor scoreProcessor { get; set; } = null!; + + // private ScoreProcessor scoreProcessorV1 => scoreProcessor.Ruleset.CreateScoreProcessor(); + + private FillFlowContainer counterFlow = null!; + private readonly PercentageCounter[] accuracyCounters = new PercentageCounter[4]; + private readonly Bindable[] accuracyDisplays; + private readonly OsuSpriteText[] text = new OsuSpriteText[4]; + + // public EzScoreText[] Text = new EzScoreText[4]; + + public EzHUDAccuracyCounter() + { + AutoSizeAxes = Axes.Both; + accuracyDisplays = new[] { AccuracyDisplay1, AccuracyDisplay2, AccuracyDisplay3, AccuracyDisplay4 }; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = counterFlow = new FillFlowContainer + { + Spacing = new Vector2(16), + AutoSizeAxes = Axes.Both, + Direction = getFillDirection(FlowDirection.Value), + }; + + for (int i = 0; i < 4; i++) + { + // Text[i] = new EzScoreText(); + // Text[i].FontName.BindTo(FontNameDropdown); + + text[i] = new OsuSpriteText(); + // Text[i].Text = $"Acc{i + 1}:"; + text[i].Text = accuracyDisplays[i].Value.GetLocalisableDescription(); + text[i].Anchor = Anchor.TopRight; + text[i].Origin = Anchor.TopRight; + + accuracyCounters[i] = new PercentageCounter(); + accuracyCounters[i].Anchor = Anchor.TopRight; + accuracyCounters[i].Origin = Anchor.TopRight; + accuracyCounters[i].Y = 12; // Offset the counter below the label + + var counterContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + text[i], + accuracyCounters[i] + } + }; + + counterFlow.Add(counterContainer); + } + + FlowDirection.BindValueChanged(_ => counterFlow.Direction = getFillDirection(FlowDirection.Value), true); + Font.BindValueChanged(font => + { + for (int i = 0; i < 4; i++) + { + text[i].Font = OsuFont.GetFont(font.NewValue); + } + }, true); + + // FontNameDropdown.BindValueChanged(font => + // { + // foreach (var counter in accuracyCounters) + // counter.Font = font.NewValue.ToString(); // 假设 PercentageCounter 有 Font 属性 + // }, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + for (int i = 0; i < 4; i++) + { + int index = i; + accuracyDisplays[i].BindValueChanged(mode => + { + accuracyCounters[index].Current.UnbindBindings(); + accuracyCounters[index].ClearTransforms(); + + switch (mode.NewValue) + { + case EzAccuracyDisplayMode.Standard: + accuracyCounters[index].Current.UnbindBindings(); + accuracyCounters[index].Current.BindTo(scoreProcessor.Accuracy); + accuracyCounters[index].Show(); + text[index].Text = mode.NewValue.GetLocalisableDescription(); + text[index].Show(); + break; + + case EzAccuracyDisplayMode.MinimumAchievable: + accuracyCounters[index].Current.UnbindBindings(); + accuracyCounters[index].Current.BindTo(scoreProcessor.MinimumAccuracy); + accuracyCounters[index].Show(); + text[index].Text = mode.NewValue.GetLocalisableDescription(); + text[index].Show(); + break; + + case EzAccuracyDisplayMode.MaximumAchievable: + accuracyCounters[index].Current.UnbindBindings(); + accuracyCounters[index].Current.BindTo(scoreProcessor.MaximumAccuracy); + accuracyCounters[index].Show(); + text[index].Text = mode.NewValue.GetLocalisableDescription(); + text[index].Show(); + break; + + case EzAccuracyDisplayMode.Classic: + scoreProcessor.IsLegacyScore = true; + accuracyCounters[index].Current.UnbindBindings(); + accuracyCounters[index].Current.BindTo(scoreProcessor.AccuracyClassic); + accuracyCounters[index].Show(); + text[index].Text = mode.NewValue.GetLocalisableDescription(); + text[index].Show(); + break; + + case EzAccuracyDisplayMode.None: + accuracyCounters[index].Current.UnbindBindings(); + accuracyCounters[index].Hide(); + text[index].Hide(); + break; + } + }, true); + } + } + + protected override void OnNewJudgement(JudgementResult judgement) + { + } + + public override void Clear() + { + } + + private FillDirection getFillDirection(Direction flow) + { + switch (flow) + { + case Direction.Horizontal: + return FillDirection.Horizontal; + + default: + return FillDirection.Vertical; + } + } + + public enum EzAccuracyDisplayMode + { + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeStandard))] + Standard, + + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeMax))] + MaximumAchievable, + + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeMin))] + MinimumAchievable, + + [Description("Classic")] + Classic, + + [Description("None")] + None + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzResourceManager.cs b/osu.Game/LAsEzExtensions/HUD/EzResourceManager.cs new file mode 100644 index 0000000000..e629b3eb03 --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzResourceManager.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Logging; +using osu.Framework.Platform; + +namespace osu.Game.LAsEzExtensions.HUD +{ + /// + /// 全局EZ GameTheme 资源管理器,负责加载和管理EZ皮肤 GameTheme 资源文件夹 + /// + [Cached] + public static class EzResourceManager + { + /// + /// 当 GameTheme 资源重新加载时触发的事件 + /// + public static event Action? OnGameThemesReloaded; + + private const string gametheme_path = @"EzResources\GameTheme"; + + public static List AvailableGameThemes { get; } = new List(); + + private static bool isLoaded; + + /// + /// 加载 GameTheme 资源文件夹 + /// + public static void LoadResources(Storage storage) + { + if (isLoaded) return; + + loadGameThemes(storage); + + isLoaded = true; + Logger.Log("EzResourceManager: GameTheme resources loaded successfully", LoggingTarget.Runtime, LogLevel.Debug); + } + + /// + /// 重新加载 GameTheme 资源(用于刷新) + /// + public static void ReloadResources(Storage storage) + { + AvailableGameThemes.Clear(); + isLoaded = false; + LoadResources(storage); + OnGameThemesReloaded?.Invoke(); + } + + private static void loadGameThemes(Storage storage) + { + try + { + string? dataFolderPath = storage.GetFullPath(gametheme_path); + + if (!Directory.Exists(dataFolderPath)) + { + Directory.CreateDirectory(dataFolderPath); + Logger.Log($"EzResourceManager: Created GameTheme directory: {dataFolderPath}", LoggingTarget.Runtime); + } + + string[] directories = Directory.GetDirectories(dataFolderPath); + AvailableGameThemes.AddRange(directories.Select(Path.GetFileName).Where(name => !string.IsNullOrEmpty(name))!); + + Logger.Log($"EzResourceManager: Found {AvailableGameThemes.Count} GameTheme sets in {dataFolderPath}", LoggingTarget.Runtime, LogLevel.Debug); + } + catch (Exception ex) + { + Logger.Error(ex, "EzResourceManager: Load GameTheme FolderSets Error"); + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzScoreText.cs b/osu.Game/LAsEzExtensions/HUD/EzScoreText.cs new file mode 100644 index 0000000000..28432903fd --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzScoreText.cs @@ -0,0 +1,101 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Skinning.Components; +using osuTK; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzScoreText : CompositeDrawable, IHasText + { + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + public readonly EzGetScoreTexture TextPart; + public Bindable FontName { get; } = new Bindable(EzSelectorEnumList.DEFAULT_NAME); + public Bindable UseLazerFont { get; } = new Bindable(false); + + public FillFlowContainer TextContainer { get; private set; } + + // public float DefaultWidth { get; set; } = 100; // 默认宽度 + + public LocalisableString Text + { + get => TextPart.Text; + set => TextPart.Text = value; + } + + // public object Spacing { get; set; } + + public EzScoreText() + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + TextPart = new EzGetScoreTexture(textLookup, FontName); + + InternalChildren = new Drawable[] + { + TextContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + + Children = new Drawable[] + { + TextPart + } + }, + }; + } + + private string textLookup(char c) + { + switch (c) + { + case '.': return @"dot"; + + case '%': return @"percentage"; + + default: return c.ToString(); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + FontName.BindTo(ezSkinConfig.GetBindable(Ez2Setting.GameThemeName)); + + float scale = calculateScale(TextPart.Height); + TextPart.Scale = new Vector2(scale); + + FontName.BindValueChanged(e => + { + TextPart.FontName.Value = e.NewValue; + // textPart.LoadAsync(); // **强制重新加载字体** + scale = calculateScale(TextPart.Height); + TextPart.Scale = new Vector2(scale); + TextPart.Invalidate(); // **确保 UI 立即刷新** + }, true); + } + + private float calculateScale(float textureHeight, float targetHeight = 35f) + { + if (textureHeight <= 0) + return 1; + + return targetHeight / textureHeight; + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzSelectorEnumList.cs b/osu.Game/LAsEzExtensions/HUD/EzSelectorEnumList.cs new file mode 100644 index 0000000000..388e416aae --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzSelectorEnumList.cs @@ -0,0 +1,136 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Overlays.Settings; + +namespace osu.Game.LAsEzExtensions.HUD +{ + public partial class EzSelectorEnumList : SettingsDropdown + { + [Resolved] + private Storage storage { get; set; } = null!; + + // public const string DEFAULT_NAME = "Celeste_Lumiere"; + public const EzEnumGameThemeName DEFAULT_NAME = EzEnumGameThemeName.Celeste_Lumiere; + + protected override void LoadComplete() + { + base.LoadComplete(); + // 动态加载GameTheme文件夹 + // var availableThemes = loadAvailableThemes(); + Items = Enum.GetValues(typeof(EzEnumGameThemeName)).Cast().ToList(); + } + + private List loadAvailableThemes() + { + var themes = new List(); + + try + { + string gameThemePath = storage.GetFullPath("EzResources/GameTheme"); + + if (Directory.Exists(gameThemePath)) + { + string[] directories = Directory.GetDirectories(gameThemePath); + themes.AddRange(directories.Select(Path.GetFileName).Where(name => !string.IsNullOrEmpty(name))!); + } + } + catch (Exception ex) + { + Logger.Log($"Failed to load GameTheme folders: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + + return themes; + } + } + + public partial class AnchorDropdown : SettingsDropdown + { + protected override void LoadComplete() + { + base.LoadComplete(); + // 限制选项范围 + Items = new List + { + Anchor.TopCentre, + Anchor.Centre, + Anchor.BottomCentre + }; + } + } + + public enum EzComEffectType + { + Scale, + Bounce, + None + } + + //TODO: 枚举维护不方便,修改后要清理重构,考虑改为读取配置文件,或自动搜索子文件夹生成列表 + //这里使用枚举,加载的是Resource.dll中的资源 + //注释用来备份,不要删除 + public enum EzEnumGameThemeName + { + // ReSharper disable InconsistentNaming + EZ2DJ_1st, + EZ2DJ_1stSE, + EZ2DJ_2nd, + EZ2DJ_3rd, + EZ2DJ_4th, + EZ2DJ_6th, + EZ2DJ_7th, + AIR, + AZURE_EXPRESSION, + Celeste_Lumiere, + CV_CRAFT, + D2D_Station, + Dark_Concert, + DJMAX, + EC_1304, + EC_Wheel, + EVOLVE, + EZ2ON, + FIND_A_WAY, + Fortress2, + Fortress3_Future, + Fortress3_Gear, + Fortress3_Green, + Fortress3_Modern, + GC, + GC_EZ, + Gem, + HX_1121, + HX_STANDARD, + JIYU, + Kings, + Limited, + NIGHT_FALL, + O2_A9100, + O2_EA05, + O2_Jam, + Platinum, + QTZ_01, + QTZ_02, + REBOOT, + SG_701, + SH_512, + Star, + TANOc, + TANOc2, + TECHNIKA, + TIME_TRAVELER, + TOMATO, + Turtle, + Various_Ways, + ArcadeScore, + // ReSharper restore InconsistentNaming + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzSelectorTextures.cs b/osu.Game/LAsEzExtensions/HUD/EzSelectorTextures.cs new file mode 100644 index 0000000000..a10a17e0fe --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzSelectorTextures.cs @@ -0,0 +1,198 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays.Settings; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.HUD +{ + //TODO 代码不对, 无法加载, 用于缩略图选择纹理 + public partial class EzSelectorTextures : SettingsItem + { + [Resolved] + private Storage storage { get; set; } = null!; + + private List availableThemes = new List(); + + public EzSelectorTextures() + { + Current = new Bindable(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + availableThemes = loadAvailableThemes(); + if (availableThemes.Count > 0) + Current.Value = availableThemes[0]; + } + + protected override Drawable CreateControl() + { + return new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = createPreviewItems().ToList() + } + }; + } + + private IEnumerable createPreviewItems() + { + foreach (string value in availableThemes) + { + yield return new PreviewContainer + { + Value = value, + Selected = value == Current.Value, + Action = () => Current.Value = value + }; + } + } + + private List loadAvailableThemes() + { + var themes = new List(); + + try + { + string gameThemePath = storage.GetFullPath("EzResources/GameTheme"); + + if (Directory.Exists(gameThemePath)) + { + string[] directories = Directory.GetDirectories(gameThemePath); + themes.AddRange(directories.Select(Path.GetFileName).Where(name => !string.IsNullOrEmpty(name))!); + } + } + catch (Exception ex) + { + Logger.Log($"Failed to load GameTheme folders: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error); + } + + return themes; + } + + private partial class PreviewContainer : Container + { + public string Value { get; set; } = string.Empty; + public Action? Action { get; set; } + + private Box? background; + private bool selected; + + public bool Selected + { + set + { + selected = value; + background?.FadeTo(selected ? 0.4f : 0f, 200, Easing.OutQuint); + } + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Height = 90; + Margin = new MarginPadding { Bottom = 5 }; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White.Opacity(0.1f), + Alpha = 0 + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Text = Value, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold) + } + }, + new[] { createPreview() } + } + } + } + }; + } + + private Drawable createPreview() + { + return new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black + }, + // 这里添加实际的预览内容 + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + background.FadeTo(0.2f, 200, Easing.OutQuint); + this.ScaleTo(1.02f, 200, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeTo(selected ? 0.4f : 0f, 200, Easing.OutQuint); + this.ScaleTo(1f, 200, Easing.OutQuint); + } + + protected override bool OnClick(ClickEvent e) + { + Action?.Invoke(); + return true; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/HUD/EzTextureFactory.txt b/osu.Game/LAsEzExtensions/HUD/EzTextureFactory.txt new file mode 100644 index 0000000000..c694adb735 --- /dev/null +++ b/osu.Game/LAsEzExtensions/HUD/EzTextureFactory.txt @@ -0,0 +1,133 @@ +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; +using osu.Game.Screens; +using osu.Game.Screens.LAsEzExtensions; + +namespace osu.Game.Skinning.Components +{ + public partial class EzTextureFactory : CompositeDrawable + { + public Bindable TextureNameBindable { get; } = new Bindable("Celeste_Lumiere"); + public string TextureBasePath = @"EzResources/note"; + private readonly EzAnimationType animationType; + + private readonly EzSkinSettingsManager ezSkinConfig; + private readonly TextureStore textureStore; + + public EzTextureFactory(EzSkinSettingsManager ezSkinConfig, EzAnimationType type, TextureStore textureStore, string? customTexturePath = null) + { + this.ezSkinConfig = ezSkinConfig; + animationType = type; + this.textureStore = textureStore; + initialize(); + + if (!string.IsNullOrEmpty(customTexturePath)) + TextureBasePath = customTexturePath; + + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Blending = new BlendingParameters + { + Source = BlendingType.SrcAlpha, + Destination = BlendingType.One, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + } + + private void initialize() + { + TextureNameBindable.Value = ezSkinConfig.Get(EzSkinSetting.NoteSetName); + + ezSkinConfig.GetBindable(EzSkinSetting.NoteSetName).BindValueChanged(e => + TextureNameBindable.Value = e.NewValue, true); + } + + public virtual Drawable CreateAnimation(string component) + { + string noteSetName = TextureNameBindable.Value; + + Container container; + TextureAnimation animation; + + if (animationType == EzAnimationType.Note) + { + container = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }; + + animation = new TextureAnimation + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + DefaultFrameLength = 1000 / 10f, + Loop = true + }; + } + else // Hit + { + container = new Container + { + AutoSizeAxes = Axes.None, + }; + + animation = new TextureAnimation + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.None, + Loop = false + }; + } + + for (int i = 0;; i++) + { + string framePath = $@"{TextureBasePath}/{noteSetName}/{component}/{i:D3}.png"; + var texture = textureStore.Get(framePath); + if (texture == null) + break; + + animation.AddFrame(texture); + } + + if (animation.FrameCount == 0) + { + Logger.Log("No animation frames loaded.", LoggingTarget.Runtime, LogLevel.Important); + return container; + } + + if (animationType == EzAnimationType.Hit) + { + animation.OnUpdate += _ => + { + var tex = animation.CurrentFrame?.Size; + + if (tex != null) + { + container.Width = tex.Value.X; + container.Height = tex.Value.Y; + } + + if (animation.CurrentFrameIndex == animation.FrameCount - 1) + animation.Expire(); + }; + } + + container.Add(animation); + return container; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/Edit/LoopIntervalDisplay.cs b/osu.Game/LAsEzExtensions/Screens/Edit/LoopIntervalDisplay.cs new file mode 100644 index 0000000000..8fa3fe246f --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/Edit/LoopIntervalDisplay.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; + +namespace osu.Game.LAsEzExtensions.Screens.Edit +{ + public partial class LoopIntervalDisplay : CompositeDrawable + { + private readonly Box intervalBox; + + public LoopIntervalDisplay() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + InternalChild = intervalBox = new Box + { + RelativeSizeAxes = Axes.Y, + Colour = Colour4.Blue.Opacity(0.5f), // Semi-transparent gray + }; + } + + public void UpdateInterval(float startX, float endX) + { + if (endX > startX) + { + intervalBox.X = startX; + intervalBox.Width = endX - startX; + intervalBox.Alpha = 1; + } + else + { + intervalBox.Alpha = 0; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/Edit/LoopMarker.cs b/osu.Game/LAsEzExtensions/Screens/Edit/LoopMarker.cs new file mode 100644 index 0000000000..81d814b694 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/Edit/LoopMarker.cs @@ -0,0 +1,231 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Overlays; +using osuTK; +using osuTK.Input; + +namespace osu.Game.LAsEzExtensions.Screens.Edit +{ + public partial class LoopMarker : CompositeDrawable + { + public new double Time { get; set; } + + public event Action? TimeChanged; + + public Func? TimeAtX { get; set; } + + /// + /// Optional snapping function applied to times produced by . + /// + public Func? SnapTime { get; set; } + + /// + /// Optional mapping from time back to X (in parent centre-based coordinates). + /// When provided, the marker will visually jump to the snapped position while dragging. + /// + public Func? XAtTime { get; set; } + + /// + /// Optional clamp for X (in parent centre-based coordinates). + /// + public Func? ClampX { get; set; } + + private readonly bool isStart; + + private readonly Container clippedBottomHalf; + + public LoopMarker(bool isStart) + { + this.isStart = isStart; + + RelativeSizeAxes = Axes.Y; + Width = 8; + + // Only show the bottom half of the marker visuals to keep the upper half of the timeline clean + // for the active playback cursor. + InternalChild = clippedBottomHalf = new Container + { + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Masking = true, + Child = new Container + { + // 2x the height of the clip container => full marker height. + RelativeSizeAxes = Axes.Both, + Height = 2, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 1.4f, + EdgeSmoothness = new Vector2(1, 0) + }, + new VerticalTriangles + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + EdgeSmoothness = Vector2.One + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + Colour = isStart ? colours.Colour3 : colours.Colour4; // Green for A, Red for B + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (!base.ReceivePositionalInputAt(screenSpacePos)) + return false; + + // Only allow interaction in the lower half of the timeline. + var localPos = ToLocalSpace(screenSpacePos); + return localPos.Y >= DrawHeight / 2; + } + + public new bool IsDragged { get; private set; } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + { + // Handle drag to set time + return true; + } + + return base.OnMouseDown(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + var localPos = ToLocalSpace(e.ScreenSpaceMousePosition); + + // Only allow dragging in the lower half of the timeline (matching ReceivePositionalInputAt). + if (localPos.Y < DrawHeight / 2) + return false; + + IsDragged = true; + return true; + } + + protected override void OnDrag(DragEvent e) + { + if (Parent == null) + return; + + // Use absolute mouse position mapped into parent space to avoid scale/unit mismatches. + Vector2 parentLocal = Parent.ToLocalSpace(e.ScreenSpaceMousePosition); + float anchorX = Parent.ChildOffset.X + Parent.ChildSize.X / 2; + float newX = parentLocal.X - anchorX; + + if (ClampX != null) + newX = ClampX(newX); + + double newTime = TimeAtX?.Invoke(newX) ?? 0; + + if (SnapTime != null) + newTime = SnapTime(newTime); + + if (XAtTime != null) + newX = XAtTime(newTime); + + X = newX; + Time = newTime; + TimeChanged?.Invoke(newTime); + } + + protected override void OnDragEnd(DragEndEvent e) + { + IsDragged = false; + } + + /// + /// Triangles drawn at the top and bottom of . + /// + /// + /// Since framework-side triangles don't support antialiasing we are using custom implementation involving rotated smoothened boxes to avoid + /// mismatch in antialiasing between top and bottom triangles when drawable moves across the screen. + /// To "trim" boxes we must enable masking at the top level. + /// + private partial class VerticalTriangles : Sprite + { + [BackgroundDependencyLoader] + private void load(IRenderer renderer) + { + Texture = renderer.WhitePixel; + } + + protected override DrawNode CreateDrawNode() => new VerticalTrianglesDrawNode(this); + + private class VerticalTrianglesDrawNode : SpriteDrawNode + { + public new VerticalTriangles Source => (VerticalTriangles)base.Source; + + public VerticalTrianglesDrawNode(VerticalTriangles source) + : base(source) + { + } + + private float triangleScreenSpaceHeight; + + public override void ApplyState() + { + base.ApplyState(); + + triangleScreenSpaceHeight = ScreenSpaceDrawQuad.Width * 0.8f; // TriangleHeightRatio = 0.8f + } + + protected override void Blit(IRenderer renderer) + { + if (triangleScreenSpaceHeight == 0 || DrawRectangle.Width == 0 || DrawRectangle.Height == 0) + return; + + Vector2 inflation = new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / (DrawRectangle.Width * 0.8f)); + + Quad topTriangle = new Quad + ( + ScreenSpaceDrawQuad.TopLeft, + ScreenSpaceDrawQuad.TopLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, -triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.TopLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.TopRight + ); + + Quad bottomTriangle = new Quad + ( + ScreenSpaceDrawQuad.BottomLeft, + ScreenSpaceDrawQuad.BottomLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, -triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.BottomLeft + new Vector2(ScreenSpaceDrawQuad.Width * 0.5f, triangleScreenSpaceHeight), + ScreenSpaceDrawQuad.BottomRight + ); + + renderer.DrawQuad(Texture, topTriangle, DrawColourInfo.Colour, inflationPercentage: inflation); + renderer.DrawQuad(Texture, bottomTriangle, DrawColourInfo.Colour, inflationPercentage: inflation); + } + + protected override bool CanDrawOpaqueInterior => false; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/EzColumnTab.cs b/osu.Game/LAsEzExtensions/Screens/EzColumnTab.cs new file mode 100644 index 0000000000..c42add3667 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/EzColumnTab.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Extensions; +using osu.Game.Overlays.Settings; +using osu.Game.Screens; +using osu.Game.Screens.Edit.Components; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public partial class EzColumnTab : EditorSidebarSection + { + private static readonly List available_key_modes = new List { 0, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18 }; + + private readonly Dictionary> columnSelectorCache = new Dictionary>(); + private readonly Dictionary colorBindables = new Dictionary(); + + private FillFlowContainer columnsContainer = null!; + private FillFlowContainer baseColorsContainer = null!; + + private Bindable columnTypeListSelectBindable = null!; + private Bindable colorSettingsEnabled = null!; + private Bindable columnBlur = new Bindable(); + private Bindable columnDim = new Bindable(); + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + [Resolved] + private Bindable beatmap { get; set; } = null!; + + [Resolved] + private SkinManager skinManager { get; set; } = null!; + + public EzColumnTab() + : base("EZ Column Settings") { } + + [BackgroundDependencyLoader] + private void load() + { + columnTypeListSelectBindable = ezSkinConfig.GetBindable(Ez2Setting.LastSelectForColumnsType); + colorSettingsEnabled = ezSkinConfig.GetBindable(Ez2Setting.ColorSettingsEnabled); + columnBlur = ezSkinConfig.GetBindable(Ez2Setting.ColumnBlur); + columnDim = ezSkinConfig.GetBindable(Ez2Setting.ColumnDim); + + colorBindables[Ez2Setting.ColumnTypeA] = createColorBindable(Ez2Setting.ColumnTypeA); + colorBindables[Ez2Setting.ColumnTypeB] = createColorBindable(Ez2Setting.ColumnTypeB); + colorBindables[Ez2Setting.ColumnTypeS] = createColorBindable(Ez2Setting.ColumnTypeS); + colorBindables[Ez2Setting.ColumnTypeE] = createColorBindable(Ez2Setting.ColumnTypeE); + colorBindables[Ez2Setting.ColumnTypeP] = createColorBindable(Ez2Setting.ColumnTypeP); + createUI(); + updateKeyModeFromCurrentBeatmap(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + setupEventHandlers(); + updateColumnsType(columnTypeListSelectBindable.Value); + } + + private void createUI() + { + baseColorsContainer = createBaseColorsContainer(); + + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Children = new Drawable[] + { + new SettingsSlider + { + LabelText = "Column Dim", + TooltipText = "修改面板背景暗化", + Current = columnDim, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider + { + LabelText = "Column Blur", + TooltipText = "修改面板背景虚化", + Current = columnBlur, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + createColorSettingsCheckbox(), + baseColorsContainer, + createKeyModeSection(), + createSaveButton() + } + } + }; + } + + private SettingsCheckbox createColorSettingsCheckbox() + { + return new SettingsCheckbox + { + LabelText = "Color Enable\n(着色设置)", + TooltipText = "仅支持EZ Style Pro皮肤. Only supports EZ Style Pro skin\n" + + "切换tab栏或保存后, 将重置默认颜色为当前设置\n" + + "Switching tabs or saving will reset the colors to the default values", + Current = colorSettingsEnabled, + }; + } + + private FillFlowContainer createBaseColorsContainer() + { + return new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding(5f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Base Colors (基础颜色)", + Margin = new MarginPadding { Bottom = 5 }, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14) + }.WithUnderline(), + SettingsColourExtensions.CreateStyledSettingsColour(EzConstants.COLUMN_TYPE_A, colorBindables[Ez2Setting.ColumnTypeA]), + SettingsColourExtensions.CreateStyledSettingsColour(EzConstants.COLUMN_TYPE_B, colorBindables[Ez2Setting.ColumnTypeB]), + SettingsColourExtensions.CreateStyledSettingsColour(EzConstants.COLUMN_TYPE_S, colorBindables[Ez2Setting.ColumnTypeS]), + SettingsColourExtensions.CreateStyledSettingsColour(EzConstants.COLUMN_TYPE_E, colorBindables[Ez2Setting.ColumnTypeE]), + SettingsColourExtensions.CreateStyledSettingsColour(EzConstants.COLUMN_TYPE_P, colorBindables[Ez2Setting.ColumnTypeP]), + } + }; + } + + private FillFlowContainer createKeyModeSection() + { + return new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Margin = new MarginPadding(5f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Key Mode (键位数)", + Margin = new MarginPadding { Bottom = 5 }, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), + }.WithUnderline(), + new SettingsDropdown + { + Current = columnTypeListSelectBindable, + Items = available_key_modes + }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = Color4.DarkGray.Opacity(0.5f), + }, + columnsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2) + }, + } + }; + } + + private SettingsButton createSaveButton() + { + return new SettingsButton + { + Action = () => + { + skinManager.CurrentSkinInfo.TriggerChange(); + ezSkinConfig.Save(); + }, + }.WithTwoLineText("(保存颜色设置)", "Save Color Settings"); + } + + private void setupEventHandlers() + { + colorSettingsEnabled.BindValueChanged(onColorSettingsEnabledChanged, true); + columnTypeListSelectBindable.BindValueChanged(e => updateColumnsType(e.NewValue)); + + // 设置颜色变化事件 + colorBindables[Ez2Setting.ColumnTypeA].BindValueChanged(e => updateBaseColour(e.NewValue, Ez2Setting.ColumnTypeA, EzConstants.COLUMN_TYPE_A)); + colorBindables[Ez2Setting.ColumnTypeB].BindValueChanged(e => updateBaseColour(e.NewValue, Ez2Setting.ColumnTypeB, EzConstants.COLUMN_TYPE_B)); + colorBindables[Ez2Setting.ColumnTypeS].BindValueChanged(e => updateBaseColour(e.NewValue, Ez2Setting.ColumnTypeS, EzConstants.COLUMN_TYPE_S)); + colorBindables[Ez2Setting.ColumnTypeE].BindValueChanged(e => updateBaseColour(e.NewValue, Ez2Setting.ColumnTypeE, EzConstants.COLUMN_TYPE_E)); + colorBindables[Ez2Setting.ColumnTypeP].BindValueChanged(e => updateBaseColour(e.NewValue, Ez2Setting.ColumnTypeP, EzConstants.COLUMN_TYPE_P)); + } + + private BindableColour4 createColorBindable(Ez2Setting setting) + { + var configBindable = ezSkinConfig.GetBindable(setting); + var result = new BindableColour4(configBindable.Value); + + configBindable.BindValueChanged(e => result.Value = e.NewValue); + result.BindValueChanged(e => configBindable.Value = e.NewValue); + + return result; + } + + private void onColorSettingsEnabledChanged(ValueChangedEvent e) + { + baseColorsContainer.Alpha = e.NewValue ? 1f : 0f; + } + + private void updateBaseColour(Colour4 newColor, Ez2Setting setting, string type) + { + if (!colorSettingsEnabled.Value) + return; + + ezSkinConfig.SetValue(setting, newColor); + + foreach (var selector in columnsContainer.ChildrenOfType()) + { + selector.SetColorMapping(type, newColor); + } + } + + private void updateKeyModeFromCurrentBeatmap() + { + if (beatmap.Value?.Beatmap == null) + return; + + if (beatmap.Value.BeatmapInfo.Ruleset.OnlineID == 3) + { + int currentKeyCount = (int)beatmap.Value.Beatmap.Difficulty.CircleSize; + + if (currentKeyCount > 0 && available_key_modes.Contains(currentKeyCount)) + { + columnTypeListSelectBindable.Value = currentKeyCount; + } + } + } + + private void updateColumnsType(int keyModeForList) + { + if (columnSelectorCache.TryGetValue(keyModeForList, out var cachedSelectors)) + { + columnsContainer.Clear(); + columnsContainer.AddRange(cachedSelectors); + return; + } + + columnsContainer.Clear(); + + if (keyModeForList == 0 || !available_key_modes.Contains(keyModeForList)) + { + columnsContainer.Add(new OsuSpriteText + { + Text = "请先选择键位数模式", + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Margin = new MarginPadding(5f), + }); + return; + } + + createColumnSelectors(keyModeForList); + } + + private void createColumnSelectors(int keyMode) + { + columnsContainer.Add(new OsuSpriteText + { + Text = $"{keyMode}K ColumnType 列类型", + Font = OsuFont.GetFont(weight: FontWeight.SemiBold), + Margin = new MarginPadding { Bottom = 5 }, + }.WithUnderline()); + + var newSelectors = new List(); + var colorMapping = createColorMapping(); + string[] columnTypes = Enum.GetNames(typeof(EzColumnType)); + + for (int i = 0; i < keyMode; i++) + { + var selector = createColumnSelector(keyMode, i, columnTypes, colorMapping); + columnsContainer.Add(selector); + newSelectors.Add(selector); + } + + columnSelectorCache[keyMode] = newSelectors; + } + + private Dictionary createColorMapping() + { + return new Dictionary + { + [EzConstants.COLUMN_TYPE_A] = colorBindables[Ez2Setting.ColumnTypeA].Value, + [EzConstants.COLUMN_TYPE_B] = colorBindables[Ez2Setting.ColumnTypeB].Value, + [EzConstants.COLUMN_TYPE_S] = colorBindables[Ez2Setting.ColumnTypeS].Value, + [EzConstants.COLUMN_TYPE_E] = colorBindables[Ez2Setting.ColumnTypeE].Value, + [EzConstants.COLUMN_TYPE_P] = colorBindables[Ez2Setting.ColumnTypeP].Value + }; + } + + private EzSelectorColour createColumnSelector(int keyMode, int columnIndex, string[] columnTypes, Dictionary colorMapping) + { + EzColumnType savedType = ezSkinConfig.GetColumnType(keyMode, columnIndex); + + var selector = new EzSelectorColour($"Column {columnIndex + 1}", columnTypes, colorMapping); + selector.Current.Value = savedType.ToString(); + + selector.Current.ValueChanged += e => + { + if (Enum.TryParse(e.NewValue, out var type)) + ezSkinConfig.SetColumnType(keyMode, columnIndex, type); + }; + + return selector; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/EzEditorSidebar.cs b/osu.Game/LAsEzExtensions/Screens/EzEditorSidebar.cs new file mode 100644 index 0000000000..7f9f422f73 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/EzEditorSidebar.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens; +using osu.Game.Screens.Edit.Components; + +namespace osu.Game.LAsEzExtensions.Screens +{ + internal partial class EzEditorSidebar : EditorSidebar + { + public enum SidebarTab + { + Default, + EzSettings, + ColorSettings + } + + private EzSkinSettings? ezSkinSettings; + private SidebarTab currentTab = SidebarTab.Default; + private Action>? lastPopulator; + + public EzEditorSidebar() + { + OsuTabControl tabControl; + // 添加tabControl背景,防止内容遮挡tab标签 + var tabBackground = new Box + { + RelativeSizeAxes = Axes.X, + Height = 30, + Colour = Colour4.FromHex("222831") // 可根据主题调整 + }; + AddInternal(tabBackground); + + // 只添加tabControl,滚动和内容由基类EditorSidebar负责 + AddInternal(tabControl = new OsuTabControl + { + RelativeSizeAxes = Axes.X, + Height = 30, + Margin = new MarginPadding { Left = 5 }, + Items = new[] { SidebarTab.Default, SidebarTab.EzSettings, SidebarTab.ColorSettings } + }); + //TODO 添加多列颜色选择 + // 设置内容区整体下移,避免与tab栏重叠 + Content.Margin = new MarginPadding { Top = 30 }; + + tabControl.Current.ValueChanged += e => + { + currentTab = e.NewValue; + Content.Clear(); + + switch (currentTab) + { + case SidebarTab.EzSettings: + showEzSettings(); + break; + + case SidebarTab.ColorSettings: + showColorSettings(); + break; + + case SidebarTab.Default when lastPopulator != null: + PopulateSettings(lastPopulator); + break; + } + }; + } + + private void showEzSettings() + { + ezSkinSettings = new EzSkinSettings + { + RelativeSizeAxes = Axes.X + }; + Content.Add(ezSkinSettings); + } + + private void showColorSettings() + { + var ezColumnSettings = new EzColumnTab + { + RelativeSizeAxes = Axes.X + }; + Content.Add(ezColumnSettings); + } + + /// + /// 仅在 Default tab 下允许填充内容。 + /// + public void PopulateSettings(Action> populator) + { + lastPopulator = populator; + if (currentTab != SidebarTab.Default) + return; + + Content.Clear(); + populator(Content); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/EzSelectorColour.cs b/osu.Game/LAsEzExtensions/Screens/EzSelectorColour.cs new file mode 100644 index 0000000000..79208820a7 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/EzSelectorColour.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.Configuration; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public partial class EzSelectorColour : CompositeDrawable + { + private readonly FillFlowContainer buttonsContainer; + private readonly Dictionary colorMap = new Dictionary(); + private readonly Dictionary buttonsByName = new Dictionary(); + private readonly Lazy> defaultColors; + private readonly Box backgroundBox; + public Bindable Current { get; } + public float ButtonHeight { get; set; } = 30; + private const float spacing_width = 5f; + + /// + /// 创建一个颜色按钮选择器 + /// + /// 选择器标签 + /// 可选项目 + /// 项目到颜色的映射 + public EzSelectorColour(string label, string[] items, Dictionary? colorMapping = null) + { + Current = new Bindable(); + // 优化:使用懒加载初始化默认颜色 + defaultColors = new Lazy>(() => new Dictionary + { + [EzConstants.COLUMN_TYPE_A] = Color4.White, + [EzConstants.COLUMN_TYPE_B] = Color4.DodgerBlue, + [EzConstants.COLUMN_TYPE_S] = Color4.IndianRed, + [EzConstants.COLUMN_TYPE_E] = Color4.IndianRed, + [EzConstants.COLUMN_TYPE_P] = Color4.LimeGreen + }); + + if (colorMapping != null) + colorMap = new Dictionary(colorMapping); + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 3f, + Colour = Color4.Black.Opacity(0.2f), + Offset = new Vector2(0, 1), + }; + + InternalChildren = new Drawable[] + { + backgroundBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.05f) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Left = 10, Right = 5, Bottom = 5 }, + Spacing = new Vector2(0, 8), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = label, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), + Margin = new MarginPadding { Left = 5, Top = 5 } + }, + buttonsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(spacing_width, 2), + Padding = new MarginPadding { Vertical = 2 } + } + } + } + }; + + AddButtons(items); + + Current.BindValueChanged(e => + { + if (e.OldValue != null && buttonsByName.TryGetValue(e.OldValue, out var oldButton)) + oldButton.Selected = false; + + if (e.NewValue != null && buttonsByName.TryGetValue(e.NewValue, out var newButton)) + newButton.Selected = true; + }); + } + + // 添加悬浮事件处理 + protected override bool OnHover(HoverEvent e) + { + backgroundBox.FadeColour(Color4.White.Opacity(0.1f), 200, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + backgroundBox.FadeColour(Color4.Black.Opacity(0.05f), 200, Easing.OutQuint); + base.OnHoverLost(e); + } + + public void AddButtons(IEnumerable colorNames, Dictionary? customColors = null) + { + foreach (string name in colorNames) + { + Color4 color = customColors?.TryGetValue(name, out Color4 customColor) == true + ? customColor + : getColorForName(name); + + var button = new EzSkinColorButton(name, color, ButtonHeight) + { + Selected = Current.Value == name, + Action = () => Current.Value = name + }; + + buttonsByName[name] = button; + buttonsContainer.Add(button); + } + + updateAllButtonWidths(); + } + + private void updateAllButtonWidths() + { + int buttonCount = buttonsContainer.Children.Count; + if (buttonCount <= 0) return; + + float width = 1f / buttonCount; + + foreach (var child in buttonsContainer.Children.OfType()) + { + child.Width = width - 0.03f; + } + } + + public void SetColorMapping(string name, Color4 color) + { + colorMap[name] = color; + + if (buttonsByName.TryGetValue(name, out var button)) + button.UpdateColor(color); + } + + private Color4 getColorForName(string name) + { + if (colorMap.TryGetValue(name, out Color4 color)) + return color; + + return defaultColors.Value.TryGetValue(name, out color) ? color : Color4.White; + } + + protected override void Dispose(bool isDisposing) + { + if (isDisposing) + { + foreach (var button in buttonsByName.Values) + { + button.Action = null; + } + + buttonsByName.Clear(); + colorMap.Clear(); + } + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/EzSettingsColour.cs b/osu.Game/LAsEzExtensions/Screens/EzSettingsColour.cs new file mode 100644 index 0000000000..333ca1a555 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/EzSettingsColour.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays.Settings; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public partial class EzSettingsColour : SettingsItem + { + protected override Drawable CreateControl() => new ColourControl(); + + public partial class ColourControl : OsuClickableContainer, IHasPopover, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(Colour4.White); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly Box fill; + private readonly OsuSpriteText colourHexCode; + + public ColourControl() + { + RelativeSizeAxes = Axes.X; + Height = 40; + CornerRadius = 20; + Masking = true; + Action = this.ShowPopover; + + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both + }, + colourHexCode = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 20) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateColour(), true); + } + + private void updateColour() + { + fill.Colour = Current.Value; + colourHexCode.Text = Current.Value.ToHex(); + colourHexCode.Colour = OsuColour.ForegroundTextColourFor(Current.Value); + } + + public Popover GetPopover() => new OsuPopover(false) + { + Child = new OsuColourPickerWithAlpha + { + Current = { BindTarget = Current } + } + }; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/EzSkinColorButton.cs b/osu.Game/LAsEzExtensions/Screens/EzSkinColorButton.cs new file mode 100644 index 0000000000..27557cc7bb --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/EzSkinColorButton.cs @@ -0,0 +1,206 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public partial class EzSkinColorButton : CompositeDrawable + { + private readonly Box background; + private readonly Box colorBox; + private readonly Container content; + private readonly OsuSpriteText label; + + public Action? Action; + private bool selected; + private bool isHovered; + + public bool Selected + { + get => selected; + set + { + if (selected == value) + return; + + selected = value; + updateVisualState(); + } + } + + public EzSkinColorButton(string colorName, Color4 color, float height) + { + RelativeSizeAxes = Axes.X; + Height = height; + Masking = true; + CornerRadius = 6; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 3f, + Colour = Color4.Black.Opacity(0.2f), + Offset = new Vector2(0, 1), + }; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White.Opacity(0.2f), + Alpha = 0.3f + }, + content = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 3, + Children = new Drawable[] + { + colorBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = color + }, + label = new OsuSpriteText + { + Text = colorName, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), + Colour = getContrastColor(color), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shadow = true, + ShadowColour = getContrastColor(color).Opacity(0.3f) + } + } + } + } + }; + } + + public void UpdateColor(Color4 newColor) + { + colorBox.Colour = newColor; + label.Colour = getContrastColor(newColor); + label.ShadowColour = getContrastColor(newColor).Opacity(0.3f); + } + + private void updateVisualState() + { + // 根据状态设置背景效果 + if (selected) + { + background.Alpha = 1; + background.Colour = Color4.CornflowerBlue; // 选中状态颜色更亮DeepSkyBlue + } + else if (isHovered) + { + background.Alpha = 0.8f; + background.FadeColour(Color4.White.Opacity(0.3f), 200, Easing.OutQuint); // 悬浮高亮效果 + } + else + { + background.Alpha = 0.3f; + background.FadeColour(Color4.White.Opacity(0.2f), 200, Easing.OutQuint); // 恢复默认 + } + + if (selected) + { + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 10f, + Colour = colorBox.Colour, + Roundness = 3f + }; + + label.Shadow = true; + label.ShadowColour = getContrastColor(colorBox.Colour).Opacity(0.7f); + label.ShadowOffset = new Vector2(0.02f, 0.02f); + + this.MoveToY(-2, 200, Easing.OutQuint); + } + else if (isHovered) + { + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 10f, + Colour = Color4.Black.Opacity(0.3f), + Roundness = 8f + }; + + label.Shadow = true; + label.ShadowColour = getContrastColor(colorBox.Colour).Opacity(0.5f); + label.ShadowOffset = new Vector2(0.02f, 0.02f); + + this.MoveToY(-1, 200, Easing.OutQuint); + } + else + { + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 3f, + Colour = Color4.Black.Opacity(0.2f), + Offset = new Vector2(0, 1), + }; + + label.Shadow = true; + label.ShadowColour = getContrastColor(colorBox.Colour).Opacity(0.3f); + label.ShadowOffset = new Vector2(0.02f, 0.02f); + + this.MoveToY(0, 200, Easing.OutQuint); + } + + this.ScaleTo(1.0f, 200, Easing.OutQuint); // 保持原始大小 + content.Scale = Vector2.One; + } + + //对比色 + private Color4 getContrastColor(Color4 background) + { + float brightness = 0.299f * background.R + 0.587f * background.G + 0.114f * background.B; + return brightness > 0.5f ? Color4.Black : Color4.White; + } + + protected override bool OnClick(ClickEvent e) + { + Action?.Invoke(); + return true; + } + + protected override bool OnHover(HoverEvent e) + { + isHovered = true; + updateVisualState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + isHovered = false; + updateVisualState(); + } + + public override bool HandlePositionalInput => Action != null; + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/EzSkinEditorScreen.cs b/osu.Game/LAsEzExtensions/Screens/EzSkinEditorScreen.cs new file mode 100644 index 0000000000..c114adf060 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/EzSkinEditorScreen.cs @@ -0,0 +1,388 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Reflection; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Screens +{ + /// + /// 一个屏幕,用于在用户请求时加载皮肤编辑器以指定目标。 + /// 这也处理目标的缩放/定位调整。 + /// + public partial class EzSkinEditorScreen : OverlayContainer + { + // Only block interactions inside the central preview rect. + // Keep non-positional input (handled by SkinEditorOverlay) working for sidebars/menus. + protected override bool BlockPositionalInput => true; + protected override bool BlockNonPositionalInput => false; + + [Resolved] + private ISkinSource skinSource { get; set; } = null!; + + [Resolved] + private SkinManager skinManager { get; set; } = null!; + + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private Bindable beatmap { get; set; } = null!; + + // 必须这样,否则会构建失败 + [Resolved(canBeNull: true)] + private IDialogOverlay? dialogOverlay { get; set; } + + private ISkinEditorVirtualProvider? provider; + + private Container? mainContainer; + private Container? backgroundContainer; + private Container? leftPlaybackContainer; + private Container? centerNoteDisplayContainer; + private Container? rightSettingsContainer; + private OsuScrollContainer? settingsScrollContainer; + private OsuButton? applyButton; + + public EzSkinEditorScreen() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + // AddLayout(drawSizeLayout = new LayoutValue(Invalidation.DrawSize)); + } + + protected override void PopIn() + { + this.FadeIn(200, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(200, Easing.OutQuint); + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, OsuColour colours) + { + InternalChildren = new Drawable[] + { + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + // Background plate (use the same skin component as mania stage background). + backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.3f), + new Dimension(GridSizeMode.Relative, 0.4f), + new Dimension(GridSizeMode.Relative, 0.3f), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 1), + }, + Content = new[] + { + new Drawable[] + { + // 左侧:虚拟播放场景 + leftPlaybackContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + // 中间:note 显示 + centerNoteDisplayContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + // 右侧:设置面板 + rightSettingsContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + settingsScrollContainer = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Height = 0.9f, // 为应用按钮留出空间 + }, + applyButton = new ApplySettingsButton + { + Text = "Apply Settings", + RelativeSizeAxes = Axes.X, + Height = 40, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Action = ApplySettings, + } + } + }, + } + } + } + } + } + }; + + // PopulateSettings is invoked from LoadComplete to ensure dependencies are available. + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Ensure dependencies are available before initialising. + PopulateSettings(); + } + + public void PopulateSettings() + { + // 注意:这里不要创建 HitObjectComposer(会引入编辑器依赖)。 + // 本功能目前只服务 mania + EzPro,通过 registry 解析 provider。 + var currentBeatmap = beatmap.Value.Beatmap; + var ezProSkin = skinManager.CurrentSkin.Value as EzStyleProSkin ?? new EzStyleProSkin(skinManager); + + // 尽量确保 mania 程序集已加载,以便其在模块初始化时完成 provider 注册。 + try + { + Assembly.Load("osu.Game.Rulesets.Mania"); + } + catch + { + } + + backgroundContainer!.Child = createManiaStageBackgroundOrNull() ?? new Container { RelativeSizeAxes = Axes.Both }; + backgroundContainer.Child.RelativeSizeAxes = Axes.Both; + + provider = createManiaProviderOrNull(); + + // 当调用时初始化屏幕 + InitializeLeftPlayback(); + InitializeCenterDisplay(); + InitializeRightSettings(); + } + + private static ISkinEditorVirtualProvider? createManiaProviderOrNull() + { + // 仅针对 mania + EzPro 的简化实现:用反射创建 provider,避免引入 registry/额外文件。 + const string type_name = "osu.Game.Rulesets.Mania.Skinning.Editor.ManiaEzProSkinEditorVirtualProvider, osu.Game.Rulesets.Mania"; + + try + { + var providerType = Type.GetType(type_name, throwOnError: false); + if (providerType == null) + return null; + + return Activator.CreateInstance(providerType) as ISkinEditorVirtualProvider; + } + catch + { + return null; + } + } + + private static Drawable? createManiaStageBackgroundOrNull() + { + var lookup = tryCreateManiaSkinComponentLookupOrNull("StageBackground"); + if (lookup == null) + return null; + + return new SkinnableDrawable(lookup) + { + RelativeSizeAxes = Axes.Both + }; + } + + private static ISkinComponentLookup? tryCreateManiaSkinComponentLookupOrNull(string componentName) + { + const string lookup_type_name = "osu.Game.Rulesets.Mania.ManiaSkinComponentLookup, osu.Game.Rulesets.Mania"; + const string enum_type_name = "osu.Game.Rulesets.Mania.ManiaSkinComponents, osu.Game.Rulesets.Mania"; + + try + { + var lookupType = Type.GetType(lookup_type_name, throwOnError: false); + var enumType = Type.GetType(enum_type_name, throwOnError: false); + + if (lookupType == null || enumType == null) + return null; + + object componentValue = Enum.Parse(enumType, componentName, ignoreCase: false); + return Activator.CreateInstance(lookupType, componentValue) as ISkinComponentLookup; + } + catch + { + return null; + } + } + + private void InitializeLeftPlayback() + { + var currentSkin = skinManager.CurrentSkin.Value as EzStyleProSkin ?? new EzStyleProSkin(skinManager); + var currentBeatmap = beatmap.Value.Beatmap; + + if (provider != null) + { + var virtualPlayfield = provider.CreateVirtualPlayfield(currentSkin, currentBeatmap); + + leftPlaybackContainer!.Children = new Drawable[] + { + virtualPlayfield.With(p => + { + p.RelativeSizeAxes = Axes.Both; + p.Anchor = Anchor.Centre; + p.Origin = Anchor.Centre; + }) + }; + } + else + { + // Fallback if not supported + leftPlaybackContainer!.Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Virtual playfield not supported for this ruleset", + Colour = Color4.White, + } + }; + } + } + + private void InitializeCenterDisplay() + { + if (provider != null) + { + centerNoteDisplayContainer!.Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Children = new[] + { + provider.CreateCurrentSkinNoteDisplay(skinManager.CurrentSkin.Value as EzStyleProSkin ?? new EzStyleProSkin(skinManager)), + provider.CreateEditedNoteDisplay(skinManager.CurrentSkin.Value as EzStyleProSkin ?? new EzStyleProSkin(skinManager)), + } + } + }; + } + else + { + centerNoteDisplayContainer!.Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Note display not supported for this ruleset", + Colour = Color4.White, + } + }; + } + } + + private void InitializeRightSettings() + { + // TODO: 添加皮肤参数控件和应用逻辑 + settingsScrollContainer!.Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "皮肤参数调整", + Colour = Color4.White, + Font = OsuFont.Default.With(size: 18), + }, + new OsuSpriteText + { + Text = "TODO: 添加参数控件", + Colour = Color4.Gray, + Font = OsuFont.Default.With(size: 14), + } + } + }; + } + + private void ApplySettings() + { + // TODO: 将设置应用到当前皮肤 + // 目前只是刷新中间显示 + InitializeCenterDisplay(); + } + + private void ShowExitDialog() + { + // 里程碑A阶段:dialog overlay 可能在当前依赖树里不可用,务必降级为直接退出。 + if (dialogOverlay == null) + { + Hide(); + return; + } + + dialogOverlay.Push(new ConfirmDialog("应用更改到皮肤?", () => + { + ApplySettings(); + Hide(); + }, Hide)); + } + + public void PresentGameplay() + { + // 作为 overlay,这里不应 Push/Present gameplay。 + } + + protected override void Update() + { + base.Update(); + + // Overlay 不需要更新屏幕大小 + } + + private partial class ApplySettingsButton : OsuButton + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + BackgroundColour = overlayColourProvider?.Background3 ?? colours.Blue3; + Content.CornerRadius = 5; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/EzSkinSettingsTab.cs b/osu.Game/LAsEzExtensions/Screens/EzSkinSettingsTab.cs new file mode 100644 index 0000000000..764bfc8ff5 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/EzSkinSettingsTab.cs @@ -0,0 +1,363 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Overlays.Settings; +using osu.Game.Screens.Edit.Components; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public partial class EzSkinSettings : EditorSidebarSection + { + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + [Resolved] + private SkinManager skinManager { get; set; } = null!; + + [Resolved] + private Storage storage { get; set; } = null!; + + public EzSkinSettings() + : base("EZ Skin Settings") { } + + private static readonly Dictionary resource_paths = new Dictionary + { + ["note"] = Path.Combine("EzResources", "note"), + ["Stage"] = Path.Combine("EzResources", "Stage"), + ["GameTheme"] = Path.Combine("EzResources", "GameTheme") + }; + + private static readonly Dictionary position_mode_config = new Dictionary + { + [true] = (new Color4(0.2f, 0.4f, 0.8f, 0.3f), EzLocalizationManager.SwitchToAbsolute, EzLocalizationManager.SwitchToAbsolute), + [false] = (new Color4(0.8f, 0.2f, 0.4f, 0.3f), EzLocalizationManager.SwitchToRelative, EzLocalizationManager.SwitchToRelative) + }; + + // TODO: 优化为动态枚举, 不能用Bindable,这不是列表/枚举,会导致控件下拉栏无选项 + private readonly List availableNoteSets = new List(); + private readonly List availableStageSets = new List(); + private Bindable nameOfNote = new Bindable(); + private Bindable nameOfStage = new Bindable(); + private Bindable nameOfGameTheme = new Bindable(); + + private SettingsButton refreshSkinButton = null!; + private bool isAbsolutePosition = true; + + [BackgroundDependencyLoader] + private void load() + { + loadFolderSets("note"); + loadFolderSets("Stage"); + // loadFolderSets("GameTheme"); + + nameOfNote = ezSkinConfig.GetBindable(Ez2Setting.NoteSetName); + nameOfStage = ezSkinConfig.GetBindable(Ez2Setting.StageName); + nameOfGameTheme = ezSkinConfig.GetBindable(Ez2Setting.GameThemeName); + // setDefaultSelection(nameOfGameTheme, availableGameThemes); + createUI(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + nameOfNote.BindValueChanged(e => ezSkinConfig.SetValue(Ez2Setting.NoteSetName, e.NewValue)); + nameOfStage.BindValueChanged(e => ezSkinConfig.SetValue(Ez2Setting.StageName, e.NewValue)); + nameOfGameTheme.BindValueChanged(e => ezSkinConfig.SetValue(Ez2Setting.GameThemeName, e.NewValue)); + nameOfGameTheme.BindValueChanged(e => updateAllEzTextureNames(e.NewValue)); + } + + private void setDefaultSelection(Bindable bindable, List availableItems) + { + if (availableItems.Count > 1 || !availableItems.Contains(bindable.Value)) + { + bindable.Value = availableItems[1]; + } + } + + private void createUI() + { + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new SettingsEnumDropdown + { + LabelText = EzLocalizationManager.GlobalTextureName, + TooltipText = EzLocalizationManager.GlobalTextureNameTooltip, + Current = nameOfGameTheme, + }, + new SettingsDropdown + { + LabelText = EzLocalizationManager.StageSet, + TooltipText = EzLocalizationManager.StageSetTooltip, + Current = nameOfStage, + Items = availableStageSets, + }, + new SettingsDropdown + { + LabelText = EzLocalizationManager.NoteSet, + TooltipText = EzLocalizationManager.NoteSetTooltip, + Current = nameOfNote, + Items = availableNoteSets, + }, + new SettingsEnumDropdown + { + LabelText = EzLocalizationManager.ColumnWidthStyle, + TooltipText = EzLocalizationManager.ColumnWidthStyleTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidthStyle), + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.ColumnWidth, + TooltipText = EzLocalizationManager.ColumnWidthTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth), + KeyboardStep = 1.0f, + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.SpecialFactor, + TooltipText = EzLocalizationManager.SpecialFactorTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor), + KeyboardStep = 0.1f, + }, + new SettingsCheckbox + { + LabelText = EzLocalizationManager.GlobalHitPosition, + TooltipText = EzLocalizationManager.GlobalHitPositionTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.GlobalHitPosition), + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.HitPosition, + TooltipText = EzLocalizationManager.HitPositionTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.HitPosition), + KeyboardStep = 1f, + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.HitTargetFloatFixed, + TooltipText = EzLocalizationManager.HitTargetFloatFixedTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.HitTargetFloatFixed), + KeyboardStep = 0.1f, + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.HitTargetAlpha, + TooltipText = EzLocalizationManager.HitTargetAlphaTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.HitTargetAlpha), + KeyboardStep = 0.01f, + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.NoteHeightScale, + TooltipText = EzLocalizationManager.NoteHeightScaleTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.NoteHeightScaleToWidth), + KeyboardStep = 0.1f, + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.ManiaHoldTailMaskGradientHeight, + TooltipText = EzLocalizationManager.ManiaHoldTailMaskGradientHeightTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.ManiaHoldTailMaskGradientHeight), + KeyboardStep = 1.0f, + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.ManiaHoldTailAlpha, + TooltipText = EzLocalizationManager.ManiaHoldTailAlphaTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.ManiaHoldTailAlpha), + KeyboardStep = 0.1f, + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.NoteTrackLine, + TooltipText = EzLocalizationManager.NoteTrackLineTooltip, + Current = ezSkinConfig.GetBindable(Ez2Setting.NoteTrackLineHeight), + }, + refreshSkinButton = new SettingsButton + { + Action = refreshSkin, + Text = EzLocalizationManager.RefreshSaveSkin, + TooltipText = EzLocalizationManager.RefreshSaveSkin + } + } + } + }; + } + + #region Save按钮处理 + + private void refreshSkin() + { + isAbsolutePosition = !isAbsolutePosition; + skinManager.CurrentSkinInfo.TriggerChange(); + // skinManager.Save(skinManager.CurrentSkin.Value); + updateButtonAppearance(); + } + + private void updateButtonAppearance() + { + var config = position_mode_config[isAbsolutePosition]; + + var box = refreshSkinButton.ChildrenOfType().FirstOrDefault(); + box?.FadeColour(config.Color, 200); + + var textContainer = refreshSkinButton.ChildrenOfType().FirstOrDefault(); + var texts = textContainer?.ChildrenOfType().ToArray(); + + if (texts?.Length >= 2) + { + texts[0].Text = config.TopText; + texts[1].Text = config.BottomText; + } + } + + #endregion + + #region 刷新所有EzComponent的纹理名称 + + private void updateAllEzTextureNames(EzEnumGameThemeName textureGameTheme) + { + var scoreTexts = this.ChildrenOfType(); + var comboTexts = this.ChildrenOfType(); + var hitResultScores = this.ChildrenOfType(); + + foreach (var scoreText in scoreTexts) + scoreText.FontName.Value = textureGameTheme; + + foreach (var comboText in comboTexts) + comboText.FontName.Value = textureGameTheme; + + foreach (var hitResultScore in hitResultScores) + hitResultScore.NameDropdown.Value = textureGameTheme; + } + + #endregion + + private void loadFolderSets(string type) + { + List targetList; + + if (type.Equals("note", StringComparison.OrdinalIgnoreCase)) + targetList = availableNoteSets; + else if (type.Equals("Stage", StringComparison.OrdinalIgnoreCase)) + targetList = availableStageSets; + // else if (type.Equals("GameTheme", StringComparison.OrdinalIgnoreCase)) + // targetList = availableGameThemes; + else + { + Logger.Log($"Unknown resource type: {type}", LoggingTarget.Runtime, LogLevel.Error); + return; + } + + targetList.Clear(); + + if (!resource_paths.TryGetValue(type, out string? relativePath)) + { + Logger.Log($"Unknown resource type: {type}", LoggingTarget.Runtime, LogLevel.Error); + return; + } + + try + { + string? dataFolderPath = storage.GetFullPath(relativePath); + // Debug.Assert(!string.IsNullOrEmpty(dataFolderPath)); + + if (!Directory.Exists(dataFolderPath)) + { + Directory.CreateDirectory(dataFolderPath); + Logger.Log($"EzSkinTab create {type} Path: {dataFolderPath}"); + } + + string[] directories = Directory.GetDirectories(dataFolderPath); + targetList.AddRange(directories.Select(Path.GetFileName).Where(name => !string.IsNullOrEmpty(name))!); + + Logger.Log($"Found {targetList.Count} {type} sets in {dataFolderPath}", LoggingTarget.Runtime, LogLevel.Debug); + } + catch (Exception ex) + { + Logger.Error(ex, $"EzSkinTab Load {type} FolderSets Error"); + } + } + } + + #region 拓展按钮的多行文本显示 + + public static class SettingsButtonExtensions + { + public static SettingsButton WithTwoLineText(this SettingsButton button, string topText, string bottomText, int fontSize = 14) + { + button.Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.AliceBlue, + Alpha = 0.1f + }, + // 文本层 + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = topText, + Font = OsuFont.GetFont(size: fontSize), + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }, + new OsuSpriteText + { + Text = bottomText, + Font = OsuFont.GetFont(size: fontSize), + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + } + } + } + }; + + return button; + } + } + + #endregion +} diff --git a/osu.Game/LAsEzExtensions/Screens/HitModePopover.cs b/osu.Game/LAsEzExtensions/Screens/HitModePopover.cs new file mode 100644 index 0000000000..96fadac146 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/HitModePopover.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.LAsEzExtensions.Configuration; +using osuTK; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public partial class HitModeButton : RoundedButton, IHasPopover + { + private readonly Bindable hitModeBindable; + + public HitModeButton(Bindable hitModeBindable) + { + this.hitModeBindable = hitModeBindable; + + Size = new Vector2(75, 30); + Text = hitModeBindable.Value.ToString(); + + hitModeBindable.BindValueChanged(v => Text = v.NewValue.ToString()); + + Action = this.ShowPopover; + } + + public Popover GetPopover() => new HitModePopover(); + + private partial class HitModePopover : OsuPopover + { + private readonly Bindable hitModeBindable = new Bindable(); + + public HitModePopover() + : base(false) + { + // this.hitModeBindable = hitModeBindable; + + Body.CornerRadius = 4; + AllowableAnchors = new[] { Anchor.TopCentre }; + } + + [BackgroundDependencyLoader] + private void load(Ez2ConfigManager ezConfig) + { + hitModeBindable.BindTo(ezConfig.GetBindable(Ez2Setting.HitMode)); + Children = new[] + { + new OsuMenu(Direction.Vertical, true) + { + Items = Enum.GetValues().Select(mode => + new OsuMenuItem(mode.ToString(), MenuItemType.Standard, () => ezConfig.SetValue(Ez2Setting.HitMode, mode))).ToArray(), + MaxHeight = 375, + }, + }; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/IEzConfig.cs b/osu.Game/LAsEzExtensions/Screens/IEzConfig.cs new file mode 100644 index 0000000000..2870706528 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/IEzConfig.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public interface IEzConfig + { + Bindable NoteSize { get; } + Bindable ColumnWidth { get; } + Bindable SpecialFactor { get; } + Bindable NoteHeightScaleToWidth { get; } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/IPreviewable.cs b/osu.Game/LAsEzExtensions/Screens/IPreviewable.cs new file mode 100644 index 0000000000..4aa0a56399 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/IPreviewable.cs @@ -0,0 +1,10 @@ +using osu.Framework.Bindables; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public interface IPreviewable + { + Bindable TextureNameBindable { get; } + string TextureBasePath { get; } + } +} diff --git a/osu.Game/LAsEzExtensions/Screens/OsuColourPickerWithAlpha.cs b/osu.Game/LAsEzExtensions/Screens/OsuColourPickerWithAlpha.cs new file mode 100644 index 0000000000..016793ee69 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Screens/OsuColourPickerWithAlpha.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays.Settings; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.Screens +{ + public partial class OsuColourPickerWithAlpha : ColourPicker + { + private readonly BindableDouble alphaBindable; + private FillFlowContainer? mainContent; + + public OsuColourPickerWithAlpha() + { + CornerRadius = 10; + Masking = true; + + alphaBindable = new BindableDouble(1) + { + MinValue = 0, + MaxValue = 1, + Precision = 0.05 + }; + } + + protected override HSVColourPicker CreateHSVColourPicker() => new OsuHSVColourPicker(); + protected override HexColourPicker CreateHexColourPicker() => new OsuHexColourPicker(); + + [BackgroundDependencyLoader] + private void load() + { + mainContent = InternalChildren.OfType().FirstOrDefault(); + + // 添加透明度滑块到已有的布局容器中 + mainContent?.Add(new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 8, Bottom = 15 }, + Child = new SettingsSlider + { + LabelText = "Alpha", + RelativeSizeAxes = Axes.X, + Current = alphaBindable + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + alphaBindable.Value = Current.Value.A; + + // 当透明度值变化时更新颜色 + alphaBindable.BindValueChanged(alpha => + { + // 创建新的颜色,只更改Alpha通道 + Current.Value = new Color4( + Current.Value.R, + Current.Value.G, + Current.Value.B, + (float)alpha.NewValue + ); + }); + + // 当颜色变化时更新透明度值 + Current.BindValueChanged(colour => + { + // 防止循环绑定 + if (Math.Abs(alphaBindable.Value - colour.NewValue.A) > 0.001) + alphaBindable.Value = colour.NewValue.A; + }); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Select/DuplicateVirtualTrack.cs b/osu.Game/LAsEzExtensions/Select/DuplicateVirtualTrack.cs new file mode 100644 index 0000000000..feb43f11da --- /dev/null +++ b/osu.Game/LAsEzExtensions/Select/DuplicateVirtualTrack.cs @@ -0,0 +1,254 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Extensions; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play; + +namespace osu.Game.LAsEzExtensions.Select +{ + public partial class DuplicateVirtualTrack : EzPreviewTrackManager + { + /// + /// 全局静态开关:当设置为 时,DuplicateVirtualTrack 将回退到基类行为, + /// 避免使用独立复制 track / 静音原始 track,从而降低状态混乱的风险。 + /// 默认启用。 + /// + public new static bool Enabled { get; set; } = true; + + public IPreviewOverrideProvider? OverrideProvider { get; set; } + public PreviewOverrideSettings? PendingOverrides { get; set; } + + private bool startRequested; + private bool started; + private IWorkingBeatmap? pendingBeatmap; + + private double? beatmapTrackVolumeBeforeMute; + private Track? mutedOriginalTrack; + + [Resolved(canBeNull: true)] + private IGameplayClock? gameplayClock { get; set; } + + [Resolved(canBeNull: true)] + private GameplayClockContainer? gameplayClockContainer { get; set; } + + [Resolved(canBeNull: true)] + private BeatmapManager? beatmapManager { get; set; } + + public new void StartPreview(IWorkingBeatmap beatmap, bool forceEnhanced = false) + { + if (!Enabled) + { + return; + } + + pendingBeatmap = beatmap; + startRequested = true; + + var overrides = PendingOverrides ?? OverrideProvider?.GetPreviewOverrides(beatmap); + + if (overrides != null) + ApplyOverrides(overrides); + + OverrideLooping = overrides?.ForceLooping ?? OverrideLooping; + ExternalClock = gameplayClock; + ExternalClockStartTime = overrides?.PreviewStart ?? OverridePreviewStartTime; + EnableHitSounds = overrides?.EnableHitSounds ?? true; + + // 重置循环状态 + ResetLoopState(); + + // gameplay 下不要把 MasterGameplayClockContainer 从真实 beatmap.Track “断开”。 + // 断开会导致: + // 1) 变速 Mod(HT/DT/RateAdjust)对 gameplay 时钟不生效(TrackVirtual 不一定按 Tempo/Frequency 推进时间)。 + // 2) SubmittingPlayer 的播放校验会持续报 "System audio playback is not working"。 + // 这里改为:保留 beatmap.Track 作为时钟来源,但将其静音,避免听到整首歌。 + if (gameplayClock != null && beatmap.Track != null) + { + // 保存被静音的 Track 实例以及其原始音量,确保后续能正确恢复。 + if (mutedOriginalTrack == null || mutedOriginalTrack != beatmap.Track) + { + beatmapTrackVolumeBeforeMute = beatmap.Track.Volume.Value; + mutedOriginalTrack = beatmap.Track; + } + + beatmap.Track.Volume.Value = 0; + } + + // 不直接开播:等待本 Drawable 完成依赖注入,并在 gameplay 时钟 running 时再开始。 + //(选歌界面无 gameplayClock,则下一帧启动即可) + started = false; + } + + protected override void Dispose(bool isDisposing) + { + // 尽可能恢复被静音的原始 track 的音量,避免退出/切换后一直静音。 + if (mutedOriginalTrack != null && beatmapTrackVolumeBeforeMute != null) + { + mutedOriginalTrack.Volume.Value = beatmapTrackVolumeBeforeMute.Value; + beatmapTrackVolumeBeforeMute = null; + mutedOriginalTrack = null; + } + + base.Dispose(isDisposing); + } + + protected override void UpdateAfterChildren() + { + if (!Enabled) + { + base.UpdateAfterChildren(); + return; + } + + base.UpdateAfterChildren(); + + if (started || !startRequested || pendingBeatmap == null) + return; + + // 当有 gameplay 时钟且第一次进入 running 状态时再启动切片播放,避免准备时间被抢占。 + if (gameplayClock != null && !gameplayClock.IsRunning) + return; + + started = true; + base.StartPreview(pendingBeatmap, false); + } + + protected override Track? CreateTrack(IWorkingBeatmap beatmap, out bool ownsTrack) + { + ownsTrack = false; + + if (!Enabled) + return beatmap.Track; + + // 由 EzPreviewTrackManager 负责外部时钟驱动的切片/循环/间隔(Seek/Stop/Start)。 + // 在 gameplay 场景下必须使用独立 Track 实例: + // - 原 beatmap.Track 可能仍在播放整首歌,需要 Stop() + // - 同时也避免对原 Track 的音量/调整影响到切片播放。 + if (gameplayClock != null) + { + string audioFile = beatmap.BeatmapInfo.Metadata.AudioFile; + + if (!string.IsNullOrEmpty(audioFile) && beatmap.BeatmapInfo.BeatmapSet is BeatmapSetInfo beatmapSet) + { + string? rawFileStorePath = beatmapSet.GetPathForFile(audioFile); + string? standardisedFileStorePath = rawFileStorePath; + + // 部分存储 API 可能返回 Windows 风格的路径分隔符。 + if (!string.IsNullOrEmpty(standardisedFileStorePath)) + standardisedFileStorePath = standardisedFileStorePath.ToStandardisedPath(); + + if (!string.IsNullOrEmpty(rawFileStorePath) || !string.IsNullOrEmpty(standardisedFileStorePath)) + { + // 为了兼容性,raw/standardised 两种路径都尝试。 + // 优先选择看起来“已正确解码”的 Track(通常 Length > 0)。 + static bool isProbablyValidTrack(Track? t) => t != null && t.Length > 0; + + static void ensureTrackLengthPopulated(Track track) + { + if (!track.IsLoaded || track.Length == 0) + { + // 强制填充 Length(参考 WorkingBeatmap.PrepareTrackForPreview() 的处理)。 + track.Seek(track.CurrentTime); + } + } + + bool hasBeatmapTrackStore = beatmapManager?.BeatmapTrackStore != null; + + Track? beatmapStoreRaw = hasBeatmapTrackStore && !string.IsNullOrEmpty(rawFileStorePath) + ? beatmapManager!.BeatmapTrackStore.Get(rawFileStorePath) + : null; + + Track? beatmapStoreStandardised = hasBeatmapTrackStore && !string.IsNullOrEmpty(standardisedFileStorePath) + ? beatmapManager!.BeatmapTrackStore.Get(standardisedFileStorePath) + : null; + + Track? globalStoreRaw = !string.IsNullOrEmpty(rawFileStorePath) + ? AudioManager.Tracks.Get(rawFileStorePath) + : null; + + Track? globalStoreStandardised = !string.IsNullOrEmpty(standardisedFileStorePath) + ? AudioManager.Tracks.Get(standardisedFileStorePath) + : null; + Track? newTrack; + + if (isProbablyValidTrack(beatmapStoreRaw)) + { + newTrack = beatmapStoreRaw; + } + else if (isProbablyValidTrack(beatmapStoreStandardised)) + { + newTrack = beatmapStoreStandardised; + } + else if (isProbablyValidTrack(globalStoreRaw)) + { + newTrack = globalStoreRaw; + } + else if (isProbablyValidTrack(globalStoreStandardised)) + { + newTrack = globalStoreStandardised; + } + else + { + // 可能尚未完成懒加载:按优先级顺序回退选择即可。 + newTrack = beatmapStoreRaw ?? beatmapStoreStandardised ?? globalStoreRaw ?? globalStoreStandardised; + } + + if (newTrack != null) + { + ensureTrackLengthPopulated(newTrack); + + // 重要:游戏内的变速 Mod(DT/HT/RateAdjust 等)会把调整应用到 + // GameplayClockContainer.AdjustmentsFromMods 上,并由 MasterGameplayClockContainer 绑定到主音轨。 + // 但在 gameplay 场景下 DuplicateVirtualTrack 使用的是“独立的 Track 实例”, + // 所以这里必须把同一套 adjustments 绑定到新 Track,确保音频变速与游戏时钟/下落判定保持一致。 + if (gameplayClockContainer != null) + newTrack.BindAdjustments(gameplayClockContainer.AdjustmentsFromMods); + + // 同步用户的播放速率调整(若存在)。 + if (gameplayClockContainer is MasterGameplayClockContainer master) + newTrack.AddAdjustment(AdjustableProperty.Frequency, master.UserPlaybackRate); + + ownsTrack = true; + return newTrack; + } + } + } + } + + return beatmap.Track; + } + + protected override void StopPreviewInternal(string reason) + { + if (!Enabled) + { + base.StopPreviewInternal(reason); + // also clear any pending state just in case + startRequested = false; + started = false; + pendingBeatmap = null; + return; + } + + // 恢复被静音的原始 beatmap.Track 音量(如果我们在 StartPreview 时修改过) + if (mutedOriginalTrack != null && beatmapTrackVolumeBeforeMute != null) + { + mutedOriginalTrack.Volume.Value = beatmapTrackVolumeBeforeMute.Value; + beatmapTrackVolumeBeforeMute = null; + mutedOriginalTrack = null; + } + + base.StopPreviewInternal(reason); + + // 重置 DuplicateVirtualTrack 特有的状态 + startRequested = false; + started = false; + pendingBeatmap = null; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Select/EzKeyModeFilter.cs b/osu.Game/LAsEzExtensions/Select/EzKeyModeFilter.cs new file mode 100644 index 0000000000..60711dd9ee --- /dev/null +++ b/osu.Game/LAsEzExtensions/Select/EzKeyModeFilter.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.LAsEzExtensions.Select +{ + public class CsItemInfo + { + public required string Id { get; set; } + public required string DisplayName { get; set; } + public float? CsValue { get; set; } + public bool IsDefault { get; set; } + } + + public static class CsItemIds + { + private const int mania_ruleset_id = 3; + + public static readonly List ALL = new List + { + // new CsItemInfo { Id = "All", DisplayName = "All", IsDefault = true }, + new CsItemInfo { Id = "CS1", DisplayName = "1", CsValue = 1 }, + new CsItemInfo { Id = "CS2", DisplayName = "2", CsValue = 2 }, + new CsItemInfo { Id = "CS3", DisplayName = "3", CsValue = 3 }, + new CsItemInfo { Id = "CS4", DisplayName = "4", CsValue = 4 }, + new CsItemInfo { Id = "CS5", DisplayName = "5", CsValue = 5 }, + new CsItemInfo { Id = "CS6", DisplayName = "6", CsValue = 6 }, + new CsItemInfo { Id = "CS7", DisplayName = "7", CsValue = 7 }, + new CsItemInfo { Id = "CS8", DisplayName = "8", CsValue = 8 }, + new CsItemInfo { Id = "CS9", DisplayName = "9", CsValue = 9 }, + new CsItemInfo { Id = "CS10", DisplayName = "10", CsValue = 10 }, + new CsItemInfo { Id = "CS12", DisplayName = "12", CsValue = 12 }, + new CsItemInfo { Id = "CS14", DisplayName = "14", CsValue = 14 }, + new CsItemInfo { Id = "CS16", DisplayName = "16", CsValue = 16 }, + new CsItemInfo { Id = "CS18", DisplayName = "18", CsValue = 18 }, + }; + + public static List GetModesForRuleset(int rulesetId) + { + if (rulesetId == mania_ruleset_id) + return ALL.Where(m => m.CsValue == null || m.CsValue >= 4).ToList(); + + return ALL.Where(m => m.CsValue == null || m.CsValue <= 12).ToList(); + } + + public static CsItemInfo? GetById(string id) => ALL.FirstOrDefault(m => m.Id == id); + } + + public class EzKeyModeFilter + { + public HashSet SelectedModeIds { get; } = new HashSet(); + + public event Action? SelectionChanged; + + public void SetSelection(HashSet modeIds) + { + SelectedModeIds.Clear(); + SelectedModeIds.UnionWith(modeIds); + SelectionChanged?.Invoke(); + } + } +} diff --git a/osu.Game/LAsEzExtensions/Select/EzKeyModeSelector.cs b/osu.Game/LAsEzExtensions/Select/EzKeyModeSelector.cs new file mode 100644 index 0000000000..dc15b36895 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Select/EzKeyModeSelector.cs @@ -0,0 +1,390 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.LAsEzExtensions.Select +{ + public partial class EzKeyModeSelector : CompositeDrawable + { + private Bindable keyModeId = new Bindable(); + private readonly BindableBool isMultiSelectMode = new BindableBool(); + private readonly Dictionary> modeSelections = new Dictionary>(); + + private ShearedButton labelButton = null!; + private ShearedCsModeTabControl tabControl = null!; + private ShearedToggleButton multiSelectButton = null!; + + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + public IBindable Current => tabControl.Current; + + public EzKeyModeFilter EzKeyModeFilter { get; } = new EzKeyModeFilter(); + + public EzKeyModeSelector() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + CornerRadius = 8; + Masking = true; + Shear = OsuGame.SHEAR; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + labelButton = new ShearedButton(50, 30) + { + Text = "Keys", + TextSize = 16, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = new Vector2(0), + TooltipText = "Clear selection", + }, + tabControl = new ShearedCsModeTabControl + { + RelativeSizeAxes = Axes.X, + Shear = new Vector2(0), + }, + multiSelectButton = new ShearedToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = new Vector2(0), + Text = "K +", + Height = 30f, + TooltipText = "Enable multi-select", + } + } + } + } + }; + + multiSelectButton.Active.BindTo(isMultiSelectMode); + + labelButton.Action = () => EzKeyModeFilter.SetSelection(new HashSet()); + + keyModeId = ezConfig.GetBindable(Ez2Setting.EzSelectCsMode); + keyModeId.BindValueChanged(onSelectorChanged, true); + + isMultiSelectMode.BindValueChanged(_ => updateValue(), true); + ruleset.BindValueChanged(onRulesetChanged, true); + EzKeyModeFilter.SelectionChanged += updateValue; + + tabControl.Current.BindTarget = keyModeId; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + EzKeyModeFilter.SelectionChanged -= updateValue; + } + + private void onRulesetChanged(ValueChangedEvent e) + { + tabControl.UpdateForRuleset(e.NewValue.OnlineID); + labelButton.Text = e.NewValue.OnlineID == 3 ? "Keys" : "CS"; + updateValue(); + } + + private void onSelectorChanged(ValueChangedEvent e) + { + var modes = parseModeIds(e.NewValue); + EzKeyModeFilter.SetSelection(modes); + tabControl.UpdateTabItemUI(modes); + } + + private void updateValue() + { + int currentRulesetId = ruleset.Value.OnlineID; + + if (!modeSelections.ContainsKey(currentRulesetId)) + modeSelections[currentRulesetId] = new HashSet(); + + HashSet selectedModes = EzKeyModeFilter.SelectedModeIds; + + if (selectedModes.Count == 0) + { + keyModeId.Value = ""; + } + else + { + if (isMultiSelectMode.Value) + { + keyModeId.Value = string.Join(",", selectedModes.OrderBy(x => x)); + } + else + { + keyModeId.Value = selectedModes.First(); + } + } + + modeSelections[currentRulesetId] = selectedModes; + tabControl.UpdateForRuleset(currentRulesetId); + tabControl.UpdateTabItemUI(selectedModes); + tabControl.IsMultiSelectMode = isMultiSelectMode.Value; + } + + private HashSet parseModeIds(string value) + { + if (string.IsNullOrEmpty(value)) + return new HashSet(); + + return new HashSet(value.Split(',')); + } + + public partial class ShearedCsModeTabControl : OsuTabControl + { + private HashSet currentSelection = new HashSet(); + private int currentRulesetId = -1; + + public bool IsMultiSelectMode { get; set; } + + public Action>? SetCurrentSelections; + // + // [Resolved] + // private OverlayColourProvider colourProvider { get; set; } = null!; + + public ShearedCsModeTabControl() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Shear = OsuGame.SHEAR; + Masking = true; + } + + [BackgroundDependencyLoader] + private void load() + { + TabContainer.Anchor = Anchor.CentreLeft; + TabContainer.Origin = Anchor.CentreLeft; + // TabContainer.Shear = OsuGame.SHEAR; + TabContainer.RelativeSizeAxes = Axes.X; + TabContainer.AutoSizeAxes = Axes.Y; + TabContainer.Spacing = new Vector2(0f); + } + + public void UpdateForRuleset(int rulesetId) + { + if (currentRulesetId == rulesetId && Items.Any()) + return; + + currentRulesetId = rulesetId; + + var keyModes = CsItemIds.GetModesForRuleset(rulesetId) + .Select(m => m.Id) + .ToList(); + + TabContainer.Clear(); + Items = keyModes; + + Schedule(() => + { + int count = keyModes.Count; + + if (count > 0) + { + float totalWidth = DrawWidth; + float itemWidth = (totalWidth - (count * 2f)) / count; + foreach (var tab in TabContainer.Children.Cast()) + tab.Width = itemWidth; + } + }); + + UpdateTabItemUI(currentSelection); + } + + public void UpdateTabItemUI(HashSet selectedModes) + { + currentSelection = new HashSet(selectedModes); + + foreach (var tabItem in TabContainer.Children.Cast()) + { + bool isSelected = selectedModes.Contains(tabItem.Value); + tabItem.UpdateButton(isSelected); + } + } + + protected override Dropdown CreateDropdown() => null!; + // protected override bool AddEnumEntriesAutomatically => false; + + protected override TabItem CreateTabItem(string value) + { + var tabItem = new ShearedCsModeTabItem(value); + tabItem.Clicked += onTabItemClicked; + return tabItem; + } + + private void onTabItemClicked(string mode) + { + var newSelection = new HashSet(currentSelection); + + if (!newSelection.Remove(mode)) + { + if (IsMultiSelectMode) + { + newSelection.Add(mode); + } + else + { + newSelection.Clear(); + newSelection.Add(mode); + } + } + + currentSelection = newSelection; + Current.Value = newSelection.Count == 0 ? "" : string.Join(",", newSelection.OrderBy(x => x)); + UpdateTabItemUI(newSelection); + + SetCurrentSelections?.Invoke(newSelection); + } + + public partial class ShearedCsModeTabItem : TabItem + { + private readonly OsuSpriteText text; + private readonly Box background; + private OverlayColourProvider colourProvider = null!; + + public event Action? Clicked; + + public ShearedCsModeTabItem(string value) + : base(value) + { + // Shear = OsuGame.SHEAR; + CornerRadius = ShearedButton.CORNER_RADIUS; + Masking = true; + // Width = 40; + AutoSizeAxes = Axes.Y; + Margin = new MarginPadding { Left = 4 }; + + var modeInfo = CsItemIds.GetById(value); + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Text = modeInfo?.DisplayName ?? value, + Margin = new MarginPadding + { Horizontal = 10f, Vertical = 7f }, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + Colour = Colour4.White, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + background.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + if (Width > 40) Width = 40; + // if (Width < 30) Width = 30; + } + + public void UpdateButton(bool isSelected) + { + if (Active.Value != isSelected) + { + Active.Value = isSelected; + Schedule(updateColours); + } + } + + private void updateColours() + { + using (BeginDelayedSequence(0)) + { + if (Active.Value) + { + background.FadeColour(colourProvider.Light4, 150, Easing.OutQuint); + text.FadeColour(Colour4.Black, 150, Easing.OutQuint); + } + else if (IsHovered) + { + background.FadeColour(colourProvider.Background4, 150, Easing.OutQuint); + text.FadeColour(Colour4.White, 150, Easing.OutQuint); + } + else + { + background.FadeColour(colourProvider.Background5, 150, Easing.OutQuint); + text.FadeColour(Colour4.White, 150, Easing.OutQuint); + } + } + } + + protected override void OnActivated() => Schedule(updateColours); + protected override void OnDeactivated() => Schedule(updateColours); + + protected override bool OnHover(HoverEvent e) + { + Schedule(updateColours); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Schedule(updateColours); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + Clicked?.Invoke(Value); + return true; + } + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/Select/EzPreviewTrackManager.cs b/osu.Game/LAsEzExtensions/Select/EzPreviewTrackManager.cs new file mode 100644 index 0000000000..f1c91c047e --- /dev/null +++ b/osu.Game/LAsEzExtensions/Select/EzPreviewTrackManager.cs @@ -0,0 +1,1137 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Framework.Threading; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Framework.Timing; + +namespace osu.Game.LAsEzExtensions.Select +{ + /// + /// 一个增强的预览音轨管理器,支持在预览时播放note音效和故事板背景音。 + /// 主要有两个用途: + /// 1. 在选歌界面实现最完整的游戏音轨预览。 + /// 2. 提供拓展支持,自定义预览时间、循环次数和间隔、关联游戏时钟、开关note音效等。 + /// + public partial class EzPreviewTrackManager : CompositeDrawable + { + /// + /// 全局静态开关:当设置为 时,EzPreviewTrackManager 将拒绝启动新的预览。 + /// 由外部(例如 UI 的 `keySoundPreview`)控制。 + /// + public static bool Enabled { get; set; } = true; + + /// + /// 当前是否处于“正在播放预览”的状态。 + /// 注意:该值同时要求内部状态认为正在播放,且底层 实际处于运行状态。 + /// + public bool IsPlaying => playback.IsPlaying && currentTrack?.IsRunning == true; + + private const int hitsound_threshold = 10; + private const double preview_window_length = 20000; // 20s + private const double scheduler_interval = 16; // ~60fps + private const double trigger_tolerance = 15; // ms 容差 + + // 外部时钟驱动(gameplay)模式下,过短的切片会导致高频 Seek/Restart,音频听感会明显“撕裂”。 + // 这里设置一个最小切片长度作为下限,避免 1ms 这类极端情况。 + private const double min_loop_length = 100; // ms + + // 在 gameplay 外部时钟驱动模式下,音频设备/解码缓冲会导致 Track.CurrentTime 与外部时钟存在微小漂移。 + // 若仍按 15ms 容差每帧 Seek,会产生明显的“撕裂/卡带”听感。 + // 因此对“音频轨道重同步”使用更宽容差,并加入冷却时间。 + private const double audio_resync_tolerance = 50; // ms + private const double audio_resync_cooldown = 120; // ms + + private double lastAudioResyncClockTime; + private const double max_dynamic_preview_length = 60000; // 动态扩展最长 ms + private readonly SampleSchedulerState sampleScheduler = new SampleSchedulerState(); + private readonly PlaybackState playback = new PlaybackState(); + + private Track? currentTrack; + private IWorkingBeatmap? currentBeatmap; + private ScheduledDelegate? updateDelegate; + private Container audioContainer = null!; + private ISampleStore sampleStore = null!; + + [Resolved] + protected AudioManager AudioManager { get; private set; } = null!; + + [Resolved] + private ISkinSource skinSource { get; set; } = null!; + + public bool EnableHitSounds { get; set; } = true; + + /// + /// 覆盖预览起点时间(毫秒)。 + /// 若为 null,则使用谱面元数据的预览时间(PreviewTime)。 + /// + public double? OverridePreviewStartTime { get; set; } + + /// + /// 覆盖预览段长度(毫秒)。 + /// 若为 null,则使用默认窗口长度,并在增强预览中可能动态延长以覆盖更多事件。 + /// + public double? OverridePreviewDuration { get; set; } + + /// + /// 覆盖底层 Track 的 Looping 行为。 + /// 注意:当启用外部驱动(存在 Duration/LoopCount/LoopInterval/ExternalClock 等)时, + /// 预览会通过 的 Stop/Seek/Start 来严格实现切片与间隔, + /// 此时 Track.Looping/RestartPoint 不再用于控制循环。 + /// + public bool? OverrideLooping { get; set; } + + /// + /// 覆盖循环次数。 + /// - 标准预览中默认 1 次。 + /// - 增强预览中默认无限(直到用户停止预览)。 + /// + public int? OverrideLoopCount { get; set; } + + /// + /// 覆盖循环间隔(毫秒)。 + /// 仅在使用外部驱动的切片循环模式下生效。 + /// + public double? OverrideLoopInterval { get; set; } + + /// + /// 外部时钟源。 + /// 设定后将使用该时钟的时间域来计算“逻辑播放时间”,用于与 gameplay 等场景同步(例如切片从 cutStart 开始)。 + /// + public IClock? ExternalClock { get; set; } + + /// + /// 当提供 时,定义预览“开始”的参考时间(毫秒,属于外部时钟的时间域)。 + /// 主要用于把预览延后到某个外部时间点才开始生效(例如 gameplay 时间到达 cutStart 时再开始切片播放)。 + /// 若为 null,则会在 实际启动时捕获当下的外部时钟时间作为参考。 + /// + public double? ExternalClockStartTime { get; set; } + + /// + /// 从 批量应用覆盖参数。 + /// 传入 null 等同于 。 + /// + public void ApplyOverrides(PreviewOverrideSettings? settings) + { + if (settings == null) + { + ResetOverrides(); + return; + } + + OverridePreviewStartTime = settings.PreviewStart; + OverridePreviewDuration = settings.PreviewDuration; + OverrideLoopCount = settings.LoopCount; + OverrideLoopInterval = settings.LoopInterval; + OverrideLooping = settings.ForceLooping; + EnableHitSounds = settings.EnableHitSounds; + } + + /// + /// 重置所有覆盖参数到默认值。 + /// + public void ResetOverrides() + { + OverridePreviewStartTime = null; + OverridePreviewDuration = null; + OverrideLoopCount = null; + OverrideLoopInterval = null; + OverrideLooping = null; + EnableHitSounds = true; + } + + /// + /// 重置循环状态,用于在开始新预览时清除之前的循环进度。 + /// + public void ResetLoopState() + { + playback.ResetExternalClockCapture(); + // 其他重置逻辑如果需要 + } + + private bool ownsCurrentTrack; + + #region Disposal + + protected override void Dispose(bool isDisposing) + { + StopPreview(); + base.Dispose(isDisposing); + } + + #endregion + + /// + /// 为指定谱面启动预览。 + /// 若命中音效数量低于阈值,会自动回退到“仅 BGM”的标准预览。 + /// + /// 要预览的谱面 + /// 是否强制使用增强预览(忽略命中音效数量阈值) + public void StartPreview(IWorkingBeatmap beatmap, bool forceEnhanced = false) + { + if (!Enabled) + return; + + if (playback.IsPlaying && currentBeatmap == beatmap) + return; + + StopPreview(); + + currentBeatmap = beatmap; + currentTrack = CreateTrack(beatmap, out ownsCurrentTrack); + playback.ResetExternalClockCapture(); + + bool enableEnhanced = forceEnhanced || fastCheckShouldUseEnhanced(beatmap, hitsound_threshold); + + if (!enableEnhanced) + { + startStandardPreview(beatmap); + return; + } + + startEnhancedPreview(beatmap); + } + + public void StopPreview() + { + StopPreviewInternal("manual"); + } + + protected virtual void StopPreviewInternal(string reason) + { + // Logger.Log($"EzPreviewTrackManager: Stopping preview (reason={reason})"); + playback.IsPlaying = false; + updateDelegate?.Cancel(); + updateDelegate = null; + + if (currentTrack != null) + { + currentTrack.Volume.Value = 1f; + currentTrack.Stop(); + if (ownsCurrentTrack) + currentTrack.Dispose(); + } + + clearEnhancedElements(); + currentBeatmap = null; + currentTrack = null; + playback.ResetPlaybackProgress(); + + // 清除所有 override 设置,确保不会影响后续使用 + OverrideLooping = null; + OverrideLoopCount = null; + OverrideLoopInterval = null; + ExternalClock = null; + ExternalClockStartTime = null; + OverridePreviewStartTime = null; + } + + [BackgroundDependencyLoader] + private void load() + { + sampleStore = AudioManager.Samples; + + InternalChild = audioContainer = new Container + { + RelativeSizeAxes = Axes.Both + }; + } + + // 快速判定:遍历命中对象直到达到阈值即返回 true,避免完整建立 HashSet 带来的额外分配 + private bool fastCheckShouldUseEnhanced(IWorkingBeatmap beatmap, int threshold) + { + try + { + var playable = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset); + var set = new HashSet(); + + foreach (var obj in playable.HitObjects) + { + collect(obj, set); + if (set.Count >= threshold) + return true; + } + + return set.Count >= threshold; + } + catch (Exception ex) + { + Logger.Log($"EzPreviewTrackManager: fastCheckShouldUseEnhanced error: {ex}", LoggingTarget.Runtime); + return false; + } + + static void collect(HitObject ho, HashSet s) + { + foreach (var sm in ho.Samples) s.Add(sm); + foreach (var n in ho.NestedHitObjects) collect(n, s); + } + } + + private void startStandardPreview(IWorkingBeatmap beatmap) + { + beatmap.PrepareTrackForPreview(true); + playback.PreviewStartTime = OverridePreviewStartTime ?? beatmap.BeatmapInfo.Metadata.PreviewTime; + + if (playback.PreviewStartTime < 0 || playback.PreviewStartTime > currentTrack?.Length) + playback.PreviewStartTime = (currentTrack?.Length ?? 0) * 0.4; + + playback.PreviewEndTime = OverridePreviewDuration.HasValue + ? playback.PreviewStartTime + Math.Max(0, OverridePreviewDuration.Value) + : playback.PreviewStartTime + preview_window_length; + + double segmentLength = playback.PreviewEndTime - playback.PreviewStartTime; + double minSegmentLength = ExternalClock != null ? min_loop_length : 1; + playback.LoopSegmentLength = Math.Max(minSegmentLength, segmentLength); + playback.LoopInterval = Math.Max(0, OverrideLoopInterval ?? 0); + playback.EffectiveLoopCount = OverrideLoopCount ?? 1; + // 当存在“切片/循环”相关 override 时,需要通过 updateSamples() 手动驱动 Track(Stop/Seek)。 + // 仅靠 Track.Looping/RestartPoint 无法严格约束 Duration/LoopCount/LoopInterval。 + playback.UseExternalLooping = ExternalClock != null + || playback.LoopInterval > 0 + || OverrideLoopCount.HasValue + || OverridePreviewDuration.HasValue + || (OverrideLooping.HasValue && !OverrideLooping.Value); + + if (currentTrack != null) + { + currentTrack.Seek(playback.PreviewStartTime); + currentTrack.Looping = !playback.UseExternalLooping && (OverrideLooping ?? true); + currentTrack.RestartPoint = playback.PreviewStartTime; + } + + currentTrack?.Start(); + playback.IsPlaying = true; + + if (playback.UseExternalLooping) + { + updateDelegate = Scheduler.AddDelayed(updateSamples, scheduler_interval, true); + updateSamples(); + } + } + + /// + /// 启动增强预览(BGM + 命中音效 + 故事板音效)。 + /// + private void startEnhancedPreview(IWorkingBeatmap beatmap) + { + double longestHitTime = 0; // 修复作用域:提前声明 + double longestStoryboardTime = 0; + + playback.LastReferenceTime = 0; + + void collectLongest(HitObject ho) + { + longestHitTime = Math.Max(longestHitTime, ho.StartTime); + foreach (var n in ho.NestedHitObjects) collectLongest(n); + } + + try + { + var playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset); + + beatmap.PrepareTrackForPreview(true); + playback.PreviewStartTime = OverridePreviewStartTime ?? beatmap.BeatmapInfo.Metadata.PreviewTime; + if (playback.PreviewStartTime < 0 || playback.PreviewStartTime > (currentTrack?.Length ?? 0)) + playback.PreviewStartTime = (currentTrack?.Length ?? 0) * 0.4; + + foreach (var ho in playableBeatmap.HitObjects) + collectLongest(ho); + + if (beatmap.Storyboard?.Layers != null) + { + foreach (var layer in beatmap.Storyboard.Layers) + { + foreach (var element in layer.Elements) + { + if (element is StoryboardSampleInfo s) + longestStoryboardTime = Math.Max(longestStoryboardTime, s.StartTime); + } + } + } + + double longestEventTime = Math.Max(longestHitTime, longestStoryboardTime); + double defaultEnd = playback.PreviewStartTime + preview_window_length; + double dynamicEnd = defaultEnd; + + if (currentTrack != null) + { + double segmentAfterStart = Math.Max(1, currentTrack.Length - playback.PreviewStartTime); + if (segmentAfterStart < preview_window_length * 0.6 && longestEventTime > defaultEnd) + dynamicEnd = Math.Min(playback.PreviewStartTime + max_dynamic_preview_length, longestEventTime); + } + + double segmentLength = OverridePreviewDuration.HasValue + ? Math.Max(0, OverridePreviewDuration.Value) + : dynamicEnd - playback.PreviewStartTime; + + double minSegmentLength = ExternalClock != null ? min_loop_length : 1; + playback.LoopSegmentLength = Math.Max(minSegmentLength, segmentLength); + playback.EffectiveLoopCount = OverrideLoopCount ?? int.MaxValue; + playback.LoopInterval = Math.Max(0, OverrideLoopInterval ?? 0); + + if (playback.EffectiveLoopCount == int.MaxValue) + playback.PreviewEndTime = double.MaxValue; + else + playback.PreviewEndTime = playback.PreviewStartTime + playback.EffectiveLoopCount * (playback.LoopSegmentLength + playback.LoopInterval) - playback.LoopInterval; + + playback.LastTrackTime = playback.PreviewStartTime; + playback.LegacyLoopCount = 0; + playback.LegacyLogicalOffset = 0; + playback.UseExternalLooping = ExternalClock != null + || playback.LoopInterval > 0 + || OverrideLoopCount.HasValue + || OverridePreviewDuration.HasValue + || (OverrideLooping.HasValue && !OverrideLooping.Value); + playback.ShortBgmOneShotMode = false; + playback.ShortBgmMutedAfterFirstLoop = false; + + // 判定一次性短BGM模式:原始音轨长度 <2s 且 谱面事件覆盖长度 > 10s + if (currentTrack != null && currentTrack.Length < 2000 && longestEventTime >= playback.PreviewStartTime + preview_window_length) + playback.ShortBgmOneShotMode = true; + + prepareHitSounds(playableBeatmap, playback.PreviewEndTime); + prepareStoryboardSamples(beatmap.Storyboard, playback.PreviewEndTime); + // preloadSamples(); + + if (sampleScheduler.ScheduledHitSounds.Count == 0 && sampleScheduler.ScheduledStoryboardSamples.Count == 0) + { + clearEnhancedElements(); + startStandardPreview(beatmap); + return; + } + + sampleScheduler.ResetIndices(); + + currentTrack?.Seek(playback.PreviewStartTime); + + if (currentTrack != null) + { + currentTrack.Looping = !playback.UseExternalLooping && (OverrideLooping ?? true); + currentTrack.RestartPoint = playback.PreviewStartTime; + } + + currentTrack?.Start(); + playback.IsPlaying = true; + + updateDelegate = Scheduler.AddDelayed(updateSamples, scheduler_interval, true); + updateSamples(); + } + catch (Exception ex) + { + Logger.Log($"EzPreviewTrackManager: startEnhancedPreview error: {ex}", LoggingTarget.Runtime); + clearEnhancedElements(); + startStandardPreview(beatmap); + } + } + + // 改为接受 endTime 参数 + private void prepareHitSounds(IBeatmap beatmap, double previewEndTime) + { + if (!EnableHitSounds) + { + sampleScheduler.ScheduledHitSounds.Clear(); + return; + } + + sampleScheduler.ScheduledHitSounds.Clear(); + foreach (var ho in beatmap.HitObjects) + schedule(ho, previewEndTime); + sampleScheduler.ScheduledHitSounds.Sort((a, b) => a.Time.CompareTo(b.Time)); + + void schedule(HitObject ho, double end) + { + if (ho.StartTime >= playback.PreviewStartTime && ho.StartTime <= end && ho.Samples.Any()) + { + sampleScheduler.ScheduledHitSounds.Add(new ScheduledHitSound + { + Time = ho.StartTime, + Samples = ho.Samples.ToArray(), + HasTriggered = false + }); + } + + foreach (var n in ho.NestedHitObjects) schedule(n, end); + } + } + + private void prepareStoryboardSamples(Storyboard? storyboard, double previewEndTime) + { + sampleScheduler.ScheduledStoryboardSamples.Clear(); + if (storyboard?.Layers == null) return; + + foreach (var layer in storyboard.Layers) + { + foreach (var element in layer.Elements) + { + if (element is StoryboardSampleInfo s && s.StartTime >= playback.PreviewStartTime && s.StartTime <= previewEndTime) + { + sampleScheduler.ScheduledStoryboardSamples.Add(new ScheduledStoryboardSample + { + Time = s.StartTime, + Sample = s, + HasTriggered = false + }); + } + } + } + + sampleScheduler.ScheduledStoryboardSamples.Sort((a, b) => a.Time.CompareTo(b.Time)); + // 移除成功日志 + } + + // 样本预加载:去重后调用一次 GetChannel() 以确保缓存 / 文件读取 + private void preloadSamples() + { + try + { + var uniqueHitInfos = new HashSet(); + + foreach (var s in sampleScheduler.ScheduledHitSounds.SelectMany(h => h.Samples)) + { + foreach (var sample in fetchSamplesForInfo(s, true)) + { + string? key = sample?.ToString(); + + if (key != null && uniqueHitInfos.Add(key)) + { + var ch = sample?.GetChannel(); + + if (ch != null) + { + try + { + ch.Stop(); + } + finally + { + // GetChannel() does not register the channel with the Sample unless Play() was invoked, + // so ensure we dispose temporary channels created for preload to avoid relying on finalizers. + if (!ch.IsDisposed && !ch.ManualFree) + ch.Dispose(); + } + } + } + } + } + + var uniqueStoryboard = new HashSet(); + + foreach (var sb in sampleScheduler.ScheduledStoryboardSamples) + { + // 通过统一的 fetchStoryboardSample 进行预热 + var fetched = fetchStoryboardSample(sb.Sample, true); + + if (fetched.sample != null && uniqueStoryboard.Add(fetched.chosenKey)) + { + var ch = fetched.sample.GetChannel(); + + if (ch != null) + { + try + { + ch.Stop(); + } + finally + { + if (!ch.IsDisposed && !ch.ManualFree) + ch.Dispose(); + } + } + } + } + + // 移除成功日志 + } + catch (Exception ex) + { + Logger.Log($"EzPreviewTrackManager: Preload error {ex.Message}", LoggingTarget.Runtime); + } + } + + // 调度函数基于索引推进 + private void updateSamples() + { + if (!playback.IsPlaying || currentTrack == null) return; + + if (!tryGetLogicalTime(out double logicalTime, out bool inBreak)) + { + StopPreviewInternal("loop-end"); + return; + } + + if (inBreak) + { + if (currentTrack.IsRunning) + currentTrack.Stop(); + if (Math.Abs(currentTrack.CurrentTime - logicalTime) > trigger_tolerance) + currentTrack.Seek(logicalTime); + playback.LastTrackTime = logicalTime; + sampleScheduler.ActiveChannels.RemoveAll(c => !c.Playing); + return; + } + + if (!currentTrack.IsRunning) + { + if (currentTrack.IsDisposed) + { + StopPreview(); + return; + } + + currentTrack.Start(); + } + + double drift = Math.Abs(currentTrack.CurrentTime - logicalTime); + + // 在 gameplay 外部时钟模式下,不要为了微小漂移每帧 Seek。 + // 只在漂移明显且超过冷却时间时才重同步一次。 + if (ExternalClock != null) + { + if (drift > audio_resync_tolerance && Clock.CurrentTime - lastAudioResyncClockTime >= audio_resync_cooldown) + { + currentTrack.Seek(logicalTime); + lastAudioResyncClockTime = Clock.CurrentTime; + } + } + else + { + if (drift > trigger_tolerance) + currentTrack.Seek(logicalTime); + } + + // 记录上一次逻辑时间,供外部时钟“暂停/不推进”判定使用。 + // 否则在某些情况下会被误判为暂停,从而导致重复 Seek 到固定时间点。 + playback.LastTrackTime = logicalTime; + + double logicalTimeForEvents = logicalTime; + bool withinWindow = logicalTimeForEvents <= playback.PreviewEndTime + trigger_tolerance; + + sampleScheduler.NextHitSoundIndex = findNextValidIndex(sampleScheduler.ScheduledHitSounds, sampleScheduler.NextHitSoundIndex, logicalTimeForEvents - trigger_tolerance); + + while (withinWindow && sampleScheduler.NextHitSoundIndex < sampleScheduler.ScheduledHitSounds.Count) + { + var hs = sampleScheduler.ScheduledHitSounds[sampleScheduler.NextHitSoundIndex]; + + if (hs.HasTriggered) + { + sampleScheduler.NextHitSoundIndex++; + continue; + } + + if (hs.Time > logicalTime + trigger_tolerance) break; + + if (Math.Abs(hs.Time - logicalTime) <= trigger_tolerance) + { + triggerHitSound(hs.Samples); + hs.HasTriggered = true; + sampleScheduler.ScheduledHitSounds[sampleScheduler.NextHitSoundIndex] = hs; + sampleScheduler.NextHitSoundIndex++; + } + else if (hs.Time < logicalTime - trigger_tolerance) + { + // 已错过(比如用户 Seek) + hs.HasTriggered = true; + sampleScheduler.ScheduledHitSounds[sampleScheduler.NextHitSoundIndex] = hs; + sampleScheduler.NextHitSoundIndex++; + } + else break; + } + + // 同样优化 storyboard samples + sampleScheduler.NextStoryboardSampleIndex = findNextValidIndex(sampleScheduler.ScheduledStoryboardSamples, sampleScheduler.NextStoryboardSampleIndex, logicalTimeForEvents - trigger_tolerance); + + while (withinWindow && sampleScheduler.NextStoryboardSampleIndex < sampleScheduler.ScheduledStoryboardSamples.Count) + { + var sb = sampleScheduler.ScheduledStoryboardSamples[sampleScheduler.NextStoryboardSampleIndex]; + + if (sb.HasTriggered) + { + sampleScheduler.NextStoryboardSampleIndex++; + continue; + } + + if (sb.Time > logicalTime + trigger_tolerance) break; + + if (Math.Abs(sb.Time - logicalTime) <= trigger_tolerance) + { + triggerStoryboardSample(sb.Sample); + sb.HasTriggered = true; + sampleScheduler.ScheduledStoryboardSamples[sampleScheduler.NextStoryboardSampleIndex] = sb; + sampleScheduler.NextStoryboardSampleIndex++; + } + else if (sb.Time < logicalTime - trigger_tolerance) + { + sb.HasTriggered = true; + sampleScheduler.ScheduledStoryboardSamples[sampleScheduler.NextStoryboardSampleIndex] = sb; + sampleScheduler.NextStoryboardSampleIndex++; + } + else break; + } + + playback.LastTrackTime = logicalTimeForEvents; + sampleScheduler.ActiveChannels.RemoveAll(c => !c.Playing); + } + + private void triggerHitSound(HitSampleInfo[] samples) + { + if (samples.Length == 0) return; + + try + { + foreach (var info in samples) + { + bool playedAny = false; + + foreach (var sample in fetchSamplesForInfo(info)) + { + if (sample == null) continue; + + var channelInner = sample.GetChannel(); + + // 同上:仅当命中对象样本显式给出音量 (>0) 时才应用;否则保持默认以跟随系统设置。 + if (info.Volume > 0) + { + double volInner = Math.Clamp(info.Volume / 100.0, 0, 1); + channelInner.Volume.Value = (float)volInner; + } + + channelInner.Play(); + sampleScheduler.ActiveChannels.Add(channelInner); + playedAny = true; + break; // 只需播放命中链中的首个可用样本 + } + + if (!playedAny) + Logger.Log($"EzPreviewTrackManager: Miss hitsound {info.Bank}-{info.Name}", LoggingTarget.Runtime); + } + } + catch (Exception ex) + { + Logger.Log($"EzPreviewTrackManager: triggerHitSound error: {ex}", LoggingTarget.Runtime); + } + } + + // 多级检索:谱面 skin -> 全局 skinSource -> sampleStore (LookupNames) -> Gameplay/ 回退 + private IEnumerable fetchSamplesForInfo(HitSampleInfo info, bool preloadOnly = false) + { + // 1. 谱面皮肤 + var s = currentBeatmap?.Skin.GetSample(info); + if (s != null) yield return s; + + // 2. 全局皮肤源 + var global = skinSource.GetSample(info); + if (global != null) yield return global; + + // 3. LookupNames 走样本库 + foreach (string name in info.LookupNames) + { + // LookupNames 通常包含 Gameplay/ 前缀;若没有尝试补全 + ISample? storeSample = sampleStore.Get(name) ?? sampleStore.Get($"Gameplay/{name}"); + if (storeSample != null) yield return storeSample; + } + + // 4. 兜底(兼容 legacy 组合) + yield return sampleStore.Get($"Gameplay/{info.Bank}-{info.Name}"); + } + + private void triggerStoryboardSample(StoryboardSampleInfo sampleInfo) + { + try + { + var (sample, _, tried) = fetchStoryboardSample(sampleInfo); + + if (sample == null) + { + // Logger.Log($"EzPreviewTrackManager: Miss storyboard sample {sampleInfo.Path} (tried: {string.Join("|", tried)})", LoggingTarget.Runtime); + return; + } + + var channel = sample.GetChannel(); + + // 仅在谱面 Storyboard 显式指定音量 (>0) 时应用相对缩放;否则保持默认,完全跟随系统全局音量/效果音量设置。 + if (sampleInfo.Volume > 0) + { + double vol = Math.Clamp(sampleInfo.Volume / 100.0, 0, 1); + channel.Volume.Value = (float)vol; + } + + channel.Play(); + sampleScheduler.ActiveChannels.Add(channel); + // Logger.Log($"EzPreviewTrackManager: Played storyboard sample {sampleInfo.Path} <- {chosenKey}", LoggingTarget.Runtime); + } + catch (Exception ex) + { + Logger.Log($"EzPreviewTrackManager: triggerStoryboardSample error: {ex}", LoggingTarget.Runtime); + } + } + + /// + /// 统一 storyboard 样本获取逻辑。返回 (sample, 命中的key, 尝试列表) + /// 顺序:缓存 -> beatmap skin -> 全局 skin -> sampleStore.LookupNames -> sampleStore 原Path 变体 + /// + private (ISample? sample, string chosenKey, List tried) fetchStoryboardSample(StoryboardSampleInfo info, bool preload = false) + { + var tried = new List(); + string normalizedPath = info.Path.Replace('\\', '/'); + + // 1. 缓存 + if (sampleScheduler.StoryboardSampleCache.TryGetValue(normalizedPath, out var cached) && cached != null) + return (cached, normalizedPath + "(cache)", tried); + + ISample? selected = null; + string chosenKey = string.Empty; + + void consider(ISample? s, string key) + { + if (s != null && selected == null) + { + selected = s; + chosenKey = key; + } + + tried.Add(key); + } + + // 2. beatmap skin + // StoryboardSampleInfo 实现 ISampleInfo,直接走 Skin.GetSample 会使用其 LookupNames + var beatmapSkinSample = currentBeatmap?.Skin.GetSample(info); + consider(beatmapSkinSample, "beatmapSkin:" + normalizedPath); + + // 3. 全局皮肤 + var globalSkinSample = skinSource.GetSample(info); + consider(globalSkinSample, "globalSkin:" + normalizedPath); + + // 4. LookupNames in sampleStore + foreach (string name in info.LookupNames) + { + string key = name.Replace('\\', '/'); + var s = sampleStore.Get(key); + consider(s, "store:" + key); + if (selected != null) break; + } + + // 5. 额外尝试:去扩展名或补 wav/mp3 (有些 beatmap 在 LookupNames 第二项无扩展) + if (selected == null) + { + string withoutExt = System.IO.Path.ChangeExtension(normalizedPath, null); + + foreach (string ext in new[] { ".wav", ".ogg", ".mp3" }) + { + var s = sampleStore.Get(withoutExt + ext); + consider(s, "store-extra:" + withoutExt + ext); + if (selected != null) break; + } + } + + // 6. 写缓存(即使 null 也缓存,避免重复磁盘尝试;预加载阶段写入,触发阶段复用) + sampleScheduler.StoryboardSampleCache[normalizedPath] = selected; + + return (selected, chosenKey, tried); + } + + private void clearEnhancedElements() + { + // 停止并释放所有仍在播放的样本通道,避免依赖最终化器来回收短期通道 + foreach (var channel in sampleScheduler.ActiveChannels) + { + try + { + channel.Stop(); + } + catch + { + } + + try + { + if (!channel.IsDisposed && !channel.ManualFree) + channel.Dispose(); + } + catch + { + } + } + + sampleScheduler.Reset(); + playback.ShortBgmOneShotMode = false; + playback.ShortBgmMutedAfterFirstLoop = false; + // 避免在非更新线程直接操作 InternalChildren 导致 InvalidThreadForMutationException + Schedule(() => audioContainer.Clear()); + } + + private struct ScheduledHitSound + { + public double Time; + public HitSampleInfo[] Samples; + public bool HasTriggered; + } + + private struct ScheduledStoryboardSample + { + public double Time; + public StoryboardSampleInfo Sample; + public bool HasTriggered; + } + + // 二分查找辅助方法:找到第一个 Time >= minTime 的索引 + private static int findNextValidIndex(List list, int startIndex, double minTime) + { + int low = startIndex, high = list.Count - 1; + + while (low <= high) + { + int mid = (low + high) / 2; + double time = list[mid].Time; + + if (time < minTime) + low = mid + 1; + else + high = mid - 1; + } + + return low; + } + + private static int findNextValidIndex(List list, int startIndex, double minTime) + { + int low = startIndex, high = list.Count - 1; + + while (low <= high) + { + int mid = (low + high) / 2; + double time = list[mid].Time; + + if (time < minTime) + low = mid + 1; + else + high = mid - 1; + } + + return low; + } + + private bool tryGetLogicalTime(out double logicalTime, out bool inBreak) + { + logicalTime = playback.PreviewStartTime; + inBreak = false; + + if (!playback.UseExternalLooping) + return legacyTrackLogicalTime(out logicalTime, out inBreak); + + double referenceTime = ExternalClock?.CurrentTime ?? currentTrack?.CurrentTime ?? 0; + + // 如果 gameplay 时钟暂停(或“看似在跑但时间不推进”),音频也应保持暂停。 + // 否则 updateSamples() 会每帧 Seek 到固定时间点,而音频设备继续播放, + // 听起来像“卡带”一样重复同一小段。 + if (ExternalClock != null) + { + // 有些时钟在暂停时仍可能 IsRunning=true,但 CurrentTime 不再推进。 + // 这里在已观察到上一次 referenceTime 后,把近似 0 的 delta 视为暂停。 + const double paused_delta_epsilon = 0.5; // ms + + if (!ExternalClock.IsRunning + || (playback.LastReferenceTime != 0 && Math.Abs(referenceTime - playback.LastReferenceTime) <= paused_delta_epsilon)) + { + inBreak = true; + logicalTime = playback.LastTrackTime == 0 ? playback.PreviewStartTime : playback.LastTrackTime; + playback.LastReferenceTime = referenceTime; + return true; + } + } + + if (!playback.ExternalClockStartCaptured) + { + playback.ExternalClockStartReference = ExternalClockStartTime ?? referenceTime; + playback.ExternalClockStartCaptured = true; + } + + double timeSinceStart = referenceTime - playback.ExternalClockStartReference; + + double segmentLen = playback.LoopSegmentLength + playback.LoopInterval; + if (segmentLen <= 0) + return false; + + if (timeSinceStart < 0) + { + inBreak = true; // 外部时钟尚未到达起点,保持暂停 + logicalTime = playback.PreviewStartTime; + playback.LastReferenceTime = referenceTime; + return true; + } + + double segment = Math.Floor(timeSinceStart / segmentLen); + + if (segment < 0) + { + inBreak = true; + logicalTime = playback.PreviewStartTime; + playback.LastReferenceTime = referenceTime; + return true; + } + + if (segment >= playback.EffectiveLoopCount) + return false; + + double offset = timeSinceStart - segment * segmentLen; + + if (offset < playback.LoopSegmentLength) + { + logicalTime = playback.PreviewStartTime + offset; + playback.LastReferenceTime = referenceTime; + return true; + } + + inBreak = true; + logicalTime = playback.PreviewEndTime; + + if (ExternalClock == null && playback.UseExternalLooping && inBreak && currentTrack != null && !currentTrack.IsRunning) + { + referenceTime += Clock.ElapsedFrameTime; + } + + playback.LastReferenceTime = referenceTime; + return true; + } + + private bool legacyTrackLogicalTime(out double logicalTime, out bool inBreak) + { + double physicalTime = currentTrack?.CurrentTime ?? 0; + + // 回绕检测阈值200ms:避免因 Seek 导致的误判 + if (physicalTime + 200 < playback.LastTrackTime) + { + playback.LegacyLoopCount++; + playback.LegacyLogicalOffset = playback.LegacyLoopCount * playback.LoopSegmentLength; + + if (playback.ShortBgmOneShotMode && !playback.ShortBgmMutedAfterFirstLoop && currentTrack != null) + { + currentTrack.Volume.Value = 0f; + playback.ShortBgmMutedAfterFirstLoop = true; + } + } + + logicalTime = physicalTime + playback.LegacyLogicalOffset; + inBreak = false; + return true; + } + + private sealed class PlaybackState + { + public bool IsPlaying; + + public double PreviewStartTime; + public double PreviewEndTime; + + public double LastTrackTime; + + public double LoopSegmentLength; + public double LoopInterval; + public int EffectiveLoopCount; + public bool UseExternalLooping; + + public int LegacyLoopCount; + public double LegacyLogicalOffset; + + public bool ShortBgmOneShotMode; + public bool ShortBgmMutedAfterFirstLoop; + + public double ExternalClockStartReference; + public bool ExternalClockStartCaptured; + public double LastReferenceTime; + + public void ResetExternalClockCapture() + { + ExternalClockStartCaptured = false; + ExternalClockStartReference = 0; + } + + public void ResetPlaybackProgress() + { + LastTrackTime = 0; + LegacyLoopCount = 0; + LegacyLogicalOffset = 0; + LastReferenceTime = 0; + } + } + + private sealed class SampleSchedulerState + { + public readonly List ScheduledHitSounds = new List(); + public readonly List ScheduledStoryboardSamples = new List(); + public readonly Dictionary StoryboardSampleCache = new Dictionary(); + public readonly List ActiveChannels = new List(); + + public int NextHitSoundIndex; + public int NextStoryboardSampleIndex; + + public void ResetIndices() + { + NextHitSoundIndex = 0; + NextStoryboardSampleIndex = 0; + } + + public void Reset() + { + ActiveChannels.Clear(); + ScheduledHitSounds.Clear(); + ScheduledStoryboardSamples.Clear(); + StoryboardSampleCache.Clear(); + ResetIndices(); + } + } + + protected virtual Track? CreateTrack(IWorkingBeatmap beatmap, out bool ownsTrack) + { + ownsTrack = false; + return beatmap.Track; + } + } + + /// + /// 预览覆盖参数集合,用于一次性配置 的切片与循环行为。 + /// + public class PreviewOverrideSettings + { + /// + /// 预览起点(毫秒)。null 表示使用谱面元数据的 PreviewTime。 + /// + public double? PreviewStart { get; init; } + + /// + /// 预览段长度(毫秒)。null 表示使用默认值。 + /// + public double? PreviewDuration { get; init; } + + /// + /// 循环次数。null 表示使用默认值(标准预览通常为 1,增强预览通常为无限)。 + /// + public int? LoopCount { get; init; } + + /// + /// 循环间隔(毫秒)。null 表示使用默认值。 + /// + public double? LoopInterval { get; init; } + + /// + /// 是否强制开启底层 Track.Looping。 + /// 注意:在启用外部驱动切片循环时,该项不会用于实现 Duration/LoopCount/LoopInterval 的严格约束。 + /// + public bool? ForceLooping { get; init; } + + public bool EnableHitSounds { get; init; } = true; + } +} diff --git a/osu.Game/LAsEzExtensions/Select/EzToCollection.txt b/osu.Game/LAsEzExtensions/Select/EzToCollection.txt new file mode 100644 index 0000000000..bed418121f --- /dev/null +++ b/osu.Game/LAsEzExtensions/Select/EzToCollection.txt @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Collections; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Select.Filter +{ + public partial class EzToCollection : OsuButton + { + public Func GetCurrentCriteria { private get; set; } = null!; + public Action ShowNamingDialog { private get; set; } = null!; + public IEnumerable ExistingCollections { private get; set; } = null!; + + public EzToCollection() + : base(HoverSampleSet.Default) + { + Text = "保存为合集"; + RelativeSizeAxes = Axes.X; + Height = 30; + + Action = () => + { + var currentCriteria = GetCurrentCriteria(); + if (currentCriteria == null) + return; + + // 动态生成合集名称 + int collectionNumber = 1; + string collectionName; + + do + { + collectionName = $"EzCollection{collectionNumber++}"; + } while (collectionExists(collectionName)); // 确保名称唯一 + + // 创建一个新的合集 + var newCollection = new BeatmapCollection + { + Name = collectionName + }; + + // 将筛选结果添加到合集 + var beatmapHashes = currentCriteria.CollectionBeatmapMD5Hashes; + + if (beatmapHashes != null) + { + foreach (string hash in beatmapHashes) + { + newCollection.BeatmapMD5Hashes.Add(hash); + } + } + + // 弹出命名对话框 + ShowNamingDialog(newCollection); + }; + } + + private bool collectionExists(string name) + { + return ExistingCollections?.Any(c => c.Name == name) ?? false; + } + } +} diff --git a/osu.Game/LAsEzExtensions/Select/ManiaRulesetDropdown.txt b/osu.Game/LAsEzExtensions/Select/ManiaRulesetDropdown.txt new file mode 100644 index 0000000000..ae99093284 --- /dev/null +++ b/osu.Game/LAsEzExtensions/Select/ManiaRulesetDropdown.txt @@ -0,0 +1,197 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Select.Filter +{ + public partial class ManiaRulesetDropdown : OsuDropdown + { + // TODO:多子集切换 + protected virtual bool ShowManageCollectionsItem => true; + + public Action? RequestFilter { private get; set; } + + private readonly BindableList filters = new BindableList(); + + public readonly Live? Collection; + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IDisposable? realmSubscription; + + public ManiaRulesetDropdown() + { + Items = Enum.GetValues(typeof(SelectManiaRulesetSubset)).Cast(); + AlwaysShowSearchBar = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(selectionChanged); + } + + private Live? lastFiltered; + + private void selectionChanged(ValueChangedEvent filter) + { + if (filter.NewValue.IsNull()) + return; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + realmSubscription?.Dispose(); + } + + protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader(); + + protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); + + protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); + + protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); + + public partial class CollectionDropdownHeader : OsuDropdownHeader + { + public CollectionDropdownHeader() + { + Height = 25; + Chevron.Size = new Vector2(12); + Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 8 }; + } + } + + protected partial class CollectionDropdownMenu : OsuDropdownMenu + { + public CollectionDropdownMenu() + { + MaxHeight = 200; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownDrawableMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; + } + + protected partial class CollectionDropdownDrawableMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem + { + private IconButton addOrRemoveButton = null!; + + private bool beatmapInCollection; + + private readonly Live? collection; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public CollectionDropdownDrawableMenuItem(MenuItem item) + : base(item) + { + collection = ((DropdownMenuItem)item).Value.Collection; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(addOrRemoveButton = new NoFocusChangeIconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -OsuScrollContainer.SCROLL_BAR_WIDTH, + Scale = new Vector2(0.65f), + Action = addOrRemove, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (collection != null) + { + beatmap.BindValueChanged(_ => + { + beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash)); + + addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; + addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; + addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; + + updateButtonVisibility(); + }, true); + } + + updateButtonVisibility(); + } + + protected override bool OnHover(HoverEvent e) + { + updateButtonVisibility(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateButtonVisibility(); + base.OnHoverLost(e); + } + + protected override void OnSelectChange() + { + base.OnSelectChange(); + updateButtonVisibility(); + } + + private void updateButtonVisibility() + { + if (collection == null) + addOrRemoveButton.Alpha = 0; + else + addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; + } + + private void addOrRemove() + { + Debug.Assert(collection != null); + + collection.PerformWrite(c => + { + if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) + c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); + }); + } + + protected override Drawable CreateContent() => (Content)base.CreateContent(); + + private partial class NoFocusChangeIconButton : IconButton + { + public override bool ChangeFocusOnClick => false; + } + } + } +} diff --git a/osu.Game/LAsEzExtensions/UserInterface/EzDisplay_LineGraph.cs b/osu.Game/LAsEzExtensions/UserInterface/EzDisplay_LineGraph.cs new file mode 100644 index 0000000000..c26a0de6fe --- /dev/null +++ b/osu.Game/LAsEzExtensions/UserInterface/EzDisplay_LineGraph.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Layout; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.UserInterface +{ + public partial class EzDisplayLineGraph : Container + { + public float? MaxValue { get; set; } + + public float? MinValue { get; set; } + + public float ActualMaxValue { get; private set; } = float.NaN; + public float ActualMinValue { get; private set; } = float.NaN; + + private const double transform_duration = 1500; + + public int DefaultValueCount; + + private readonly Container maskingContainer; + private readonly Path path; + + private float[] values; + private int valuesCount; + + public Color4 LineColour + { + get => maskingContainer.Colour; + set => maskingContainer.Colour = value; + } + + public EzDisplayLineGraph() + { + Add(maskingContainer = new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Child = path = new SmoothPath + { + AutoSizeAxes = Axes.None, + RelativeSizeAxes = Axes.Both, + PathRadius = 1 + } + }); + + AddLayout(pathCached); + } + + public void SetValues(IReadOnlyList source) + { + if (source == null) + return; + + int count = source.Count; + valuesCount = count; + + if (count == 0) + { + ActualMaxValue = float.NaN; + ActualMinValue = float.NaN; + pathCached.Invalidate(); + return; + } + + if (values == null || values.Length < count) + values = new float[count]; + + float max = float.MinValue; + float min = float.MaxValue; + + for (int i = 0; i < count; i++) + { + float v = (float)source[i]; + values[i] = v; + if (v > max) max = v; + if (v < min) min = v; + } + + if (MaxValue > max) max = MaxValue.Value; + if (MinValue < min) min = MinValue.Value; + + ActualMaxValue = max; + ActualMinValue = min; + + pathCached.Invalidate(); + + maskingContainer.Width = 0; + maskingContainer.ResizeWidthTo(1, transform_duration, Easing.OutQuint); + } + + private readonly LayoutValue pathCached = new LayoutValue(Invalidation.DrawSize); + + protected override void Update() + { + base.Update(); + + if (!pathCached.IsValid) + { + applyPath(); + pathCached.Validate(); + } + } + + private void applyPath() + { + path.ClearVertices(); + + int count = valuesCount; + if (count <= 0) + return; + + int totalCount = Math.Max(count, DefaultValueCount); + + for (int i = 0; i < count; i++) + { + float x = (i + totalCount - count) / (float)(totalCount - 1) * (DrawWidth - 2 * path.PathRadius); + float y = GetYPosition(values[i]) * (DrawHeight - 2 * path.PathRadius); + path.AddVertex(new Vector2(x, y)); + } + } + + protected float GetYPosition(float value) + { + if (ActualMaxValue == ActualMinValue) + return value > 1 ? 0 : 1; + + return (ActualMaxValue - value) / (ActualMaxValue - ActualMinValue); + } + } +} diff --git a/osu.Game/LAsEzExtensions/UserInterface/EzDisplay_XxySR.cs b/osu.Game/LAsEzExtensions/UserInterface/EzDisplay_XxySR.cs new file mode 100644 index 0000000000..0e21b75dac --- /dev/null +++ b/osu.Game/LAsEzExtensions/UserInterface/EzDisplay_XxySR.cs @@ -0,0 +1,149 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.UserInterface +{ + /// + /// A pill that displays xxy_SR (mania). + /// + public partial class EzDisplayXxySR : CompositeDrawable, IHasCurrentValue + { + private readonly Box background; + private readonly SpriteIcon moonIcon; + private readonly OsuSpriteText srText; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider? colourProvider { get; set; } + + public EzDisplayXxySR(double? initialValue = null) + { + Current.Value = initialValue; + + AutoSizeAxes = Axes.Both; + + InternalChild = new CircularContainer + { + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Horizontal = 7f }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 3f), + new Dimension(GridSizeMode.AutoSize, minSize: 25f), + }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] + { + new[] + { + moonIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Moon, + Size = new Vector2(8f), + }, + Empty(), + srText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 1.5f }, + Spacing = new Vector2(-1.4f), + Font = OsuFont.Torus.With(size: 14.4f, weight: FontWeight.Bold, fixedWidth: true), + Shadow = false, + }, + } + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(v => updateDisplay(v.NewValue), true); + } + + private void updateDisplay(double? sr) + { + if (sr == null) + { + srText.Text = "..."; + + // Placeholder state: keep the pill background subtle, but ensure icon/text remain visible. + background.Colour = colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); + moonIcon.Colour = colourProvider?.Content2 ?? Color4.White.Opacity(0.9f); + srText.Colour = colourProvider?.Content2 ?? Color4.White.Opacity(0.9f); + return; + } + + if (sr.Value < 0) + { + srText.Text = "-"; + } + else + { +#if DEBUG + // Debug: show 4 decimal places for easier detection of value reuse. + // Keep the same "never round up" behaviour as FormatUtils.FormatStarRating(). + srText.Text = sr.Value.FloorToDecimalDigits(4).ToLocalisableString("0.0000"); +#else + // Release: match official star formatting (2 decimal places). + srText.Text = sr.Value.FormatStarRating(); +#endif + } + + background.Colour = colours.ForStarDifficulty(sr.Value); + + moonIcon.Colour = sr.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF + ? colours.Orange1 + : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); + + srText.Colour = sr.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF + ? colours.Orange1 + : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); + } + } +} diff --git a/osu.Game/LAsEzExtensions/UserInterface/TriangleBorderLineGraph.cs b/osu.Game/LAsEzExtensions/UserInterface/TriangleBorderLineGraph.cs new file mode 100644 index 0000000000..ea67ba98f2 --- /dev/null +++ b/osu.Game/LAsEzExtensions/UserInterface/TriangleBorderLineGraph.cs @@ -0,0 +1,193 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Rendering; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.UserInterface; +using osuTK.Graphics; + +namespace osu.Game.LAsEzExtensions.UserInterface +{ + public partial class TriangleBorderLineGraph : LineGraph + { + private float thickness = 0.15f; + + /// + /// The thickness of the triangle border effect. + /// + public float Thickness + { + get => thickness; + set + { + if (thickness == value) return; + + thickness = value; + Invalidate(Invalidation.DrawNode); + } + } + + private float texelSize = 0.005f; + + /// + /// The texel size for the border effect. + /// + public float TexelSize + { + get => texelSize; + set + { + if (texelSize == value) return; + + texelSize = value; + Invalidate(Invalidation.DrawNode); + } + } + + /// + /// The colour of the main line. + /// + public new Color4 LineColour + { + get => base.LineColour; + set => base.LineColour = value; + } + + /// + /// The base colour of the triangle border effect, similar to TrianglesV2. + /// This affects the overall colour of the line segments and supports gradients. + /// + public new ColourInfo Colour + { + get => base.Colour; + set => base.Colour = value; + } + + /// + /// The colour of the border (for compatibility, not used in shader version). + /// + public new Color4 BorderColour { get; set; } + + public TriangleBorderLineGraph() + { + // Set up blending for the triangle effect + Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + // Modify the path's shader to use TriangleBorder for the triangle effect + var pathField = typeof(LineGraph).GetField("path", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (pathField != null) + { + if (pathField.GetValue(this) is Path path) + { + // Replace the path with our custom TriangleBorderPath + var triangleBorderPath = new TriangleBorderPath(thickness, texelSize) + { + AutoSizeAxes = path.AutoSizeAxes, + RelativeSizeAxes = path.RelativeSizeAxes, + PathRadius = path.PathRadius + }; + + // Copy vertices + triangleBorderPath.ClearVertices(); + foreach (var vertex in path.Vertices) + triangleBorderPath.AddVertex(vertex); + + // Replace in the masking container + var maskingContainerField = typeof(LineGraph).GetField("maskingContainer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (maskingContainerField != null) + { + if (maskingContainerField.GetValue(this) is Container maskingContainer) + { + maskingContainer.Child = triangleBorderPath; + pathField.SetValue(this, triangleBorderPath); + } + } + } + } + } + } + + public partial class TriangleBorderPath : Path + { + private readonly float thickness; + private readonly float texelSize; + + public TriangleBorderPath(float thickness, float texelSize) + { + this.thickness = thickness; + this.texelSize = texelSize; + } + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + // Use reflection to set the TriangleBorder shader + var shaderField = typeof(Path).GetField("TextureShader", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (shaderField != null) + { + var triangleBorderShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder"); + shaderField.SetValue(this, triangleBorderShader); + } + } + + protected override DrawNode CreateDrawNode() => new TriangleBorderPathDrawNode(this); + + private class TriangleBorderPathDrawNode : DrawNode + { + protected new TriangleBorderPath Source => (TriangleBorderPath)base.Source; + + private IUniformBuffer? borderDataBuffer; + private IShader? shader; + + public TriangleBorderPathDrawNode(TriangleBorderPath source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + shader = Source.TextureShader; + } + + protected override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + // Set up TriangleBorder uniform data and bind it + if (shader != null) + { + borderDataBuffer ??= renderer.CreateUniformBuffer(); + borderDataBuffer.Data = borderDataBuffer.Data with + { + Thickness = Source.thickness, + TexelSize = Source.texelSize + }; + + shader.BindUniformBlock("m_BorderData", borderDataBuffer); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + borderDataBuffer?.Dispose(); + } + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index f707f0d198..02742aa575 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -37,6 +37,7 @@ using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Extensions; +using osu.Game.LAsEzExtensions.Analysis; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -163,6 +164,15 @@ namespace osu.Game [Resolved] private FrameworkConfigManager frameworkConfig { get; set; } + private const int non_gameplay_draw_multiplier = 4; + private const int maximum_sane_draw_fps = 8000; + + private Bindable frameSyncMode; + + private GameHost gameHost; + + private bool gameplayScreenActive; + private DifficultyRecommender difficultyRecommender; [Cached] @@ -359,6 +369,8 @@ namespace osu.Game { base.SetHost(host); + gameHost = host; + if (host.Window != null) { host.Window.CursorState |= CursorState.Hidden; @@ -1053,6 +1065,11 @@ namespace osu.Game { base.LoadComplete(); + frameSyncMode = frameworkConfig.GetBindable(FrameworkSetting.FrameSync); + frameSyncMode.BindValueChanged(_ => Schedule(updateDrawLimiter), true); + + gameHost?.Window?.CurrentDisplayMode.BindValueChanged(_ => Schedule(updateDrawLimiter), true); + var languages = Enum.GetValues(); var mappings = languages.Select(language => @@ -1262,6 +1279,7 @@ namespace osu.Game loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add); loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); + loadComponentSingleFile(new EzManiaAnalysisWarmupProcessor(), Add); loadComponentSingleFile(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true); loadComponentSingleFile(new QueueController(), Add, true); @@ -1376,6 +1394,24 @@ namespace osu.Game if (entry.Exception is SentryOnlyDiagnosticsException) return; + // Custom builds may hit server-side gating for online features. + // These messages are not actionable for end-users of this fork, so avoid spamming notifications. + if (entry.Message?.Contains("Realtime online functionality is not supported on this version of the game", StringComparison.OrdinalIgnoreCase) == true) + return; + + if (entry.Message?.Contains("Please ensure that you are using the latest version of the official game releases", StringComparison.OrdinalIgnoreCase) == true) + return; + + if (entry.Message?.Contains("Your score will not be submitted", StringComparison.OrdinalIgnoreCase) == true) + return; + + if (entry.Message?.Contains("This is not an official build of the game", StringComparison.OrdinalIgnoreCase) == true) + return; + + // Some of the above messages are logged with a blank separator line at important level. + if (string.IsNullOrWhiteSpace(entry.Message) && entry.Target == LoggingTarget.Network) + return; + const int short_term_display_limit = 3; if (generalLogRecentCount < short_term_display_limit) @@ -1733,6 +1769,91 @@ namespace osu.Game skinEditor.SetTarget(newOsuScreen); } + + gameplayScreenActive = newScreen is Player || newScreen is PlayerLoader; + Schedule(updateDrawLimiter); + } + + private void updateDrawLimiter() + { + // 暂时屏蔽测试情况 + // return; + + if (gameHost?.Window == null) + return; + + int refreshRate = (int)MathF.Round(gameHost.Window.CurrentDisplayMode.Value.RefreshRate); + + // For invalid refresh rates let's assume 60 Hz as it is most common. + if (refreshRate <= 0) + refreshRate = 120; + + int drawLimiter; + bool shouldVSync; + bool shouldThrottleTextureUploads; + + if (gameplayScreenActive) + { + // gameplay 期间遵循玩家配置的帧同步(FrameSync)。 + drawLimiter = refreshRate; + shouldVSync = false; + shouldThrottleTextureUploads = false; + + if (frameSyncMode != null) + { + switch (frameSyncMode.Value) + { + case FrameSync.VSync: + case FrameSync.Unlimited: + drawLimiter = int.MaxValue; + shouldVSync = frameSyncMode.Value == FrameSync.VSync; + break; + + case FrameSync.Limit2x: + drawLimiter *= 2; + break; + + case FrameSync.Limit4x: + drawLimiter *= 4; + break; + + case FrameSync.Limit8x: + drawLimiter *= 8; + break; + } + } + } + else + { + // 非 gameplay 场景强制按显示器刷新率的 4 倍绘制。 + drawLimiter = refreshRate * non_gameplay_draw_multiplier; + + // 额外强制关闭 VSync,避免 draw 被量化到刷新率(并尽量避免 OpenGL 下类似 glFinish 的额外停顿)。 + shouldVSync = false; + + // UI 界面在快速滚动时可能会大量流式加载纹理(封面/背景等)。 + // 通过限制“每帧上传预算”来降低帧时间尖刺。 + shouldThrottleTextureUploads = true; + } + + // 仅对 draw 应用与 framework 类似的“合理上限”限制。 + if (!gameHost.AllowBenchmarkUnlimitedFrames) + drawLimiter = Math.Min(maximum_sane_draw_fps, drawLimiter); + + gameHost.MaximumDrawHz = drawLimiter; + + gameHost.SetVerticalSync(shouldVSync); + + if (shouldThrottleTextureUploads) + { + // 偏保守的默认值:优先保证交互流畅,代价是缩略图/封面加载完成会稍慢。 + // 前者提高随机速度,后者提高顺序速度。 + gameHost.SetTextureUploadLimits(maxTexturesUploadedPerFrame: 12, maxPixelsUploadedPerFrame: 1024 * 1024); + } + else + { + gameHost.RestoreTextureUploadLimits(); + } } private void screenPushed(IScreen lastScreen, IScreen newScreen) => ScreenChanged((OsuScreen)lastScreen, (OsuScreen)newScreen); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 222427cb60..ffb5925b07 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -45,6 +45,10 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; +using osu.Game.LAsEzExtensions; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Analysis.Persistence; using osu.Game.Localisation; using osu.Game.Online; using osu.Game.Online.API; @@ -76,9 +80,9 @@ namespace osu.Game public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { #if DEBUG - public const string GAME_NAME = "osu! (development)"; + public const string GAME_NAME = "ez2osu! (development)"; #else - public const string GAME_NAME = "osu!"; + public const string GAME_NAME = "ez2osu!"; #endif public const string OSU_PROTOCOL = "osu://"; @@ -172,6 +176,10 @@ namespace osu.Game protected Storage Storage { get; set; } + protected Ez2ConfigManager Ez2ConfigManager { get; private set; } + + protected EzLocalTextureFactory NoteFactory { get; private set; } + /// /// The language in which the game is currently displayed in. /// @@ -204,6 +212,7 @@ namespace osu.Game public readonly Bindable>> AvailableMods = new Bindable>>(new Dictionary>()); private BeatmapDifficultyCache difficultyCache; + private EzBeatmapManiaAnalysisCache maniaAnalysisCache; private IBeatmapUpdater beatmapUpdater; private UserLookupCache userCache; @@ -276,6 +285,15 @@ namespace osu.Game Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); + // 初始化并注册EzSkinSettingsManager + dependencies.Cache(Ez2ConfigManager = new Ez2ConfigManager(Storage)); + + dependencies.Cache( + NoteFactory = new EzLocalTextureFactory( + Ez2ConfigManager, + Host.Renderer, + Storage)); + dependencies.Cache(realm = new RealmAccess(Storage, CLIENT_DATABASE_FILENAME, Host.UpdateThread)); dependencies.CacheAs(RulesetStore = new RealmRulesetStore(realm, Storage)); @@ -325,11 +343,15 @@ namespace osu.Game dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); dependencies.CacheAs(BeatmapManager); + dependencies.Cache(new EzManiaAnalysisPersistentStore(Storage)); + dependencies.Cache(maniaAnalysisCache = new EzBeatmapManiaAnalysisCache()); + dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); // Add after all the above cache operations as it depends on them. base.Content.Add(difficultyCache); + base.Content.Add(maniaAnalysisCache); // TODO: OsuGame or OsuGameBase? dependencies.CacheAs(beatmapUpdater = CreateBeatmapUpdater()); @@ -435,6 +457,9 @@ namespace osu.Game // if this becomes a more common thing, tracked settings should be reconsidered to allow local DI. LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; LocalConfig.LookupKeyBindings = l => KeyBindingStore.GetBindingsStringFor(l); + + // 添加自动背景捕获组件,启用亚克力效果 + // base.Content.Add(new AutoBackgroundCapture()); } private void updateLanguage() => CurrentLanguage.Value = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 9ba3b3774f..abac82b7d6 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -324,6 +324,8 @@ namespace osu.Game.Overlays.Mods }); } + yield return createModColumnContent(ModType.LA_Mod); + yield return createModColumnContent(ModType.YuLiangSSS_Mod); yield return createModColumnContent(ModType.DifficultyReduction); yield return createModColumnContent(ModType.DifficultyIncrease); yield return createModColumnContent(ModType.Automation); diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index e66b999540..02cb200e16 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -130,7 +130,7 @@ namespace osu.Game.Overlays // All looks good, forward away! forwardNotification(notification); - }, notification.IsImportant ? 12000 : 2500); + }, notification.IsImportant ? 3000 : 1000); //缩短通知驻留时长 } private void forwardNotification(Notification notification) diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index 811f6b606a..4a8b7e57fa 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -8,9 +8,12 @@ using System.Collections.Generic; using System.Linq; using osu.Framework; using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.LAsEzExtensions.Audio; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Framework.Logging; +using osu.Game.LAsEzExtensions.Configuration; namespace osu.Game.Overlays.Settings.Sections.Audio { @@ -21,6 +24,10 @@ namespace osu.Game.Overlays.Settings.Sections.Audio [Resolved] private AudioManager audio { get; set; } = null!; + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; + + private SettingsDropdown? sampleRateDropdown; private SettingsDropdown dropdown = null!; private SettingsCheckbox? wasapiExperimental; @@ -33,12 +40,26 @@ namespace osu.Game.Overlays.Settings.Sections.Audio dropdown = new AudioDeviceSettingsDropdown { LabelText = AudioSettingsStrings.OutputDevice, - Keywords = new[] { "speaker", "headphone", "output" } + Keywords = new[] { "speaker", "headphone", "output" }, + TooltipText = "ASIO is testing! For virtual devices, you may need to switch between physical devices before switching back to virtual devices, or the virtual device will be inactive." }, }; + audio.OnNewDevice += onDeviceChanged; + audio.OnLostDevice += onDeviceChanged; + dropdown.Current = audio.AudioDevice; + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) { + Add(sampleRateDropdown = new SettingsDropdown + { + LabelText = "ASIO Sample Rate(Testing)", + Keywords = new[] { "sample", "rate", "frequency" }, + Items = AudioExtensions.COMMON_SAMPLE_RATES, + Current = ezConfig.GetBindable(Ez2Setting.AsioSampleRate), + // Current = new Bindable(audio.GetSampleRate()), + TooltipText = "48k is better, too high a value will cause delays and clock synchronization errors" + }); Add(wasapiExperimental = new SettingsCheckbox { LabelText = AudioSettingsStrings.WasapiLabel, @@ -47,12 +68,30 @@ namespace osu.Game.Overlays.Settings.Sections.Audio Keywords = new[] { "wasapi", "latency", "exclusive" } }); - wasapiExperimental.Current.ValueChanged += _ => onDeviceChanged(string.Empty); - } + // Setup ASIO sample rate synchronization + audio.SetupAsioSampleRateSync(actualSampleRate => + { + Schedule(() => + { + // Logger.Log($"ASIO sync: actualSampleRate={actualSampleRate}", LoggingTarget.Runtime, LogLevel.Debug); + sampleRateDropdown.Current.Value = actualSampleRate; + }); + }); - audio.OnNewDevice += onDeviceChanged; - audio.OnLostDevice += onDeviceChanged; - dropdown.Current = audio.AudioDevice; + wasapiExperimental.Current.ValueChanged += _ => onDeviceChanged(string.Empty); + sampleRateDropdown.Current.ValueChanged += e => + { + Logger.Log($"User set sample rate to {e.NewValue}Hz", LoggingTarget.Runtime, LogLevel.Debug); + audio.SetPreferredAsioSampleRate(e.NewValue); + }; + + // 根据初始设备类型显示或隐藏采样率设置 + dropdown.Current.ValueChanged += e => + { + if (e.NewValue.Contains("(ASIO)")) sampleRateDropdown.Show(); + else sampleRateDropdown.Hide(); + }; + } onDeviceChanged(string.Empty); } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 779d5cdf00..5e4603a7b4 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; using osu.Game.Rulesets.Scoring; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay protected override LocalisableString Header => CommonStrings.General; [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, Ez2ConfigManager ezConfig) { Children = new Drawable[] { @@ -26,6 +27,22 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Current = config.GetBindable(OsuSetting.ScoreDisplayMode), Keywords = new[] { "scoring" } }, + new SettingsSlider + { + LabelText = EzLocalizationManager.AccuracyCutoffS, + Current = ezConfig.GetBindable(Ez2Setting.AccuracyCutoffS), + KeyboardStep = 0.01f, + DisplayAsPercentage = true, + Keywords = new[] { "mania" } + }, + new SettingsSlider + { + LabelText = EzLocalizationManager.AccuracyCutoffA, + Current = ezConfig.GetBindable(Ez2Setting.AccuracyCutoffA), + KeyboardStep = 0.01f, + DisplayAsPercentage = true, + Keywords = new[] { "mania" } + }, new SettingsCheckbox { LabelText = GraphicsSettingsStrings.HitLighting, diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs index c245a1a9ea..7176ec029e 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay protected override LocalisableString Header => GameplaySettingsStrings.InputHeader; [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, Ez2ConfigManager ezConfig) { Children = new Drawable[] { @@ -46,6 +47,15 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) }); } + + if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS) + { + Add(new SettingsCheckbox + { + LabelText = EzLocalizationManager.DisableCmdSpace, + Current = ezConfig.GetBindable(Ez2Setting.GameplayDisableCmdSpace) + }); + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index cdc4f328c3..57c28eac26 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -19,6 +19,7 @@ using osu.Framework.Platform.Windows; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; using osuTK; using osuTK.Graphics; @@ -70,7 +71,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private const int transition_duration = 400; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, GameHost host) + private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, GameHost host, Ez2ConfigManager ezConfig) { window = host.Window; @@ -150,6 +151,11 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics Current = osuConfig.GetBindable(OsuSetting.Scaling), Keywords = new[] { "scale", "letterbox" }, }, + new SettingsEnumDropdown + { + LabelText = "Scaling To Game Mode", + Current = ezConfig.GetBindable(Ez2Setting.ScalingGameMode), + }, scalingSettings = new FillFlowContainer> { Direction = FillDirection.Vertical, diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index c9ef6ef891..150d357de2 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -515,7 +515,12 @@ namespace osu.Game.Overlays.Settings.Sections.Input private void tryPersistKeyBinding(RealmKeyBinding keyBinding, bool advanceToNextBinding, bool restoringDefaults = false) { List bindings = GetAllSectionBindings(); - RealmKeyBinding? existingBinding = keyBinding.KeyCombination.Equals(new KeyCombination(InputKey.None)) + + // 检查是否是Mania规则集的操作 + var actionType = Action.GetType(); + bool isManiaAction = actionType.Namespace?.StartsWith("osu.Game.Rulesets.Mania", StringComparison.Ordinal) == true; + + RealmKeyBinding? existingBinding = isManiaAction || keyBinding.KeyCombination.Equals(new KeyCombination(InputKey.None)) ? null : bindings.FirstOrDefault(other => isConflictingBinding(keyBinding, other, restoringDefaults)); diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index f1b1511df8..87af02035f 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Maintenance; @@ -23,6 +24,7 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { + new AnalysisSettings(), new GeneralSettings(), new BeatmapSettings(), new SkinSettings(), diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 4d7c0117e2..3b706c8a32 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -127,6 +127,9 @@ namespace osu.Game.Overlays.Settings.Sections // In the future we should change this to properly handle ChangeSet events. dropdownItems.Clear(); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.EZ2_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.EZ_STYLE_PRO_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.SBI_SKIN).ToLive(realm)); dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_SKIN).ToLive(realm)); dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_PRO_SKIN).ToLive(realm)); dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm)); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 193d570a21..3cb06d6405 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -35,6 +35,7 @@ using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Skinning; using osu.Framework.Graphics.Cursor; using osu.Game.Input.Bindings; +using osu.Game.LAsEzExtensions.Screens; using osu.Game.Utils; namespace osu.Game.Overlays.SkinEditor @@ -84,7 +85,7 @@ namespace osu.Game.Overlays.SkinEditor private Container? content; private EditorSidebar componentsSidebar = null!; - private EditorSidebar settingsSidebar = null!; + private EzEditorSidebar settingsSidebar = null!; private SkinEditorChangeHandler? changeHandler; @@ -228,7 +229,7 @@ namespace osu.Game.Overlays.SkinEditor Depth = float.MaxValue, RelativeSizeAxes = Axes.Both, }, - settingsSidebar = new EditorSidebar(), + settingsSidebar = new EzEditorSidebar(), } } } @@ -537,10 +538,12 @@ namespace osu.Game.Overlays.SkinEditor private void populateSettings() { - settingsSidebar.Clear(); - - foreach (var component in SelectedComponents.OfType()) - settingsSidebar.Add(new SkinSettingsToolbox(component)); + //过滤选择组件 + settingsSidebar.PopulateSettings(content => + { + foreach (var component in SelectedComponents.OfType()) + content.Add(new SkinSettingsToolbox(component)); + }); } private IEnumerable availableTargets => targetScreen.ChildrenOfType(); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 83a5d95bb4..18dc62b7bb 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -31,6 +31,7 @@ using osu.Game.Screens.Play; using osu.Game.Screens.SelectV2; using osu.Game.Users; using osu.Game.Utils; +using osu.Game.LAsEzExtensions.Screens; namespace osu.Game.Overlays.SkinEditor { @@ -46,6 +47,8 @@ namespace osu.Game.Overlays.SkinEditor private SkinEditor? skinEditor; + private EzSkinEditorScreen? ezSkinEditorScreen; + [Resolved] private IPerformFromScreenRunner? performer { get; set; } @@ -99,6 +102,13 @@ namespace osu.Game.Overlays.SkinEditor base.LoadComplete(); externalEditOverlayRegistration = overlayManager?.RegisterBlockingOverlay(externalEditOverlay); + + // EzSkinEditorScreen 是皮肤编辑器内的 overlay,不应 Push 到 ScreenStack。 + // 它作为 SkinEditorOverlay 的子级存在,切换场景或退出皮肤编辑器时会自动隐藏/销毁,不与其他场景叠画。 + AddInternal(ezSkinEditorScreen = new EzSkinEditorScreen + { + Depth = -10 + }); } public bool OnPressed(KeyBindingPressEvent e) @@ -106,6 +116,12 @@ namespace osu.Game.Overlays.SkinEditor switch (e.Action) { case GlobalAction.Back: + if (ezSkinEditorScreen?.State.Value == Visibility.Visible) + { + ToggleEzSkinEditor(); + return true; + } + if (skinEditor?.State.Value != Visibility.Visible) break; @@ -160,9 +176,33 @@ namespace osu.Game.Overlays.SkinEditor nestedInputManagerDisable?.Dispose(); nestedInputManagerDisable = null; + // 离开皮肤编辑器时确保关闭 Ez overlay。 + ezSkinEditorScreen?.Hide(); + restoreSkinEditorRelevantSettings(); } + /// + /// 在皮肤编辑器内切换 EzSkinEditorScreen 的可见性。 + /// + public void ToggleEzSkinEditor() + { + if (ezSkinEditorScreen == null) + return; + + if (ezSkinEditorScreen.State.Value == Visibility.Visible) + { + ezSkinEditorScreen.Hide(); + } + else + { + ezSkinEditorScreen.Show(); + + // Ensure sizing is applied immediately when showing. + Scheduler.AddOnce(updateScreenSizing); + } + } + public void PresentGameplay() => presentGameplay(false); private void presentGameplay(bool attemptedBeatmapSwitch) @@ -239,6 +279,15 @@ namespace osu.Game.Overlays.SkinEditor 1f - relativeToolbarHeight - padding / DrawHeight); scalingContainer.SetCustomRect(rect, true); + + // Keep Ez overlay constrained to the same central preview area. + if (ezSkinEditorScreen != null) + { + ezSkinEditorScreen.RelativePositionAxes = Axes.Both; + ezSkinEditorScreen.RelativeSizeAxes = Axes.Both; + ezSkinEditorScreen.Position = rect.Location; + ezSkinEditorScreen.Size = rect.Size; + } } private void updateComponentVisibility() @@ -274,6 +323,9 @@ namespace osu.Game.Overlays.SkinEditor nestedInputManagerDisable?.Dispose(); nestedInputManagerDisable = null; + // 切换场景时,Ez overlay 应当自动退出,避免与其他场景叠画。 + ezSkinEditorScreen?.Hide(); + lastTargetScreen = screen; if (skinEditor == null) return; diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs index f8d5213622..cd1bf16e10 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs @@ -89,6 +89,13 @@ namespace osu.Game.Overlays.SkinEditor Origin = Anchor.CentreLeft, Action = () => skinEditorOverlay?.PresentGameplay(), }, + new SceneButton + { + Text = "Mania Note Editor(Testing)", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Action = () => skinEditorOverlay?.ToggleEzSkinEditor(), + }, } }, } diff --git a/osu.Game/Properties/AssemblyInfo.cs b/osu.Game/Properties/AssemblyInfo.cs index 75e3ff8fd0..be430a0fe4 100644 --- a/osu.Game/Properties/AssemblyInfo.cs +++ b/osu.Game/Properties/AssemblyInfo.cs @@ -11,7 +11,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("osu.Game.Tests.Dynamic")] [assembly: InternalsVisibleTo("osu.Game.Tests.iOS")] [assembly: InternalsVisibleTo("osu.Game.Tests.Android")] -[assembly: InternalsVisibleTo("osu.Game.Tournament.Tests")] // intended for Moq usage [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 8b8892113b..e3fe077ffe 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -20,6 +20,11 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// protected IReadOnlyList Mods => mods; + /// + /// List of calculated per-object difficulties, populated by Process + /// + protected readonly List ObjectDifficulties = new List(); + private readonly Mod[] mods; protected Skill(Mod[] mods) @@ -37,5 +42,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// public abstract double DifficultyValue(); + + public IReadOnlyList GetObjectDifficulties() => ObjectDifficulties; } } diff --git a/osu.Game/Rulesets/Judgements/JudgementResult.cs b/osu.Game/Rulesets/Judgements/JudgementResult.cs index ab83ee62b0..a3b3af55b3 100644 --- a/osu.Game/Rulesets/Judgements/JudgementResult.cs +++ b/osu.Game/Rulesets/Judgements/JudgementResult.cs @@ -99,6 +99,18 @@ namespace osu.Game.Rulesets.Judgements /// public bool IsHit => Type.IsHit(); + /// + /// Optional override for combo processing. + /// When set, will use this value in place of + /// when updating combo for results where is true. + /// + /// + /// This is intended for hit modes that treat some hit results (e.g. ) as combo breaks + /// while still keeping their original scoring/accuracy semantics. + /// 为了实现非MISS判定可打断combo + /// + public bool? IsComboHit; + /// /// The increase in health resulting from this judgement result. /// diff --git a/osu.Game/Rulesets/Mods/IApplicableAfterConversion.cs b/osu.Game/Rulesets/Mods/IApplicableAfterConversion.cs new file mode 100644 index 0000000000..1b227401d9 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableAfterConversion.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// 提供一个接口,用于在通过 生成 后应用更改的 。 + /// 可以实现 n to m key。 + /// 但需要注意转换过程。建议搭配 一起使用,以确保在正确环节进行转k。 + /// + public interface IApplicableAfterConversion : IApplicableMod + { + /// + /// Applies this to the after conversion has taken place. + /// + /// The converted . + void ApplyToBeatmapAfterConversion(IBeatmap beatmap); + } +} diff --git a/osu.Game/Rulesets/Mods/IHasApplyOrder.cs b/osu.Game/Rulesets/Mods/IHasApplyOrder.cs new file mode 100644 index 0000000000..24f896a37c --- /dev/null +++ b/osu.Game/Rulesets/Mods/IHasApplyOrder.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Mods +{ + /// + /// 提供一个简单的排序接口,用于调整不同Mod的应用顺序。 + /// 主要帮助 在正确环节处理谱面。 + /// 处理顺序从0开始,数字越大,优先级越靠后。 + /// + public interface IHasApplyOrder + { + int ApplyOrder { get; } + } +} diff --git a/osu.Game/Rulesets/Mods/ILoopTimeRangeMod.cs b/osu.Game/Rulesets/Mods/ILoopTimeRangeMod.cs new file mode 100644 index 0000000000..2e156f0dc2 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ILoopTimeRangeMod.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Mods +{ + /// + /// 用于链接循环时间范围的Mod接口。 单位是ms。 + /// + /// 目前主要用于 SummaryTimeline 将时间设置传递到 ManiaModLoopPlayClip。 + /// + public interface ILoopTimeRangeMod + { + /// + /// 更新循环时间范围。 + /// + /// Start time in milliseconds. + /// End time in milliseconds. + void SetLoopTimeRange(double startTime, double endTime); + } +} diff --git a/osu.Game/Rulesets/Mods/IPreviewOverrideProvider.cs b/osu.Game/Rulesets/Mods/IPreviewOverrideProvider.cs new file mode 100644 index 0000000000..29d56443a5 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IPreviewOverrideProvider.cs @@ -0,0 +1,14 @@ +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Select; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// 提供预览覆写参数给选曲界面使用(歌曲预览)。 + /// + public interface IPreviewOverrideProvider + { + PreviewOverrideSettings? GetPreviewOverrides(IWorkingBeatmap beatmap); + } +} + diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 49bdd93bc6..e106deb115 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -23,7 +23,8 @@ namespace osu.Game.Rulesets.Mods sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); } - public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value; + // 供override的速率调整使用 + public virtual double ApplyToRate(double time, double rate) => rate * SpeedChange.Value; public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) }; diff --git a/osu.Game/Rulesets/Mods/ModType.cs b/osu.Game/Rulesets/Mods/ModType.cs index e3c82e42f5..8ddd6b8b9c 100644 --- a/osu.Game/Rulesets/Mods/ModType.cs +++ b/osu.Game/Rulesets/Mods/ModType.cs @@ -10,6 +10,8 @@ namespace osu.Game.Rulesets.Mods Conversion, Automation, Fun, - System + System, + LA_Mod, + YuLiangSSS_Mod, } } diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 46c0371d9f..4204093ad5 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -23,6 +23,16 @@ namespace osu.Game.Rulesets.Scoring [Order(15)] None, + /// + /// mania特殊专用,按键事件的未命中结果。 + /// 禁止用在Judgement覆写上,这不属于note返回的判定结果 + /// TODO: 这是一个错误拼写,为了突出这是一个临时解决方案。未来应当通过更好的方式实现可切换的空判机制,并改为正确的Poor。 + /// + [Description(@"Pool")] + [EnumMember(Value = "pool")] + [Order(17)] + Pool, + /// /// Indicates that the object has been judged as a miss. /// @@ -217,6 +227,10 @@ namespace osu.Game.Rulesets.Scoring case HitResult.ComboBreak: return false; + // 不影响ACC + case HitResult.Pool: + return false; + default: return IsScorable(result) && !IsBonus(result); } @@ -237,6 +251,10 @@ namespace osu.Game.Rulesets.Scoring case HitResult.ComboBreak: return false; + // 有这个才能把Pool添加到计数器控件中 + case HitResult.Pool: + return true; + default: return IsScorable(result) && !IsTick(result) && !IsBonus(result); } @@ -292,6 +310,7 @@ namespace osu.Game.Rulesets.Scoring case HitResult.SmallTickMiss: case HitResult.LargeTickMiss: case HitResult.ComboBreak: + case HitResult.Pool: return true; default: @@ -340,6 +359,9 @@ namespace osu.Game.Rulesets.Scoring case HitResult.SliderTailHit: return true; + case HitResult.Pool: + return false; + default: // Note that IgnoreHit and IgnoreMiss are excluded as they do not affect score. return result >= HitResult.Miss && result < HitResult.IgnoreMiss; @@ -366,6 +388,9 @@ namespace osu.Game.Rulesets.Scoring if (result == minResult || result == maxResult) return true; + if (result == HitResult.Pool) + return true; + Debug.Assert(minResult <= maxResult); return result > minResult && result < maxResult; } @@ -382,6 +407,10 @@ namespace osu.Game.Rulesets.Scoring if (maxResult == HitResult.None || !IsHit(maxResult)) throw new ArgumentOutOfRangeException(nameof(maxResult), $"{maxResult} is not a valid maximum judgement result."); + // Pool is a special result that can be both max and min + if (minResult == HitResult.Pool) + return; + if (minResult == HitResult.None || IsHit(minResult)) throw new ArgumentOutOfRangeException(nameof(minResult), $"{minResult} is not a valid minimum judgement result."); diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index f4d1fe1e14..7a8d166ca2 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -74,6 +74,11 @@ namespace osu.Game.Rulesets.Scoring /// The parameter. public abstract void SetDifficulty(double difficulty); + /// + /// Pool 判定是否启用 + /// + public virtual bool AllowPoolEnabled { get; set; } = false; + /// /// Retrieves the for a time offset. /// @@ -83,6 +88,21 @@ namespace osu.Game.Rulesets.Scoring { timeOffset = Math.Abs(timeOffset); + if (AllowPoolEnabled) + { + if (IsHitResultAllowed(HitResult.Pool)) + { + double miss = WindowFor(HitResult.Miss); + double poolEarlyWindow = miss + 50; + double poolLateWindow = miss + 15; + if ((timeOffset > -poolEarlyWindow && + timeOffset < -miss) || + (timeOffset < poolLateWindow && + timeOffset > miss)) + return HitResult.Pool; + } + } + for (var result = HitResult.Perfect; result >= HitResult.Miss; --result) { if (IsHitResultAllowed(result) && timeOffset <= WindowFor(result)) diff --git a/osu.Game/Rulesets/Scoring/IHitWindows.cs b/osu.Game/Rulesets/Scoring/IHitWindows.cs new file mode 100644 index 0000000000..022a183c65 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/IHitWindows.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Scoring +{ + [Obsolete("过时接口,但在未来可能重新启用。")] + public interface IHitWindows : IApplicableMod + { + bool IsHitResultAllowed(HitResult result); + // double WindowFor(HitResult result); + + // internal DifficultyRange[] GetRanges(); + // public DifficultyRange[] GetRanges() => BaseRanges; + + // public static DifficultyRange[] BaseRanges = + // { + // new DifficultyRange(HitResult.Perfect, 22.4D, 19.4D, 13.9D), + // new DifficultyRange(HitResult.Great, 64, 49, 34), + // new DifficultyRange(HitResult.Good, 97, 82, 67), + // new DifficultyRange(HitResult.Ok, 127, 112, 97), + // new DifficultyRange(HitResult.Meh, 151, 136, 121), + // new DifficultyRange(HitResult.Miss, 188, 173, 158), + // }; + + void SetHitWindows(double window); + + void SetDifficulty(double difficulty); + + void ResetHitWindows(); + } +} diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 3663e7f008..626ca7a7b1 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -6,10 +6,12 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using MessagePack; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -69,6 +71,11 @@ namespace osu.Game.Rulesets.Scoring /// public readonly BindableDouble Accuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + /// + /// The current accuracy in legacy (Classic) mode. + /// + public readonly BindableDouble AccuracyClassic = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + /// /// The minimum achievable accuracy for the whole beatmap at this stage of gameplay. /// Assumes that all objects that have not been judged yet will receive the minimum hit result. @@ -198,6 +205,22 @@ namespace osu.Game.Rulesets.Scoring public bool ApplyNewJudgementsWhenFailed { get; set; } + public double ClassicBaseScore { get; protected set; } + public double ClassicMaxBaseScore { get; protected set; } + + // 标记后,用于分数算法切换。 + public bool IsLegacyScore = false; + private static double accS; + + private static double accA; + + [BackgroundDependencyLoader] + private void load(Ez2ConfigManager ezConfig) + { + accS = ezConfig.Get(Ez2Setting.AccuracyCutoffS); + accA = ezConfig.Get(Ez2Setting.AccuracyCutoffA); + } + public ScoreProcessor(Ruleset ruleset) { Ruleset = ruleset; @@ -232,10 +255,15 @@ namespace osu.Game.Rulesets.Scoring ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1; - if (result.Type.IncreasesCombo()) - Combo.Value++; - else if (result.Type.BreaksCombo()) - Combo.Value = 0; + if (result.Type.AffectsCombo()) + { + bool isComboHit = result.IsComboHit ?? result.Type.IsHit(); + + if (result.Type.IncreasesCombo() || isComboHit) + Combo.Value++; + else if (result.Type.BreaksCombo() || !isComboHit) + Combo.Value = 0; + } HighestCombo.Value = Math.Max(HighestCombo.Value, Combo.Value); @@ -374,8 +402,14 @@ namespace osu.Game.Rulesets.Scoring { } + public void UpdateScoreClassic() + { + updateScore(); + } + private void updateScore() { + AccuracyClassic.Value = ClassicMaxBaseScore > 0 ? ClassicBaseScore / ClassicMaxBaseScore : 1; Accuracy.Value = currentMaximumBaseScore > 0 ? currentBaseScore / currentMaximumBaseScore : 1; MinimumAccuracy.Value = maximumBaseScore > 0 ? currentBaseScore / maximumBaseScore : 0; MaximumAccuracy.Value = maximumBaseScore > 0 ? (currentBaseScore + (maximumBaseScore - currentMaximumBaseScore)) / maximumBaseScore : 1; @@ -438,6 +472,10 @@ namespace osu.Game.Rulesets.Scoring ScoreResultCounts.Clear(); + ClassicBaseScore = 0; + ClassicMaxBaseScore = 0; + AccuracyClassic.Value = 1; + currentBaseScore = 0; currentMaximumBaseScore = 0; currentAccuracyJudgementCount = 0; @@ -537,9 +575,9 @@ namespace osu.Game.Rulesets.Scoring { if (accuracy == accuracy_cutoff_x) return ScoreRank.X; - if (accuracy >= accuracy_cutoff_s) + if (accuracy >= accS) return ScoreRank.S; - if (accuracy >= accuracy_cutoff_a) + if (accuracy >= accA) return ScoreRank.A; if (accuracy >= accuracy_cutoff_b) return ScoreRank.B; diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs index 177520f28f..1738b508ab 100644 --- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -73,6 +73,17 @@ namespace osu.Game.Rulesets.UI protected virtual void PlaySamples(ISampleInfo[] samples) => Schedule(() => { + var existing = hitSounds.FirstOrDefault(h => h.IsPlaying && h.Samples != null && h.Samples.SequenceEqual(samples)); + + if (existing != null) + { + // 如果相同的音效正在播放,打断并重放 + existing.Stop(); + ApplySampleInfo(existing, samples); + existing.Play(); + return; + } + var hitSound = GetNextSample(); ApplySampleInfo(hitSound, samples); hitSound.Play(); diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index 53f0b39ef7..59c43ed9db 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -21,6 +21,12 @@ namespace osu.Game.Screens public bool AnimateEntry { get; set; } = true; + /// + /// 是否应该在这个背景屏上禁用视差效果。 + /// 禁用后,背景将保持静态,不会响应鼠标移动。 + /// + public bool DisableParallax { get; set; } + protected BackgroundScreen() { Anchor = Anchor.Centre; @@ -47,7 +53,9 @@ namespace osu.Game.Screens protected override void Update() { base.Update(); - Scale = new Vector2(1 + x_movement_amount / DrawSize.X * 2); + + if (!DisableParallax) + Scale = new Vector2(1 + x_movement_amount / DrawSize.X * 2); } public override void OnEntering(ScreenTransitionEvent e) @@ -74,7 +82,7 @@ namespace osu.Game.Screens if (IsLoaded) { this.FadeOut(TRANSITION_LENGTH, Easing.OutExpo); - this.MoveToX(x_movement_amount, TRANSITION_LENGTH, Easing.OutExpo); + this.MoveToX(x_movement_amount, TRANSITION_LENGTH, Easing.OutExpo); } return base.OnExiting(e); diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 82bfd23801..26a910e102 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -4,10 +4,13 @@ #nullable disable using System.Diagnostics; +using System.IO; +using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; @@ -16,6 +19,8 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Backgrounds; +using osu.Game.LAsEzExtensions.Background; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Skinning; @@ -24,6 +29,8 @@ namespace osu.Game.Screens.Backgrounds { public partial class BackgroundScreenDefault : BackgroundScreen { + private bool storageTextureSourceAdded; + private Background background; private int currentDisplay; @@ -43,7 +50,7 @@ namespace osu.Game.Screens.Backgrounds protected virtual bool AllowStoryboardBackground => true; [BackgroundDependencyLoader] - private void load(IAPIProvider api, SkinManager skinManager, OsuConfigManager config) + private void load(IAPIProvider api, SkinManager skinManager, OsuConfigManager config, Ez2ConfigManager ezSkinConfig) { user = api.LocalUser.GetBoundCopy(); skin = skinManager.CurrentSkin.GetBoundCopy(); @@ -51,6 +58,8 @@ namespace osu.Game.Screens.Backgrounds introSequence = config.GetBindable(OsuSetting.IntroSequence); AddInternal(seasonalBackgroundLoader); + GlobalConfigStore.Config = config; + GlobalConfigStore.EzConfig = ezSkinConfig; } protected override void LoadComplete() @@ -138,12 +147,129 @@ namespace osu.Game.Screens.Backgrounds currentDisplay++; } + [Resolved] + private Storage storage { get; set; } = null!; + + [Resolved] + private TextureStore textures { get; set; } = null!; + + [Resolved] + private LargeTextureStore largeTextures { get; set; } = null!; + + // Try to pick a random file from storage under a relative path. + // Returns storage-relative resourcePath (for textures) and fullPath (absolute on disk) for file-based consumers like Video. + private bool tryGetRandomStorageFile(string relativePath, out string resourcePath, out string fullPath, string[] extensions = null) + { + resourcePath = null; + fullPath = null; + + try + { + string[] files = storage.GetFiles(relativePath, "*").ToArray(); + + if (extensions != null && extensions.Length > 0) + files = files.Where(f => extensions.Any(ext => f.EndsWith(ext, System.StringComparison.OrdinalIgnoreCase))).ToArray(); + + // ensure directory exists on disk so users can drop files + string dataFolderPath = storage.GetFullPath(relativePath); + + if (files.Length == 0) + { + if (dataFolderPath != null && !Directory.Exists(dataFolderPath)) + { + Directory.CreateDirectory(dataFolderPath); + Logger.Log(EzLocalizationManager.StorageFolder_Created.Format(dataFolderPath), LoggingTarget.Information, LogLevel.Important); + } + + // directory exists but no files + if (dataFolderPath != null) + Logger.Log(EzLocalizationManager.StorageFolder_Empty.Format(dataFolderPath), LoggingTarget.Information, LogLevel.Important); + + return false; + } + + // ensure textures can resolve storage-backed resources (only add once) + if (!storageTextureSourceAdded) + { + try + { + var loader = gameHost.CreateTextureLoaderStore(new osu.Framework.IO.Stores.StorageBackedResourceStore(storage)); + textures.AddTextureSource(loader); + + largeTextures?.AddTextureSource(loader); + + storageTextureSourceAdded = true; + } + catch + { + // ignore failures; callers will fall back + } + } + + string file = files[RNG.Next(files.Length)]; + + // Normalize separators for comparison. + string normalizedFile = file.Replace('\\', '/'); + string normalizedRelative = relativePath.Replace('\\', '/').TrimEnd('/'); + + if (normalizedFile.StartsWith(normalizedRelative + "/", System.StringComparison.OrdinalIgnoreCase) + || normalizedFile.Equals(normalizedRelative, System.StringComparison.OrdinalIgnoreCase)) + { + resourcePath = normalizedFile; + } + else if (Path.IsPathRooted(file)) + { + resourcePath = normalizedFile; + } + else + { + resourcePath = (normalizedRelative + "/" + normalizedFile.TrimStart('/')); + } + + // fullPath: prefer absolute if file contains full path, else resolve via storage + if (Path.IsPathRooted(file)) + fullPath = file; + else + fullPath = storage.GetFullPath(file); + + return true; + } + catch + { + return false; + } + } + private Background createBackground() { + switch (source.Value) + { + case BackgroundSource.WebmSource: + { + string relativePath = Path.Combine("EzResources", "Webm"); + + if (tryGetRandomStorageFile(relativePath, out string resourcePath, out string fullPath, new[] { ".webm", ".mp4", ".flv", ".mkv" })) + return new VideoBackgroundScreen(fullPath ?? resourcePath); + + Stream videoName = textures.GetStream("EzResources/default_video.webm"); + return new StreamVideoBackgroundScreen(videoName); + } + + case BackgroundSource.Slides: + { + string relativePath = Path.Combine("EzResources", "BG"); + + if (tryGetRandomStorageFile(relativePath, out string resourcePath, out string _, new[] { ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff" })) + return new Background(resourcePath); + + return new Background($@"Menu/Ez-background-{currentDisplay % 6 + 1}"); + } + } + // seasonal background loading gets highest priority. Background newBackground = seasonalBackgroundLoader.LoadNextBackground(); - if (newBackground == null && user.Value?.IsSupporter == true) + if (newBackground == null) { switch (source.Value) { diff --git a/osu.Game/Screens/Edit/BottomBar.cs b/osu.Game/Screens/Edit/BottomBar.cs index 49f3d704bc..364e7c6314 100644 --- a/osu.Game/Screens/Edit/BottomBar.cs +++ b/osu.Game/Screens/Edit/BottomBar.cs @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Edit { new Dimension(GridSizeMode.Absolute, 150), new Dimension(), - new Dimension(GridSizeMode.Absolute, 220), + new Dimension(GridSizeMode.Absolute, 330), new Dimension(GridSizeMode.Absolute, HitObjectComposer.TOOLBOX_CONTRACTED_SIZE_RIGHT), }, Content = new[] diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index 01d777cdc6..162b1c619f 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osuTK; using osuTK.Graphics; @@ -19,6 +20,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Localisation; using osu.Game.Overlays; using osuTK.Input; @@ -27,6 +29,10 @@ namespace osu.Game.Screens.Edit.Components { public partial class PlaybackControl : BottomBarContainer { + private LoopPointButton setAButton = null!; + private LoopPointButton setBButton = null!; + private IconButton loopButton = null!; + private IconButton playButton = null!; private PlaybackSpeedControl playbackSpeedControl = null!; @@ -35,6 +41,7 @@ namespace osu.Game.Screens.Edit.Components private readonly Bindable currentScreenMode = new Bindable(); private readonly BindableNumber tempoAdjustment = new BindableDouble(1); + private readonly BindableBool loopEnabled = new BindableBool(); [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, Editor? editor) @@ -43,19 +50,53 @@ namespace osu.Game.Screens.Edit.Components Children = new Drawable[] { - playButton = new IconButton + new FillFlowContainer { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Scale = new Vector2(1.2f), - IconScale = new Vector2(1.2f), - Icon = FontAwesome.Regular.PlayCircle, - Action = togglePause, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + setAButton = new LoopPointButton("A") + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(1.2f), + Action = setLoopStartToCurrentTime, + }, + setBButton = new LoopPointButton("B") + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(1.2f), + Action = setLoopEndToCurrentTime, + }, + loopButton = new IconButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(1.2f), + IconScale = new Vector2(1.2f), + Icon = FontAwesome.Solid.SyncAlt, + Action = toggleLoop, + }, + playButton = new IconButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(1.2f), + IconScale = new Vector2(1.2f), + Icon = FontAwesome.Regular.PlayCircle, + Action = togglePause, + }, + }, }, playbackSpeedControl = new PlaybackSpeedControl { AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, + Width = 180, Padding = new MarginPadding { Left = 45, }, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -80,6 +121,8 @@ namespace osu.Game.Screens.Edit.Components if (editor != null) currentScreenMode.BindTo(editor.Mode); + + loopEnabled.BindTo(editorClock.LoopEnabled); } protected override void LoadComplete() @@ -135,14 +178,93 @@ namespace osu.Game.Screens.Edit.Components editorClock.Start(); } + private void toggleLoop() + { + loopEnabled.Value = !loopEnabled.Value; + + if (loopEnabled.Value) + { + // Prefer persisted session A/B range (ms). Only fall back to defaults when not available. + if (LoopTimeRangeStore.TryGet(out double startMs, out double endMs)) + { + editorClock.SetLoopStartTime(editorClock.GetSnappedTime(startMs)); + editorClock.SetLoopEndTime(editorClock.GetSnappedTime(endMs)); + } + else + { + // 默认范围:以当前活动光标为 A 起点,向后 设置 B 终点。 + double currentTime = Math.Clamp(editorClock.CurrentTime, 0, editorClock.TrackLength); + + double startTime = editorClock.GetSnappedTime(currentTime); + var timingPoint = editorClock.ControlPointInfo.TimingPointAt(startTime); + + // 8 * (4/4 beat) = 8 beats.也就是8根白线。 + double endTime = startTime + timingPoint.BeatLength * 8; + endTime = Math.Min(endTime, editorClock.TrackLength); + endTime = editorClock.GetSnappedTime(endTime); + + if (endTime <= startTime) + endTime = Math.Min(editorClock.TrackLength, startTime + 1); + + editorClock.SetLoopStartTime(startTime); + editorClock.SetLoopEndTime(endTime); + } + + editorClock.Seek(editorClock.LoopStartTime.Value); // 跳转到开头 + } + } + + private void setLoopStartToCurrentTime() + { + double currentTime = Math.Clamp(editorClock.CurrentTime, 0, editorClock.TrackLength); + editorClock.SetLoopStartTime(editorClock.GetSnappedTime(currentTime)); + persistLoopRangeIfValid(); + } + + private void setLoopEndToCurrentTime() + { + double currentTime = Math.Clamp(editorClock.CurrentTime, 0, editorClock.TrackLength); + editorClock.SetLoopEndTime(editorClock.GetSnappedTime(currentTime)); + persistLoopRangeIfValid(); + } + + private void persistLoopRangeIfValid() + { + double start = editorClock.LoopStartTime.Value; + double end = editorClock.LoopEndTime.Value; + + if (end > start) + LoopTimeRangeStore.Set(start, end); + } + private static readonly IconUsage play_icon = FontAwesome.Regular.PlayCircle; private static readonly IconUsage pause_icon = FontAwesome.Regular.PauseCircle; + private static readonly IconUsage loop_on_icon = FontAwesome.Solid.Redo; + private static readonly IconUsage loop_off_icon = FontAwesome.Regular.Circle; protected override void Update() { base.Update(); playButton.Icon = editorClock.IsRunning ? pause_icon : play_icon; + loopButton.Icon = loopEnabled.Value ? loop_on_icon : loop_off_icon; + } + + private partial class LoopPointButton : OsuAnimatedButton + { + public LoopPointButton(string label) + : base(HoverSampleSet.Button) + { + Size = new Vector2(IconButton.DEFAULT_BUTTON_SIZE); + + Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = label, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + }); + } } private partial class PlaybackSpeedControl : FillFlowContainer, IHasTooltip diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index 1fedd6f589..06f61e0720 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -36,6 +36,16 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts private double? lastSeekTime; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (!base.ReceivePositionalInputAt(screenSpacePos)) + return false; + + // 改为只在上半部分响应交互,为了配合A-B Loop光标仅下半区的UI交互 + var localPos = ToLocalSpace(screenSpacePos); + return localPos.Y < DrawHeight / 2; + } + protected override bool OnDragStart(DragStartEvent e) => true; protected override void OnDrag(DragEvent e) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index 568137cce1..9ad95d8d1b 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -1,12 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Screens.Edit; using osu.Game.Overlays; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; +using osu.Game.Utils; using osuTK; namespace osu.Game.Screens.Edit.Components.Timelines.Summary @@ -16,6 +25,19 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary /// public partial class SummaryTimeline : BottomBarContainer { + private LoopIntervalDisplay loopInterval = null!; + + [Resolved] + private EditorClock editorClock { get; set; } = null!; + + [Resolved(canBeNull: true)] + private IBindable>? mods { get; set; } + + private ScheduledDelegate? pendingSync; + + private LoopMarker loopStartMarker = null!; + private LoopMarker loopEndMarker = null!; + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { @@ -58,6 +80,23 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, }, + loopInterval = new LoopIntervalDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Alpha = 0, + }, + loopStartMarker = new LoopMarker(true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + loopEndMarker = new LoopMarker(false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, new KiaiPart { Anchor = Anchor.Centre, @@ -87,5 +126,110 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary new MarkerPart { RelativeSizeAxes = Axes.Both }, }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + float getTimelineWidth() => Content.ChildSize.X; + + float clampX(float x) + { + float w = getTimelineWidth(); + return Math.Clamp(x, -w / 2, w / 2); + } + + float xAtTime(double time) + { + float w = getTimelineWidth(); + return (float)(time / editorClock.TrackLength * w - w / 2); + } + + double timeAtX(float x) + { + float w = getTimelineWidth(); + x = clampX(x); + return (x + w / 2) / w * editorClock.TrackLength; + } + + loopStartMarker.ClampX = clampX; + loopStartMarker.XAtTime = xAtTime; + loopStartMarker.SnapTime = editorClock.GetSnappedTime; + loopStartMarker.TimeAtX = timeAtX; + + loopEndMarker.ClampX = clampX; + loopEndMarker.XAtTime = xAtTime; + loopEndMarker.SnapTime = editorClock.GetSnappedTime; + loopEndMarker.TimeAtX = timeAtX; + + loopStartMarker.TimeChanged += time => editorClock.SetLoopStartTime(time); + loopEndMarker.TimeChanged += time => editorClock.SetLoopEndTime(time); + + editorClock.LoopStartTime.BindValueChanged(_ => updateLoopInterval()); + editorClock.LoopEndTime.BindValueChanged(_ => updateLoopInterval()); + editorClock.LoopStartTime.BindValueChanged(_ => scheduleSyncToMods()); + editorClock.LoopEndTime.BindValueChanged(_ => scheduleSyncToMods()); + editorClock.LoopEnabled.BindValueChanged(_ => scheduleSyncToMods()); + editorClock.LoopEnabled.BindValueChanged(enabled => + { + loopInterval.FadeTo(enabled.NewValue ? 1 : 0, 200, Easing.OutQuint); + // loopStartMarker.FadeTo(enabled.NewValue ? 1 : 0, 200, Easing.OutQuint); + // loopEndMarker.FadeTo(enabled.NewValue ? 1 : 0, 200, Easing.OutQuint); + }); + } + + protected override void Update() + { + base.Update(); + + float timelineWidth = Content.ChildSize.X; + + if (!loopStartMarker.IsDragged) + loopStartMarker.X = (float)(editorClock.LoopStartTime.Value / editorClock.TrackLength * timelineWidth - timelineWidth / 2); + if (!loopEndMarker.IsDragged) + loopEndMarker.X = (float)(editorClock.LoopEndTime.Value / editorClock.TrackLength * timelineWidth - timelineWidth / 2); + } + + private void updateLoopInterval() + { + float timelineWidth = Content.ChildSize.X; + float startX = (float)(editorClock.LoopStartTime.Value / editorClock.TrackLength * timelineWidth - timelineWidth / 2); + float endX = (float)(editorClock.LoopEndTime.Value / editorClock.TrackLength * timelineWidth - timelineWidth / 2); + loopInterval.UpdateInterval(startX, endX); + } + + private void scheduleSyncToMods() + { + pendingSync?.Cancel(); + pendingSync = Scheduler.AddDelayed(syncLoopRangeToMods, 200); + } + + private void syncLoopRangeToMods() + { + double start = editorClock.LoopStartTime.Value; + double end = editorClock.LoopEndTime.Value; + + if (end <= start) + return; + + // Always update the global session store, regardless of loop enabled state. + LoopTimeRangeStore.Set(start, end); + + if (mods == null) + return; + + bool applied = false; + + foreach (var rangeMod in ModUtils.FlattenMods(mods.Value).OfType()) + { + rangeMod.SetLoopTimeRange(start, end); + applied = true; + } + + // 如果用户在不重启的情况下返回歌曲选择,Mod覆盖可能已经存在。 + // 它只对 SelectedMods 绑定值的更改做出反应,因此强制值更新以传播更改的设置。 + if (applied && mods is Bindable> writableMods) + writableMods.Value = writableMods.Value.ToArray(); + } } } diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index d82ecbeff6..5df899d158 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -53,12 +53,21 @@ namespace osu.Game.Screens.Edit /// public bool IsSeeking { get; private set; } + public IBindable LoopStartTime => loopStartTime; + public IBindable LoopEndTime => loopEndTime; + public BindableBool LoopEnabled { get; } = new BindableBool(); + + private readonly Bindable loopStartTime = new Bindable(); + private readonly Bindable loopEndTime = new Bindable(); + public EditorClock(IBeatmap beatmap = null, BindableBeatDivisor beatDivisor = null) { Beatmap = beatmap ?? new Beatmap(); this.beatDivisor = beatDivisor ?? new BindableBeatDivisor(); + // 编辑器使用谱面原始时间。若在此应用偏移会破坏寻迹/循环逻辑 + // (FramedBeatmapClock 在原始时间上进行寻迹,但报告的 CurrentTime 却应用了偏移)。 underlyingClock = new FramedBeatmapClock(applyOffsets: true, requireDecoupling: true); AddInternal(underlyingClock); @@ -88,7 +97,18 @@ namespace osu.Game.Screens.Edit if (position > nextTimingPoint?.Time) position = nextTimingPoint.Time; - return Seek(position); + return Seek(GetSnappedTime(position)); + } + + /// + /// 这个方法返回根据当前编辑器节拍除数配置进行捕捉的。 + /// 对其到显示的编辑器网格粒度。 + /// TODO: 尽管如此,视觉上也依然难以发觉捕捉效果。最好在编辑器顶部的时间轴上同步显示光标位置。 + /// + public double GetSnappedTime(double time) + { + double snapped = ControlPointInfo.GetClosestSnappedTime(time, beatDivisor.Value); + return Math.Clamp(snapped, 0, TrackLength); } /// @@ -292,6 +312,12 @@ namespace osu.Game.Screens.Edit underlyingClock.Seek(TrackLength); } + // Handle A-B loop + if (LoopEnabled.Value && IsRunning && CurrentTime >= loopEndTime.Value) + { + underlyingClock.Seek(loopStartTime.Value); + } + updateSeekingState(); } @@ -340,5 +366,9 @@ namespace osu.Game.Screens.Edit protected override void ReadIntoStartValue(EditorClock clock) => StartValue = clock.currentTime; } + + public void SetLoopStartTime(double time) => loopStartTime.Value = time; + + public void SetLoopEndTime(double time) => loopEndTime.Value = time; } } diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index f8824795d8..235babeed2 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -20,6 +20,7 @@ using osu.Game.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; using osu.Framework.Localisation; using osu.Game.Beatmaps.ControlPoints; @@ -257,6 +258,15 @@ namespace osu.Game.Screens.Menu protected override void OnMouseUp(MouseUpEvent e) { + // HORRIBLE HACK + // This is here so that on mobile, the main menu button that progresses to song select can correctly progress to song select v2 when held. + // Once the temporary solution of holding the button to access song select v2 is removed, this should be too. + // Without this, the long-press-to-right-click flow intercepts the hold and converts it to a right click which would not trigger the button + // and therefore not progress to song select. + if (e.Button == MouseButton.Right && e.CurrentState.Mouse.LastSource is ISourcedFromTouch) + trigger(e); + // END OF HORRIBLE HACK + boxHoverLayer.FadeTo(0, 1000, Easing.OutQuint); base.OnMouseUp(e); } diff --git a/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplay.cs b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplay.cs new file mode 100644 index 0000000000..43a8297a27 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplay.cs @@ -0,0 +1,203 @@ + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD.EzHealthDisplay +{ + public partial class EzHealthDisplay : EzHealthDisplayBase, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + [SettingSource("Bar height")] + public BindableFloat BarHeight { get; } = new BindableFloat(20) + { + MinValue = 0, + MaxValue = 64, + Precision = 1 + }; + + [SettingSource("Use relative size")] + public BindableBool UseRelativeSize { get; } = new BindableBool(true); + + private EzHealthDisplayBar mainBar = null!; + private EzHealthDisplayBar glowBar = null!; + + private ScheduledDelegate? resetMissBarDelegate; + + private bool displayingMiss => resetMissBarDelegate != null; + + private double glowBarValue; + private double healthBarValue; + + public const float MAIN_PATH_RADIUS = 10f; + private const float padding = MAIN_PATH_RADIUS * 2; + private const float glow_path_radius = 40f; + + protected override string[] TextureSuffixes => new[] { "background", "mainbar", "glowbar" }; + + public EzHealthDisplay() + { + Width = 0.98f; + } + + protected override void LoadTextures() + { + base.LoadTextures(); + + Content.Children = new Drawable[] + { + new EzHealthDisplayBackground(TextureFactory, TexturePrefix + "background") + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(MAIN_PATH_RADIUS - glow_path_radius), + Child = glowBar = new EzHealthDisplayBar(TextureFactory, TexturePrefix + "glowbar") + { + RelativeSizeAxes = Axes.Both, + PathRadius = glow_path_radius, + } + }, + mainBar = new EzHealthDisplayBar(TextureFactory, TexturePrefix + "mainbar") + { + RelativeSizeAxes = Axes.Both, + PathRadius = MAIN_PATH_RADIUS, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + HealthProcessor.NewJudgement += onNewJudgement; + + float previousWidth = Width; + UseRelativeSize.BindValueChanged(v => RelativeSizeAxes = v.NewValue ? Axes.X : Axes.None, true); + Width = previousWidth; + + BarHeight.BindValueChanged(_ => updateContentSize(), true); + } + + private void onNewJudgement(JudgementResult result) + { + pendingMissAnimation |= !result.IsHit && result.HealthIncrease < 0; + } + + private bool pendingMissAnimation; + + protected override void Update() + { + base.Update(); + + healthBarValue = Interpolation.DampContinuously(healthBarValue, Current.Value, 50, Time.Elapsed); + if (!displayingMiss) + glowBarValue = Interpolation.DampContinuously(glowBarValue, Current.Value, 50, Time.Elapsed); + + mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); + glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, glowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + + updatePathProgress(); + updateContentSize(); + } + + protected override void HealthChanged(bool increase) + { + if (Current.Value >= glowBarValue) + finishMissDisplay(); + + if (pendingMissAnimation) + { + triggerMissDisplay(); + pendingMissAnimation = false; + } + + base.HealthChanged(increase); + } + + protected override void FinishInitialAnimation(double value) + { + base.FinishInitialAnimation(value); + this.TransformTo(nameof(healthBarValue), value, 500, Easing.OutQuint); + this.TransformTo(nameof(glowBarValue), value, 250, Easing.OutQuint); + } + + protected override void Flash() + { + base.Flash(); + + if (!displayingMiss) + { + glowBar.Flash(); + } + } + + private void triggerMissDisplay() + { + resetMissBarDelegate?.Cancel(); + resetMissBarDelegate = null; + + this.Delay(500).Schedule(() => + { + this.TransformTo(nameof(glowBarValue), Current.Value, 300, Easing.OutQuint); + finishMissDisplay(); + }, out resetMissBarDelegate); + + glowBar.SetDamageColour(); + } + + private void finishMissDisplay() + { + if (!displayingMiss) + return; + + if (Current.Value > 0) + { + glowBar.ResetColour(); + } + + resetMissBarDelegate?.Cancel(); + resetMissBarDelegate = null; + } + + private void updateContentSize() + { + float usableWidth = DrawWidth - padding; + + if (usableWidth < 0) enforceMinimumWidth(); + + Content.Size = new Vector2(DrawWidth, BarHeight.Value + padding); + + void enforceMinimumWidth() + { + Axes relativeAxes = RelativeSizeAxes; + RelativeSizeAxes = Axes.None; + Width = padding; + RelativeSizeAxes = relativeAxes; + } + } + + private void updatePathProgress() + { + mainBar.ProgressRange = new Vector2(0f, (float)healthBarValue); + glowBar.ProgressRange = new Vector2((float)healthBarValue, (float)Math.Max(glowBarValue, healthBarValue)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + HealthProcessor.NewJudgement -= onNewJudgement; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBackground.cs b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBackground.cs new file mode 100644 index 0000000000..adec51182d --- /dev/null +++ b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBackground.cs @@ -0,0 +1,16 @@ + +using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions; + +namespace osu.Game.Screens.Play.HUD.EzHealthDisplay +{ + public partial class EzHealthDisplayBackground : Container + { + public EzHealthDisplayBackground(EzLocalTextureFactory textureFactory, string textureName) + { + var textureAnimation = textureFactory.CreateAnimation(textureName); + + Add(textureAnimation); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBar.cs b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBar.cs new file mode 100644 index 0000000000..0654046bda --- /dev/null +++ b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBar.cs @@ -0,0 +1,78 @@ + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osuTK.Graphics; +using osu.Game.LAsEzExtensions; + +namespace osu.Game.Screens.Play.HUD.EzHealthDisplay +{ + public partial class EzHealthDisplayBar : Container + { + private Vector2 progressRange = new Vector2(0f, 1f); + + public Vector2 ProgressRange + { + get => progressRange; + set + { + if (progressRange == value) + return; + + progressRange = value; + updateMasking(); + } + } + + private float pathRadius = 10f; + + public float PathRadius + { + get => pathRadius; + set + { + if (pathRadius == value) + return; + + pathRadius = value; + updateMasking(); + } + } + + public EzHealthDisplayBar(EzLocalTextureFactory textureFactory, string textureName) + { + var textureAnimation1 = textureFactory.CreateAnimation(textureName); + + Add(textureAnimation1); + + Masking = true; + } + + private void updateMasking() + { + // Implement masking based on progressRange and pathRadius + // For simplicity, use a simple rectangle mask for now + // In a real implementation, you might need custom shaders or more complex masking + float progress = progressRange.Y - progressRange.X; + Width = progress * DrawWidth; + } + + public void Flash() + { + // Implement flash effect, e.g., change colour or alpha temporarily + this.FadeTo(0.5f, 30).Then().FadeTo(1f, 300); + } + + public void SetDamageColour() + { + // Set to red for damage + Colour = Color4.Red; + } + + public void ResetColour() + { + // Reset to default + Colour = Color4.White; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBase.cs b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBase.cs new file mode 100644 index 0000000000..f5212cfcfa --- /dev/null +++ b/osu.Game/Screens/Play/HUD/EzHealthDisplay/EzHealthDisplayBase.cs @@ -0,0 +1,49 @@ +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.LAsEzExtensions; + +namespace osu.Game.Screens.Play.HUD.EzHealthDisplay +{ + public abstract partial class EzHealthDisplayBase : HealthDisplay + { + protected EzLocalTextureFactory TextureFactory { get; private set; } = null!; + + protected string TexturePrefix { get; set; } = "health/"; + + protected Container Content { get; private set; } = null!; + + protected abstract string[] TextureSuffixes { get; } + + [BackgroundDependencyLoader] + private void load(EzLocalTextureFactory textureFactory) + { + TextureFactory = textureFactory; + + AutoSizeAxes = Axes.Y; + + InternalChild = Content = new Container + { + RelativeSizeAxes = Axes.Both, + }; + + LoadTextures(); + } + + protected virtual void LoadTextures() + { + foreach (string suffix in TextureSuffixes) + { + var texture = TextureFactory.CreateAnimation(TexturePrefix + suffix); + + Content.Add(texture); + } + } + + protected override void Update() + { + base.Update(); + // Implement health update logic here + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index e27a7544c9..51c9de1539 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -35,6 +35,14 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Precision = 0.1f, }; + [SettingSource("Icon Fade Out Duration", "Icon Fade Out Duration")] + public BindableNumber JudgementFadeOutDuration { get; } = new BindableNumber(1200) + { + MinValue = 100, + MaxValue = 5000, + Precision = 100, + }; + [SettingSource(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.ColourBarVisibility))] public Bindable ColourBarVisibility { get; } = new Bindable(true); @@ -470,7 +478,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters base.PrepareForUse(); const int judgement_fade_in_duration = 100; - const int judgement_fade_out_duration = 5000; + // const int judgement_fade_out_duration = 1200; Alpha = 0; Width = 0; @@ -479,8 +487,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters .FadeTo(0.6f, judgement_fade_in_duration, Easing.OutQuint) .ResizeWidthTo(1, judgement_fade_in_duration, Easing.OutQuint) .Then() - .FadeOut(judgement_fade_out_duration) - .ResizeWidthTo(0, judgement_fade_out_duration, Easing.InQuint) + .FadeOut(barHitErrorMeter.JudgementFadeOutDuration.Value) + .ResizeWidthTo(0, barHitErrorMeter.JudgementFadeOutDuration.Value, Easing.InQuint) .Expire(); } } diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 2772e7514c..01314c5de6 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -21,7 +21,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; -using osu.Game.Localisation; using osuTK; using osuTK.Graphics; @@ -94,8 +93,8 @@ namespace osu.Game.Screens.Play.HUD button.HoldActivationDelay.BindValueChanged(v => { text.Text = v.NewValue > 0 - ? UserInterfaceStrings.HoldForMenu - : UserInterfaceStrings.PressForMenu; + ? "hold for menu" + : "press for menu"; }, true); touchActive = sessionStatics.GetBindable(Static.TouchInputActive); diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index ba543db996..a2fc974cee 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -36,6 +36,7 @@ using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Screens.Ranking; using osu.Game.Skinning; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Users; using osu.Game.Utils; using osuTK.Graphics; @@ -102,7 +103,8 @@ namespace osu.Game.Screens.Play private bool isRestarting; private bool skipExitTransition; - private readonly Bindable storyboardReplacesBackground = new Bindable(); + // 公开以供外部检查当前状态。 + public readonly Bindable StoryboardReplacesBackground = new Bindable(); public IBindable LocalUserPlaying => localUserPlaying; @@ -263,6 +265,20 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(ScoreProcessor); + // Initialize InputAudioLatencyTracker if available in DI + try + { + var ezConfig = dependencies.Get(); + var tracker = new osu.Game.LAsEzExtensions.Audio.InputAudioLatencyTracker(ezConfig); + dependencies.CacheAs(tracker); + tracker.Initialize(ScoreProcessor); + tracker.Start(); + } + catch (Exception) + { + // Ez2ConfigManager not available or tracker initialization failed — skip silently. + } + HealthProcessor = gameplayMods.OfType().FirstOrDefault()?.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor ??= ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor.ApplyBeatmap(playableBeatmap); @@ -1126,7 +1142,7 @@ namespace osu.Game.Screens.Play // bind component bindables. ((IBindable)b.IsBreakTime).BindTo(breakTracker.IsBreakTime); - b.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); + b.StoryboardReplacesBackground.BindTo(StoryboardReplacesBackground); failAnimationContainer.Background = b; }); @@ -1136,7 +1152,7 @@ namespace osu.Game.Screens.Play DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); - storyboardReplacesBackground.Value = GameplayState.Storyboard.ReplacesBackground && GameplayState.Storyboard.HasDrawable; + StoryboardReplacesBackground.Value = GameplayState.Storyboard.ReplacesBackground && GameplayState.Storyboard.HasDrawable; foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToPlayer(this); @@ -1300,7 +1316,7 @@ namespace osu.Game.Screens.Play } }); - storyboardReplacesBackground.Value = false; + StoryboardReplacesBackground.Value = false; } } diff --git a/osu.Game/Screens/Play/Player_ManiaBackgroundScreen.cs b/osu.Game/Screens/Play/Player_ManiaBackgroundScreen.cs new file mode 100644 index 0000000000..e004635ad9 --- /dev/null +++ b/osu.Game/Screens/Play/Player_ManiaBackgroundScreen.cs @@ -0,0 +1,180 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.Backgrounds; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.Screens.Backgrounds; +using osuTK; + +namespace osu.Game.Screens.Play +{ + public partial class Player + { + /// + /// 已过时的 Mania 专用背景屏幕。新的实现在mania规则集中,Stage实现。 + /// + public partial class PlayerManiaBackgroundScreen : BackgroundScreenBeatmap + { + private Bindable maniaColumnBlur = new Bindable(); + private Bindable maniaColumnWidth = new Bindable(); + private Bindable maniaSpecialFactor = new Bindable(); + private Bindable uiScale = new Bindable(1f); + + private readonly Player player; + + private Container maniaBackgroundMask = null!; + private DimmableBackground maniaMaskedDimmable = null!; + private Vector2 lastDrawSize; + + private int keyMode; + + [Resolved] + private Ez2ConfigManager ezSkinConfig { get; set; } = null!; + + public PlayerManiaBackgroundScreen(WorkingBeatmap beatmap, Player player) + : base(beatmap) + { + this.player = player; + DisableParallax = true; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, CancellationToken cancellationToken) + { + var playableBeatmap = player.loadPlayableBeatmap(player.Mods.Value.ToArray(), cancellationToken); + + keyMode = (int)playableBeatmap.Difficulty.CircleSize; + + Logger.Log($"[ManiaBackground] KeyMode: {keyMode}"); + + // 创建遮罩背景容器 + // 关键:不使用嵌套结构,直接让 DimmableBackground 作为遮罩容器的子元素 + maniaBackgroundMask = new Container + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + Child = maniaMaskedDimmable = new DimmableBackground + { + RelativeSizeAxes = Axes.None, // 不使用相对尺寸,如果用Both会导致主副背景缩放不一致 + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + + AddInternal(maniaBackgroundMask); + + // 设置副本背景 + var maskedBackground = new BeatmapBackground(player.Beatmap.Value); + maskedBackground.FadeInFromZero(500, Easing.OutQuint); + maniaMaskedDimmable.Background = maskedBackground; + maniaMaskedDimmable.StoryboardReplacesBackground.BindTo(player.StoryboardReplacesBackground); + maniaMaskedDimmable.IgnoreUserSettings.BindTo(new Bindable(true)); + maniaMaskedDimmable.IsBreakTime.BindTo(player.IsBreakTime); + + maniaColumnBlur = ezSkinConfig.GetBindable(Ez2Setting.ColumnBlur); + maniaColumnWidth = ezSkinConfig.GetBindable(Ez2Setting.ColumnWidth); + maniaSpecialFactor = ezSkinConfig.GetBindable(Ez2Setting.SpecialFactor); + + maniaColumnBlur.BindValueChanged(v => maniaMaskedDimmable.BlurAmount.Value = (float)v.NewValue * 50, true); + maniaColumnWidth.BindValueChanged(_ => updateMaskWidth(), true); + maniaSpecialFactor.BindValueChanged(_ => updateMaskWidth(), true); + + uiScale = config.GetBindable(OsuSetting.UIScale); + uiScale.BindValueChanged(_ => updateMaskWidth(), true); + } + + protected override void Update() + { + base.Update(); + + if (lastDrawSize != DrawSize) + { + lastDrawSize = DrawSize; + maniaMaskedDimmable.Size = DrawSize; + updateMaskWidth(); + } + } + + private void updateMaskWidth() + { + if (!player.LoadedBeatmapSuccessfully) return; + + float totalWidth = ezSkinConfig.GetTotalWidth(keyMode); + + float uiScaleCompensation = 1f / uiScale.Value; + + maniaBackgroundMask.Width = totalWidth * uiScaleCompensation; + } + } + } +} + +// 尝试通过反射获取ManiaPlayfield的实际下落面板宽度, 反射有延迟且不稳定,先注释掉,不要删除 +// if (player.DrawableRuleset?.Playfield is ScrollingPlayfield scrollingPlayfield) +// { +// try +// { +// // 通过反射访问ManiaPlayfield的私有stages字段 +// var stagesField = scrollingPlayfield.GetType().GetField("stages", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); +// +// if (stagesField != null) +// { +// if (stagesField.GetValue(scrollingPlayfield) is IList stages && stages.Count > 0) +// { +// // 获取第一个Stage的DrawWidth(Mania通常只有一个Stage) +// object? firstStage = stages[0]; +// var drawWidthProperty = firstStage?.GetType().GetProperty("DrawWidth"); +// +// if (drawWidthProperty != null) +// { +// totalWidth = (float)drawWidthProperty.GetValue(firstStage)!; +// } +// } +// } +// } +// catch +// { +// // 如果反射失败,回退到计算列宽总和 +// totalWidth = 0; +// } +// } + +// if (player.DrawableRuleset?.Playfield is ScrollingPlayfield scrollingPlayfield) +// { +// try +// { +// // 通过反射访问ManiaPlayfield的私有stages字段 +// var stagesField = scrollingPlayfield.GetType().GetField("stages", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); +// +// if (stagesField != null) +// { +// if (stagesField.GetValue(scrollingPlayfield) is IList stages && stages.Count > 0) +// { +// // 获取第一个Stage的DrawWidth(Mania通常只有一个Stage) +// object? firstStage = stages[0]; +// var cs = firstStage?.GetType().GetProperty("Columns"); +// +// if (cs != null) +// { +// keyMode = (int)cs.GetValue(firstStage)!; +// } +// } +// } +// } +// catch +// { +// // 如果反射失败,回退到谱面信息获取 +// keyMode = (int)workingBeatmap.Beatmap.Difficulty.CircleSize; +// } +// } diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 06f1a9c530..df65a0e42c 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -14,6 +14,8 @@ using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.LAsEzExtensions.Audio; +using osu.Game.LAsEzExtensions.Configuration; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; @@ -48,6 +50,12 @@ namespace osu.Game.Screens.Play [CanBeNull] private UserStatisticsWatcher userStatisticsWatcher { get; set; } + [Resolved] + private osu.Framework.Audio.AudioManager audioManager { get; set; } + + [CanBeNull] + private InputAudioLatencyTracker latencyTracker; + private readonly object scoreSubmissionLock = new object(); private TaskCompletionSource scoreSubmissionSource; @@ -57,7 +65,7 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader] - private void load() + private void load(Ez2ConfigManager ezConfig) { if (DrawableRuleset == null) { @@ -75,6 +83,19 @@ namespace osu.Game.Screens.Play Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, }); + + // 初始化延迟追踪 + latencyTracker = new InputAudioLatencyTracker(ezConfig); + latencyTracker?.Initialize(ScoreProcessor); + // Ensure tracker is started for testing scenarios in SubmittingPlayer + try + { + latencyTracker?.Start(); + } + catch (Exception ex) + { + Logger.Log($"InputAudioLatencyTracker failed to Start: {ex.Message}", level: LogLevel.Error); + } } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart) @@ -186,23 +207,24 @@ namespace osu.Game.Screens.Play /// Whether gameplay should be immediately exited as a result. Returning false allows the gameplay session to continue. Defaults to true. protected virtual bool ShouldExitOnTokenRetrievalFailure(Exception exception) => true; - public override bool AllowCriticalSettingsAdjustment - { - get - { - // General limitations to ensure players don't do anything too weird. - // These match stable for now. - - // TODO: the blocking conditions should probably display a message. - if (!IsBreakTime.Value && GameplayClockContainer.CurrentTime - GameplayClockContainer.GameplayStartTime > 10000) - return false; - - if (GameplayClockContainer.IsPaused.Value) - return false; - - return base.AllowCriticalSettingsAdjustment; - } - } + // 重写允许在游戏过程中调整关键设置(如速度修改器)大于10秒后、非休息时间且未暂停时不允许调整 + // public override bool AllowCriticalSettingsAdjustment + // { + // get + // { + // // General limitations to ensure players don't do anything too weird. + // // These match stable for now. + // + // // TODO: the blocking conditions should probably display a message. + // if (!IsBreakTime.Value && GameplayClockContainer.CurrentTime - GameplayClockContainer.GameplayStartTime > 10000) + // return false; + // + // if (GameplayClockContainer.IsPaused.Value) + // return false; + // + // return base.AllowCriticalSettingsAdjustment; + // } + // } protected override async Task PrepareScoreForResultsAsync(Score score) { @@ -257,6 +279,10 @@ namespace osu.Game.Screens.Play bool exiting = base.OnExiting(e); submitFromFailOrQuit(Score); statics.SetValue(Static.LastLocalUserScore, Score?.ScoreInfo.DeepClone()); + + // 生成延迟报告 + latencyTracker?.GenerateLatencyReport(); + return exiting; } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 8d5e6c05c3..9c69ff9278 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Screens; @@ -23,6 +24,8 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Screens; using osu.Game.Localisation; using osu.Game.Online.Placeholders; using osu.Game.Overlays; @@ -59,6 +62,13 @@ namespace osu.Game.Screens.Ranking [Resolved] private Player? player { get; set; } + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; + + private Bindable hitModeBindable = new Bindable(); + + private HitModeButton hitModeButton = null!; + private bool skipExitTransition; protected StatisticsPanel StatisticsPanel { get; private set; } = null!; @@ -233,12 +243,32 @@ namespace osu.Game.Screens.Ranking if (Score?.BeatmapInfo?.BeatmapSet != null && Score.BeatmapInfo.BeatmapSet.OnlineID > 0) buttons.Add(new FavouriteButton(Score.BeatmapInfo.BeatmapSet)); + + // 底部增加按钮 + hitModeBindable = ezConfig.GetBindable(Ez2Setting.HitMode); + buttons.Add(new HitModeButton(hitModeBindable)); + + // Add settings button (placeholder) + buttons.Add(new IconButton + { + Icon = FontAwesome.Solid.Cog, + Action = () => + { + /* TODO: show settings menu */ + } + }); } protected override void LoadComplete() { base.LoadComplete(); + hitModeBindable.BindValueChanged(v => + { + Score?.HitEvents.Clear(); + StatisticsPanel.Score.TriggerChange(); + }); + StatisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); fetchScores(null); diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index b0e1c89121..afdea9ba2c 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -206,6 +206,9 @@ namespace osu.Game.Screens.Ranking // In the end, it's easier to compute the scroll position manually. float scrollOffset = flow.GetPanelIndex(expandedPanel.Score) * (ScorePanel.CONTRACTED_WIDTH + panel_spacing); scroll.ScrollTo(scrollOffset); + + // 进入结算自动展开拓展分析 + PostExpandAction?.Invoke(); }); } diff --git a/osu.Game/Screens/Ranking/Statistics/ScoreHitEventGeneratorBridge.cs b/osu.Game/Screens/Ranking/Statistics/ScoreHitEventGeneratorBridge.cs new file mode 100644 index 0000000000..e95197f711 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/ScoreHitEventGeneratorBridge.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Utils; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// + /// 桥接类,提供对规则集特定的 实现的访问。 + /// 允许每个规则集注册自己的生成器,并为未注册的规则集提供回退机制。 + /// + public static class ScoreHitEventGeneratorBridge + { + private const string logger_name = "hit_events"; + + private static readonly ConcurrentDictionary generators = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 注册规则集特定的命中事件生成器。 + /// + /// 规则集标识符(通常是短名称,如"osu"、"mania"等) + /// 规则集的命中事件生成器 + public static void Register(string key, IHitEventGenerator generator) + { + if (string.IsNullOrEmpty(key)) + return; + + generators[key] = generator; + } + + /// + /// 注销规则集特定的命中事件生成器。 + /// + /// 要注销的规则集标识符 + public static void Unregister(string key) + { + if (string.IsNullOrEmpty(key)) + return; + + generators.TryRemove(key, out _); + } + + /// + /// 通过规则集键获取已注册的命中事件生成器。 + /// + /// 规则集标识符 + /// 已注册的生成器,如果未找到则为null + public static IHitEventGenerator? Get(string key) + { + if (string.IsNullOrEmpty(key)) + return null; + + return generators.GetValueOrDefault(key); + } + + /// + /// 尝试使用适当的规则集特定生成器为分数生成命中事件。 + /// 自动根据分数的规则集确定正确的生成器。 + /// + /// 要为其生成命中事件的分数 + /// 与分数关联的谱面 + /// 用于停止生成的取消令牌 + /// 命中事件列表,如果没有可用的生成器则为null + public static List? TryGenerate(Score score, IBeatmap playableBeatmap, CancellationToken cancellationToken) + { + try + { + string key = score.ScoreInfo.Ruleset.ShortName; + + if (string.IsNullOrEmpty(key)) + key = score.ScoreInfo.Ruleset.OnlineID.ToString(); + + // 快速路径:如果可用,使用已注册的生成器 + var gen = Get(key); + if (gen != null) + return gen.Generate(score, playableBeatmap, cancellationToken); + + // 回退:通过反射发现实现了IHitEventGenerator接口的类型 + var discoveredGen = discoverAndRegisterGenerator(key, score, playableBeatmap, cancellationToken); + return discoveredGen; + } + catch (Exception ex) + { + Logger.Error(ex, $"HitEvent generation via bridge failed. ruleset={score.ScoreInfo.Ruleset.ShortName}", logger_name); + return null; + } + } + + /// + /// 通过反射发现实现了IHitEventGenerator接口的类型并注册以供将来使用。 + /// 专门查找实现了IHitEventGenerator接口的类型。 + /// + /// 用于为发现的生成器注册的规则集键 + /// 要为其生成命中事件的分数 + /// 与分数关联的谱面 + /// 用于停止生成的取消令牌 + /// 发现的生成器的结果,如果没有找到则为null + private static List? discoverAndRegisterGenerator(string key, Score score, IBeatmap playableBeatmap, CancellationToken cancellationToken) + { + // 遍历所有程序集,查找实现了IHitEventGenerator接口的类型 + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + Type[] types; + + // 获取程序集中的所有类型,如果无法访问则跳过此程序集 + // 某些程序集可能因安全限制等原因无法获取其类型列表 + try + { + types = asm.GetTypes(); + } + catch + { + continue; + } + + foreach (var t in types) + { + if (t.IsInterface || t.IsAbstract) + continue; + + if (!typeof(IHitEventGenerator).IsAssignableFrom(t)) + continue; + + IHitEventGenerator? candidate = null; + + var instProp = t.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static); + + if (instProp != null && typeof(IHitEventGenerator).IsAssignableFrom(instProp.PropertyType)) + { + candidate = instProp.GetValue(null) as IHitEventGenerator; + } + + // 回退到无参构造函数 + candidate ??= Activator.CreateInstance(t) as IHitEventGenerator; + + // 测试此候选项是否适用于提供的分数 + var result = candidate?.Generate(score, playableBeatmap, cancellationToken); + + if (result != null && candidate != null) + { + Register(key, candidate); + return result; + } + } + } + + Logger.Log($"No HitEvent generator found for ruleset={key}. Skipping local generation.", logger_name, LogLevel.Debug); + return null; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index 5c5c814c5b..23e7d0d9d3 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -21,6 +21,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Placeholders; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics.User; using osuTK; @@ -46,6 +47,9 @@ namespace osu.Game.Screens.Ranking.Statistics [Resolved] private BeatmapManager beatmapManager { get; set; } = null!; + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + [Resolved] private RealmAccess realm { get; set; } = null!; @@ -111,12 +115,37 @@ namespace osu.Game.Screens.Ranking.Statistics var workingBeatmap = beatmapManager.GetWorkingBeatmap(newScore.BeatmapInfo); // Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events. - Task.Run(() => workingBeatmap.GetPlayableBeatmap(newScore.Ruleset, newScore.Mods), loadCancellation.Token).ContinueWith(task => Schedule(() => + Task.Run(() => { + #region 接口反射后台加载结算 + + // 结算后加载一次分数,后台计算 + var playable = workingBeatmap.GetPlayableBeatmap(newScore.Ruleset, newScore.Mods); + + List? generatedHitEvents = null; + + if (newScore.HitEvents.Count == 0) + { + // Only attempt generation when we have a local replay. + var databasedScore = scoreManager.GetScore(newScore); + if (databasedScore != null) + generatedHitEvents = ScoreHitEventGeneratorBridge.TryGenerate(databasedScore, playable, loadCancellation.Token); + } + + return (playable, generatedHitEvents); + }, loadCancellation.Token).ContinueWith(task => Schedule(() => + { + var result = task.GetResultSafely(); + + if (result.generatedHitEvents != null) + newScore.HitEvents = result.generatedHitEvents; + bool hitEventsAvailable = newScore.HitEvents.Count != 0; Container container; - var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely()).ToArray(); + var statisticItems = CreateStatisticItems(newScore, result.playable).ToArray(); + + #endregion if (!hitEventsAvailable && statisticItems.All(c => c.RequiresHitEvents)) { diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index a8f5b6dd24..e53d80851a 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -17,6 +17,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Collections; @@ -32,6 +33,10 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.UserInterface; +using osu.Game.Screens.SelectV2; using CommonStrings = osu.Game.Localisation.CommonStrings; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; @@ -40,13 +45,14 @@ namespace osu.Game.Screens.Select.Carousel public partial class DrawableCarouselBeatmap : DrawableCarouselItem, IHasContextMenu { public const float CAROUSEL_BEATMAP_SPACING = 5; + private const int mania_ui_update_throttle_ms = 15; /// /// The height of a carousel beatmap, including vertical spacing. /// public const float HEIGHT = height + CAROUSEL_BEATMAP_SPACING; - private const float height = MAX_HEIGHT * 0.6f; + private const float height = MAX_HEIGHT * 0.9f; private readonly BeatmapInfo beatmapInfo; @@ -64,6 +70,18 @@ namespace osu.Game.Screens.Select.Carousel private OsuSpriteText keyCountText = null!; + private EzDisplayLineGraph ezKpsGraph = null!; + private EzKpsDisplay ezKpsDisplay = null!; + private EzKpcDisplay ezKpcDisplay = null!; + private EzDisplayXxySR displayXxySR = null!; + private Bindable xxySrFilterSetting = null!; + + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; + + [Resolved] + private EzBeatmapManiaAnalysisCache maniaAnalysisCache { get; set; } = null!; + [Resolved] private BeatmapSetOverlay? beatmapOverlay { get; set; } @@ -94,6 +112,30 @@ namespace osu.Game.Screens.Select.Carousel private IBindable starDifficultyBindable = null!; private CancellationTokenSource? starDifficultyCancellationSource; + private IBindable? maniaAnalysisBindable; + private CancellationTokenSource? maniaAnalysisCancellationSource; + private string? cachedScratchText; + + private ScheduledDelegate? scheduledManiaUiUpdate; + private (double averageKps, double maxKps, List kpsList) pendingKpsResult; + private Dictionary? pendingColumnCounts; + private Dictionary? pendingHoldNoteCounts; + private bool hasPendingUiUpdate; + + private Bindable kpcDisplayMode = null!; + + private int cachedKpcKeyCount = -1; + private Guid cachedKpcBeatmapId; + private int cachedKpcRulesetId = -1; + private int cachedKpcModsHash; + + private Dictionary? normalizedColumnCounts; + private Dictionary? normalizedHoldNoteCounts; + private int normalizedCountsKeyCount; + + private int lastKpcCountsHash; + private EzKpcDisplay.KpcDisplayMode lastKpcMode; + public DrawableCarouselBeatmap(CarouselBeatmap panel) { beatmapInfo = panel.BeatmapInfo; @@ -140,6 +182,8 @@ namespace osu.Game.Screens.Select.Carousel { TooltipType = DifficultyIconTooltipType.None, Scale = new Vector2(1.8f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft }, new FillFlowContainer { @@ -148,12 +192,27 @@ namespace osu.Game.Screens.Select.Carousel AutoSizeAxes = Axes.Both, Children = new Drawable[] { + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + ezKpsGraph = new EzDisplayLineGraph + { + Size = new Vector2(300, 20), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + } + }, new FillFlowContainer { Direction = FillDirection.Horizontal, Spacing = new Vector2(4, 0), AutoSizeAxes = Axes.Both, - Children = new[] + Children = new Drawable[] { keyCountText = new OsuSpriteText { @@ -175,6 +234,11 @@ namespace osu.Game.Screens.Select.Carousel Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft }, + ezKpsDisplay = new EzKpsDisplay + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft + }, } }, new FillFlowContainer @@ -186,7 +250,18 @@ namespace osu.Game.Screens.Select.Carousel Children = new Drawable[] { new TopLocalRank(beatmapInfo), - starCounter = new StarCounter() + starCounter = new StarCounter(), + displayXxySR = new EzDisplayXxySR + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.875f), + }, + ezKpcDisplay = new EzKpcDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } } } } @@ -200,8 +275,33 @@ namespace osu.Game.Screens.Select.Carousel { base.LoadComplete(); - ruleset.BindValueChanged(_ => updateKeyCount()); - mods.BindValueChanged(_ => updateKeyCount()); + ruleset.BindValueChanged(_ => + { + computeManiaAnalysis(); + updateKeyCount(); + }); + + mods.BindValueChanged(_ => + { + computeManiaAnalysis(); + updateKeyCount(); + }, true); + + // 设置 XxySRFilter 设置的绑定 + xxySrFilterSetting = ezConfig.GetBindable(Ez2Setting.XxySRFilter); + xxySrFilterSetting.BindValueChanged(value => + { + // 根据 XxySRFilter 设置切换图标 + starCounter.Icon = value.NewValue + ? FontAwesome.Solid.Moon + : FontAwesome.Solid.Star; + }, true); // true 表示立即触发一次以设置初始状态 + + kpcDisplayMode = ezConfig.GetBindable(Ez2Setting.KpcDisplayMode); + kpcDisplayMode.BindValueChanged(mode => + { + ezKpcDisplay.CurrentKpcDisplayMode = mode.NewValue; + }, true); } protected override void Selected() @@ -256,9 +356,200 @@ namespace osu.Game.Screens.Select.Carousel updateKeyCount(); } + // Start/refresh mania analysis binding when visible + computeManiaAnalysis(); + base.ApplyState(); } + private void queueManiaUiUpdate((double averageKps, double maxKps, List kpsList) result, Dictionary? columnCounts, Dictionary? holdNoteCounts) + { + pendingKpsResult = result; + pendingColumnCounts = columnCounts; + pendingHoldNoteCounts = holdNoteCounts; + hasPendingUiUpdate = true; + + if (scheduledManiaUiUpdate != null) + return; + + scheduledManiaUiUpdate = Scheduler.AddDelayed(() => + { + scheduledManiaUiUpdate = null; + + if (!hasPendingUiUpdate) + return; + + hasPendingUiUpdate = false; + updateKPs(pendingKpsResult, pendingColumnCounts, pendingHoldNoteCounts); + }, mania_ui_update_throttle_ms, false); + } + + private void resetManiaAnalysisDisplay() + { + cachedScratchText = null; + displayXxySR.Current.Value = null; + + if (ruleset.Value.OnlineID == 3) + { + ezKpcDisplay.Show(); + displayXxySR.Show(); + } + else + { + ezKpcDisplay.Hide(); + displayXxySR.Hide(); + } + } + + private int getCachedKpcKeyCount() + { + Guid beatmapId = beatmapInfo.ID; + int rulesetId = ruleset.Value.OnlineID; + int modsHash = computeModsHash(mods.Value); + + if (cachedKpcKeyCount >= 0 + && cachedKpcBeatmapId == beatmapId + && cachedKpcRulesetId == rulesetId + && cachedKpcModsHash == modsHash) + return cachedKpcKeyCount; + + // legacy KPC key count calculation intentionally left unimplemented here. + cachedKpcBeatmapId = beatmapId; + cachedKpcRulesetId = rulesetId; + cachedKpcModsHash = modsHash; + return cachedKpcKeyCount; + } + + private void ensureNormalizedCounts(int keyCount) + { + // Defensive: ensure keyCount is non-negative. Some legacy paths may + // return -1 when unimplemented, which would crash when used as a + // Dictionary capacity. + if (keyCount < 0) + keyCount = 0; + + if (normalizedColumnCounts != null && normalizedHoldNoteCounts != null && normalizedCountsKeyCount == keyCount) + return; + + normalizedCountsKeyCount = keyCount; + normalizedColumnCounts = new Dictionary(Math.Max(0, keyCount)); + normalizedHoldNoteCounts = new Dictionary(Math.Max(0, keyCount)); + + for (int i = 0; i < keyCount; i++) + { + normalizedColumnCounts[i] = 0; + normalizedHoldNoteCounts[i] = 0; + } + } + + private static int computeModsHash(IReadOnlyList mods) + { + unchecked + { + int hash = 17; + for (int i = 0; i < mods.Count; i++) + hash = hash * 31 + mods[i].GetHashCode(); + + return hash; + } + } + + private static int computeCountsHash(Dictionary columnCounts, Dictionary holdCounts, int keyCount) + { + unchecked + { + int hash = 17; + + for (int i = 0; i < keyCount; i++) + { + hash = hash * 31 + columnCounts.GetValueOrDefault(i); + hash = hash * 31 + holdCounts.GetValueOrDefault(i); + } + + return hash; + } + } + + private void updateKPs((double averageKps, double maxKps, List kpsList) result, Dictionary? columnCounts, Dictionary? holdNoteCounts) + { + if (Item == null) + return; + + // 滚动过程中会有大量不可见/刚离屏的面板仍收到分析回调。 + // 这些面板的 UI 更新会造成明显 GC 压力与 Draw FPS 下降,因此先缓存为 pending,等再次可见时再应用。 + if (!IsPresent) + { + pendingKpsResult = result; + pendingColumnCounts = columnCounts; + pendingHoldNoteCounts = holdNoteCounts; + hasPendingUiUpdate = true; + return; + } + + var (averageKps, maxKps, kpsList) = result; + + ezKpsDisplay.SetKps(averageKps, maxKps); + + // Update KPS graph with the KPS list + if (kpsList.Count > 0) + { + ezKpsGraph.SetValues(kpsList); + } + + if (columnCounts != null) + { + // 注意:分析结果里的 ColumnCounts 只包含“出现过的列”。 + // 当某个 mod 删除了某一列的所有 notes 时,这一列会缺失, + // 直接显示会导致列号错位(看起来像“没有更新”)。 + // 这里把字典补齐到 0..keyCount-1,缺失列填 0。 + int keyCount = getCachedKpcKeyCount(); + ensureNormalizedCounts(keyCount); + + for (int i = 0; i < keyCount; i++) + { + normalizedColumnCounts![i] = columnCounts.GetValueOrDefault(i); + normalizedHoldNoteCounts![i] = holdNoteCounts?.GetValueOrDefault(i) ?? 0; + } + + int countsHash = computeCountsHash(normalizedColumnCounts!, normalizedHoldNoteCounts!, keyCount); + var mode = ezKpcDisplay.CurrentKpcDisplayMode; + + if (countsHash != lastKpcCountsHash || mode != lastKpcMode) + { + lastKpcCountsHash = countsHash; + lastKpcMode = mode; + ezKpcDisplay.UpdateColumnCounts(normalizedColumnCounts!, normalizedHoldNoteCounts!); + } + } + } + + private void computeManiaAnalysis() + { + maniaAnalysisCancellationSource?.Cancel(); + maniaAnalysisCancellationSource = new CancellationTokenSource(); + + if (Item == null) + return; + + // Reset UI to avoid showing stale data from previous beatmap + resetManiaAnalysisDisplay(); + + maniaAnalysisBindable = maniaAnalysisCache.GetBindableAnalysis(beatmapInfo, maniaAnalysisCancellationSource.Token, computationDelay: 100); + maniaAnalysisBindable.BindValueChanged(result => + { + // Ignore placeholder handling – use whatever real data is provided. + + // Always update cachedScratchText (may be empty) so 0-note columns are reflected. + cachedScratchText = result.NewValue.ScratchText; + Schedule(updateKeyCount); + + queueManiaUiUpdate((result.NewValue.AverageKps, result.NewValue.MaxKps, result.NewValue.KpsList), result.NewValue.ColumnCounts, result.NewValue.HoldNoteCounts); + + if (result.NewValue.XxySr != null) + displayXxySR.Current.Value = result.NewValue.XxySr; + }, true); + } + private void updateKeyCount() { if (Item?.State.Value == CarouselItemState.Collapsed) @@ -271,7 +562,8 @@ namespace osu.Game.Screens.Select.Carousel ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); keyCountText.Alpha = 1; - keyCountText.Text = $"[{legacyRuleset.GetKeyCount(beatmapInfo, mods.Value)}K]"; + keyCountText.Text = cachedScratchText ?? $"[{legacyRuleset.GetKeyCount(beatmapInfo, mods.Value)}K] "; + keyCountText.Colour = Colour4.LightPink.ToLinear(); } else keyCountText.Alpha = 0; @@ -316,6 +608,13 @@ namespace osu.Game.Screens.Select.Carousel { base.Dispose(isDisposing); starDifficultyCancellationSource?.Cancel(); + starDifficultyBindable?.UnbindAll(); + starDifficultyBindable = null!; + + maniaAnalysisCancellationSource?.Cancel(); + if (maniaAnalysisBindable != null) + maniaAnalysisBindable.UnbindAll(); + maniaAnalysisBindable = null; } } } diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 4781a3dee7..9b5a2e42d5 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -29,6 +29,8 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select.Filter; using osuTK; using osuTK.Input; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Select; namespace osu.Game.Screens.Select { @@ -52,6 +54,9 @@ namespace osu.Game.Screens.Select private Bindable sortMode; private Bindable groupMode; private FilterControlTextBox searchTextBox; + private EzKeyModeSelector csSelector = null!; + private ShearedToggleButton keySoundPreviewButton = null!; + private ShearedToggleButton xxySrFilterButton = null!; private CollectionDropdown collectionDropdown; [CanBeNull] @@ -78,16 +83,49 @@ namespace osu.Game.Screens.Select criteria.UserStarDifficulty.Max = maximumStars.Value; criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); + applyCircleSizeFilter(criteria); FilterQueryParser.ApplyQueries(criteria, query); return criteria; } + private void applyCircleSizeFilter(FilterCriteria criteria) + { + if (csSelector == null) + return; + + var selectedModeIds = csSelector.EzKeyModeFilter.SelectedModeIds; + + if (selectedModeIds.Count == 0 || selectedModeIds.Contains("All")) + return; + + var selectedModes = CsItemIds.ALL + .Where(m => selectedModeIds.Contains(m.Id) && m.CsValue.HasValue) + .Select(m => m.CsValue!.Value) + .ToList(); + + if (selectedModes.Count == 0) + return; + + criteria.DiscreteCircleSizeValues = new List(selectedModes); + + if (ruleset.Value.OnlineID != 3) + { + criteria.CircleSize = new FilterCriteria.OptionalRange + { + Min = selectedModes.Min() - 0.5f, + Max = selectedModes.Max() + 0.5f, + IsLowerInclusive = false, + IsUpperInclusive = false + }; + } + } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, OsuConfigManager config) + private void load(OsuColour colours, OsuConfigManager config, Ez2ConfigManager ezConfig) { sortMode = config.GetBindable(OsuSetting.SongSelectSortingMode); groupMode = config.GetBindable(OsuSetting.SongSelectGroupMode); @@ -134,7 +172,7 @@ namespace osu.Game.Screens.Select new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), new Dimension(), - new Dimension(GridSizeMode.Absolute, OsuTabControl.HORIZONTAL_SPACING), + new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize), }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, @@ -161,7 +199,6 @@ namespace osu.Game.Screens.Select AccentColour = colours.GreenLight, Current = { BindTarget = sortMode } }, - Empty(), new OsuTabControlCheckbox { Text = "Show converted", @@ -169,6 +206,13 @@ namespace osu.Game.Screens.Select Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, }, + keySoundPreviewButton = new ShearedToggleButton + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Text = "kSound Preview", + Height = 30f, + } } } }, @@ -203,6 +247,28 @@ namespace osu.Game.Screens.Select } } }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 30, + Children = new Drawable[] + { + csSelector = new EzKeyModeSelector + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + RelativeSizeAxes = Axes.X, + }, + xxySrFilterButton = new ShearedToggleButton + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Text = "xxy_SR Filter", + TooltipText = "(NoActive)Filter, sort beatmaps by Xxy Star Rating", + Height = 30f, + }, + } + }, } } } @@ -231,6 +297,9 @@ namespace osu.Game.Screens.Select updateCriteria(); }); + ezConfig.BindWith(Ez2Setting.XxySRFilter, xxySrFilterButton.Active); + ezConfig.BindWith(Ez2Setting.KeySoundPreview, keySoundPreviewButton.Active); + groupMode.BindValueChanged(_ => updateCriteria()); sortMode.BindValueChanged(_ => updateCriteria()); @@ -239,6 +308,45 @@ namespace osu.Game.Screens.Select updateCriteria(); } + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset?.BindValueChanged(_ => + { + updateCriteria(); + + if (ruleset.Value.OnlineID == 1) // Taiko + { + csSelector.Hide(); + xxySrFilterButton.Hide(); + } + else + { + csSelector.Show(); + + if (ruleset.Value.OnlineID == 3) + { + xxySrFilterButton.Show(); + } + } + }); + + mods?.BindValueChanged(m => + { + if (m.NewValue.SequenceEqual(m.OldValue)) + return; + + if (currentCriteria?.RulesetCriteria?.FilterMayChangeFromMods(m) == true) + updateCriteria(); + }); + + csSelector?.Current.BindValueChanged(_ => updateCriteria()); + // csSelector?.EzKeyModeFilter.SelectionChanged += updateCriteria; + xxySrFilterButton?.Active.BindValueChanged(_ => updateCriteria()); + keySoundPreviewButton?.Active.BindValueChanged(_ => updateCriteria()); + } + public void Deactivate() { searchTextBox.ReadOnly = true; diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 485c4d1d72..88eb7d8399 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -116,6 +116,9 @@ namespace osu.Game.Screens.Select /// public IEnumerable? CollectionBeatmapMD5Hashes { get; set; } + // 多条件过滤按钮的实现 + public List? DiscreteCircleSizeValues { get; set; } + public IRulesetFilterCriteria? RulesetCriteria { get; set; } /// diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 4519e594c6..cba3d80204 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -27,6 +27,7 @@ using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Carousel; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Rulesets; @@ -116,6 +117,21 @@ namespace osu.Game.Screens.SelectV2 }; AddInternal(loading = new LoadingLayer()); + +#if DEBUG + // Lightweight on-screen instrumentation for development. + var debugText = new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Colour = Colour4.White, + Alpha = 0.8f, + Margin = new MarginPadding { Left = 10, Top = 6 } + }; + + AddInternal(debugText); + Scheduler.AddDelayed(() => debugText.Text = $"filter: {LastFilterMs:F1}ms items: {LastFilterItems} panels: {LastFilterPanels} runs: {FilterRuns}", 250, true); +#endif } [BackgroundDependencyLoader] @@ -792,6 +808,17 @@ namespace osu.Game.Screens.SelectV2 private ScheduledDelegate? loadingDebounce; + // Lightweight instrumentation for investigating filter performance regressions. + private double lastFilterMs; + private int lastFilterItems; + private int lastFilterPanels; + private int filterRuns; + + public double LastFilterMs => lastFilterMs; + public int LastFilterItems => lastFilterItems; + public int LastFilterPanels => lastFilterPanels; + public int FilterRuns => filterRuns; + public void Filter(FilterCriteria criteria, bool showLoadingImmediately = false) { bool resetDisplay = grouping.BeatmapSetsGroupedTogether != BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria); @@ -810,13 +837,29 @@ namespace osu.Game.Screens.SelectV2 loading.Show(); }, showLoadingImmediately ? 0 : 250); + // Instrumented call: measure filter duration and record counts. + Interlocked.Increment(ref filterRuns); + var sw = Stopwatch.StartNew(); + FilterAsync(resetDisplay).ContinueWith(_ => Schedule(() => { - loadingDebounce?.Cancel(); - loadingDebounce = null; + try + { + sw.Stop(); + lastFilterMs = sw.Elapsed.TotalMilliseconds; - Scroll.FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint); - loading.Hide(); + var items = GetCarouselItems(); + lastFilterItems = items?.Count ?? 0; + lastFilterPanels = Scroll.Panels.Count; + } + finally + { + loadingDebounce?.Cancel(); + loadingDebounce = null; + + Scroll.FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint); + loading.Hide(); + } })); } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index 280db188ef..ee1183d1c6 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -210,7 +210,7 @@ namespace osu.Game.Screens.SelectV2 return getGroupsBy(b => defineGroupByLength(b.Length), items); case GroupMode.Source: - return getGroupsBy(b => defineGroupBySource(b.BeatmapSet!.Metadata.Source), items); + return getGroupsBy(defineGroupBySource, items); case GroupMode.Collections: { @@ -394,12 +394,77 @@ namespace osu.Game.Screens.SelectV2 return new GroupDefinition(11, "Over 10 minutes").Yield(); } - private IEnumerable defineGroupBySource(string source) + private IEnumerable defineGroupBySource(BeatmapInfo beatmap) { - if (string.IsNullOrEmpty(source)) - return new GroupDefinition(1, "Unsourced").Yield(); + var meta = beatmap.BeatmapSet!.Metadata; - return new GroupDefinition(0, source).Yield(); + string source = meta.Source; + string tags = meta.Tags; + string title = meta.Title; + string artist = meta.Artist; + string diff = beatmap.DifficultyName; + + // combine fields for matching, but preserve whether source was provided + bool hasSource = !string.IsNullOrWhiteSpace(source); + string combined = string.Join(" ", source, tags, title, artist, diff).Trim(); + + if (string.IsNullOrWhiteSpace(combined)) + return new GroupDefinition(200, "Unsourced").Yield(); + + // helper for case-insensitive contains + static bool containsAny(string haystack, params string[] needles) + { + if (string.IsNullOrEmpty(haystack)) return false; + + foreach (string n in needles) + { + if (!string.IsNullOrEmpty(n) && haystack.IndexOf(n, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + + return false; + } + + // priority-ordered matching + if (containsAny(combined, "touhou", "東方", "东方", "touhou project", "東方Project", "東方プロジェクト", "동방프로젝트", "동방Project", "tohou", + "瑶山百灵", "藤咲かりん", "小峠舞", "ZUN", "上海アリス幻樂団", "上海アリス", "Team Shanghai Alice", "IOSYS", "EastNewSound", "幽閉サテライト", "C-CLAYS", "Silver Forest", "Sound Holic", "Alstroemeria Records", "豚乙女", "Demetori", "SOUND HOLIC", + "幽闭星光", + "博麗", "霊夢", "霊夢", "魔理沙", "アリス", "咲夜", "レミリア", "フランドール", "チルノ", "パチュリー", "妖夢", "鈴仙", "早苗", "映姫", "幽々子", "蓮子", "メディスン", "妖怪", + "地霊殿", "紅魔郷", "妖々夢", "永夜抄", "花映塚", "風神録", "神霊廟", "輝針城", "紺珠伝", "天空璋", "鬼形獣", "虹龍洞")) + return new GroupDefinition(2, "东方Project").Yield(); + + if (containsAny(combined, "vocaloid", "ボーカロイド", "보컬로이드", "vocaloids", "vocaloid music", "diva", + "miku", "hatsune", "kagamine", "gumi", "luka", "meiko", "kaito", "鏡音", "初音", "巡音", "巡音ルカ", "鏡音リン", "鏡音レン", "MEIKO", "KAITO", "GUMI")) + return new GroupDefinition(4, "VOCALOID").Yield(); + + if (containsAny(combined, "ez2", "ez2ac", "ez2dj", "ez2on")) + return new GroupDefinition(1, "EZ2AC").Yield(); + + if (containsAny(combined, "djmax", "디제이맥스", "DJMAX")) + return new GroupDefinition(0, "DJMAX").Yield(); + + if (containsAny(combined, "bms", "bof")) + return new GroupDefinition(0, "BMS").Yield(); + + if (containsAny(combined, "iidx", "beatmania iidx", "beatmania", "beatmaniaIIDX", "konami", "bemani", "sdvx", "sound voltex", + "pop'n music", "pop'n", "popn", "guitarFreaks", "drummania", "DDR", "dance dance revolution", "DanceDanceRevolution", "jubeat", "reflec beat", "REFLEC BEAT", + "あさき", "dj TAKA", "DJ YOSHITAKA", " 猫叉Master", "U1", "L.E.D.", "wac", "Qrispy Joybox", "PON", "DJ TOTTO", "PHQUASE", "村井圣夜" + )) + return new GroupDefinition(0, "BEMANI SOUND").Yield(); + + if (containsAny(combined, "o2jam", "o2mania", "오투잼", "[荣誉]", "[木星灵魂]", "[木星]", "劲乐团")) + return new GroupDefinition(0, "O2").Yield(); + + if (containsAny(combined, "tv", "tv-size", "tv size", "anime", "op", "ed", "tv_ver", "アニメ", "动画", "acg", "galgame", "dmm", "Douga", "Nico", + "game")) + return new GroupDefinition(3, "ACG").Yield(); + + // If none of the special rules matched but the source field was provided, put into Others + if (hasSource) + return new GroupDefinition(50, "Others").Yield(); + + // No source and no match -> Unsourced + return new GroupDefinition(200, "Unsourced").Yield(); } private List defineGroupsByCollection(List carouselItems, List allCollections) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs index 31e6c790f2..a94852d4c7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs @@ -79,6 +79,7 @@ namespace osu.Game.Screens.SelectV2 if (!match) return false; + // TODO: 这里要改成根据 xxySrFilter 的设置来决定是用 star rating 还是 xxy star rating 进行过滤 match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(beatmap.StarRating.FloorToDecimalDigits(2)); match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(beatmap.Difficulty.ApproachRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(beatmap.Difficulty.DrainRate); diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index 268e937167..07b5114c5a 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -20,6 +20,8 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Input.Bindings; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Select; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -35,7 +37,7 @@ namespace osu.Game.Screens.SelectV2 public partial class FilterControl : OverlayContainer { // taken from draw visualiser. used for carousel alignment purposes. - public const float HEIGHT_FROM_SCREEN_TOP = 141 - corner_radius; + public const float HEIGHT_FROM_SCREEN_TOP = 171 - corner_radius; private const float corner_radius = 10; @@ -47,6 +49,12 @@ namespace osu.Game.Screens.SelectV2 private ShearedDropdown sortDropdown = null!; private ShearedDropdown groupDropdown = null!; private CollectionDropdown collectionDropdown = null!; + private EzKeyModeSelector csSelector = null!; + private ShearedToggleButton keySoundPreviewButton = null!; + private ShearedToggleButton xxySrFilterButton = null!; + + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; [Resolved] private IBindable ruleset { get; set; } = null!; @@ -164,6 +172,7 @@ namespace osu.Game.Screens.SelectV2 new Dimension(GridSizeMode.Absolute, 5), new Dimension(), new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { @@ -185,13 +194,53 @@ namespace osu.Game.Screens.SelectV2 { RelativeSizeAxes = Axes.X, }, + keySoundPreviewButton = new ShearedToggleButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "kSound Preview", + Height = 30f, + }, } } }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute), // can probably be removed? + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + csSelector = new EzKeyModeSelector + { + RelativeSizeAxes = Axes.X, + }, + Empty(), + xxySrFilterButton = new ShearedToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "xxy_SR Filter", + TooltipText = "(NoActive)Filter, sort beatmaps by Xxy Star Rating", + Height = 30f, + }, + }, + } + }, new ScopedBeatmapSetDisplay { ScopedBeatmapSet = ScopedBeatmapSet, - } + Depth = float.MinValue, // hack to ensure that the scoped display handles `GlobalAction.Back` input before the filter control + }, }, } }; @@ -206,11 +255,31 @@ namespace osu.Game.Screens.SelectV2 difficultyRangeSlider.LowerBound = config.GetBindable(OsuSetting.DisplayStarsMinimum); difficultyRangeSlider.UpperBound = config.GetBindable(OsuSetting.DisplayStarsMaximum); + ezConfig.BindWith(Ez2Setting.XxySRFilter, xxySrFilterButton.Active); + ezConfig.BindWith(Ez2Setting.KeySoundPreview, keySoundPreviewButton.Active); config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmapsButton.Active); config.BindWith(OsuSetting.SongSelectSortingMode, sortDropdown.Current); config.BindWith(OsuSetting.SongSelectGroupMode, groupDropdown.Current); - ruleset.BindValueChanged(_ => updateCriteria()); + ruleset.BindValueChanged(_ => + { + updateCriteria(); + + if (ruleset.Value.OnlineID == 1) // Taiko + { + csSelector.Hide(); + xxySrFilterButton.Hide(); + } + else + { + csSelector.Show(); + + if (ruleset.Value.OnlineID == 3) + { + xxySrFilterButton.Show(); + } + } + }); mods.BindValueChanged(m => { // The following is a note carried from old song select and may not be a valid reason anymore: @@ -252,9 +321,19 @@ namespace osu.Game.Screens.SelectV2 localUserFavouriteBeatmapSets.BindCollectionChanged((_, _) => updateCriteria()); ScopedBeatmapSet.BindValueChanged(_ => updateCriteria(clearScopedSet: false)); + csSelector.Current.BindValueChanged(_ => updateCriteria()); + csSelector.EzKeyModeFilter.SelectionChanged += updateCriteria; + xxySrFilterButton.Active.BindValueChanged(_ => updateCriteria()); + updateCriteria(); } + private void updateCriteria() + { + currentCriteria = CreateCriteria(); + CriteriaChanged?.Invoke(currentCriteria); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -288,12 +367,44 @@ namespace osu.Game.Screens.SelectV2 if (!difficultyRangeSlider.UpperBound.IsDefault) criteria.UserStarDifficulty.Max = difficultyRangeSlider.UpperBound.Value; + applyCircleSizeFilter(criteria); + criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); FilterQueryParser.ApplyQueries(criteria, query); return criteria; } + private void applyCircleSizeFilter(FilterCriteria criteria) + { + var selectedModeIds = csSelector.EzKeyModeFilter.SelectedModeIds; + + if (selectedModeIds.Count == 0 || selectedModeIds.Contains("All")) + return; + + var selectedModes = CsItemIds.ALL + .Where(m => selectedModeIds.Contains(m.Id) && m.CsValue.HasValue) + .Select(m => m.CsValue!.Value) + .ToList(); + + if (selectedModes.Count == 0) + return; + + criteria.DiscreteCircleSizeValues = new List(selectedModes); + + if (ruleset.Value.OnlineID != 3) + { + // For no mania rulesets, ±0.5 is an intuitive range. + criteria.CircleSize = new FilterCriteria.OptionalRange + { + Min = selectedModes.Min() - 0.5f, + Max = selectedModes.Max() + 0.5f, + IsLowerInclusive = false, + IsUpperInclusive = false + }; + } + } + private void updateCriteria(bool clearScopedSet = true) { if (clearScopedSet && ScopedBeatmapSet.Value != null) diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index 6dc2286f44..75e43caba6 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Threading; using osu.Framework.Allocation; +using osu.Framework.Logging; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -13,6 +14,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -21,11 +23,15 @@ using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.UserInterface; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.SelectV2 { @@ -33,6 +39,8 @@ namespace osu.Game.Screens.SelectV2 { public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT; + private const int mania_ui_update_throttle_ms = 15; + private StarCounter starCounter = null!; private ConstrainedIconContainer difficultyIcon = null!; private OsuSpriteText keyCountText = null!; @@ -50,6 +58,18 @@ namespace osu.Game.Screens.SelectV2 private TrianglesV2 triangles = null!; + private EzDisplayLineGraph ezKpsGraph = null!; + private EzKpsDisplay ezKpsDisplay = null!; + private EzKpcDisplay ezKpcDisplay = null!; + private EzDisplayXxySR displayXxySR = null!; + private Bindable xxySrFilterSetting = null!; + + [Resolved] + private Ez2ConfigManager ezConfig { get; set; } = null!; + + [Resolved] + private EzBeatmapManiaAnalysisCache maniaAnalysisCache { get; set; } = null!; + [Resolved] private IRulesetStore rulesets { get; set; } = null!; @@ -67,6 +87,30 @@ namespace osu.Game.Screens.SelectV2 private BeatmapInfo beatmap => ((GroupedBeatmap)Item!.Model).Beatmap; + private IBindable? maniaAnalysisBindable; + private CancellationTokenSource? maniaAnalysisCancellationSource; + private string? cachedScratchText; + + private ScheduledDelegate? scheduledManiaUiUpdate; + private (double averageKps, double maxKps, List kpsList) pendingKpsResult; + private Dictionary? pendingColumnCounts; + private Dictionary? pendingHoldNoteCounts; + private bool hasPendingUiUpdate; + + private Bindable kpcDisplayMode = null!; + + private int cachedKpcKeyCount = -1; + private Guid cachedKpcBeatmapId; + private int cachedKpcRulesetId = -1; + private int cachedKpcModsHash; + + private Dictionary? normalizedColumnCounts; + private Dictionary? normalizedHoldNoteCounts; + private int normalizedCountsKeyCount; + + private int lastKpcCountsHash; + private EzKpcDisplay.KpcDisplayMode lastKpcMode; + public PanelBeatmap() { PanelXOffset = 60; @@ -159,7 +203,22 @@ namespace osu.Game.Screens.SelectV2 Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft - } + }, + ezKpsDisplay = new EzKpsDisplay + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + Empty(), + ezKpsGraph = new EzDisplayLineGraph + { + Size = new Vector2(300, 20), + LineColour = Color4.CornflowerBlue.Opacity(0.8f), + Blending = BlendingParameters.Mixture, + Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.CornflowerBlue), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, } }, new FillFlowContainer @@ -175,18 +234,29 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.CentreLeft, Scale = new Vector2(0.875f), }, + displayXxySR = new EzDisplayXxySR + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.875f), + }, starCounter = new StarCounter { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Scale = new Vector2(0.4f) - } + }, + ezKpcDisplay = new EzKpcDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, }, } } } } - } + }, }; } @@ -194,8 +264,35 @@ namespace osu.Game.Screens.SelectV2 { base.LoadComplete(); - ruleset.BindValueChanged(_ => updateKeyCount()); - mods.BindValueChanged(_ => updateKeyCount(), true); + ruleset.BindValueChanged(_ => + { + computeStarRating(); + computeManiaAnalysis(); + updateKeyCount(); + }); + + mods.BindValueChanged(_ => + { + computeStarRating(); + computeManiaAnalysis(); + updateKeyCount(); + }, true); + + // 设置 XxySRFilter 设置的绑定 + xxySrFilterSetting = ezConfig.GetBindable(Ez2Setting.XxySRFilter); + xxySrFilterSetting.BindValueChanged(value => + { + // 根据 XxySRFilter 设置切换图标 + starCounter.Icon = value.NewValue + ? FontAwesome.Solid.Moon + : FontAwesome.Solid.Star; + }, true); // true 表示立即触发一次以设置初始状态 + + kpcDisplayMode = ezConfig.GetBindable(Ez2Setting.KpcDisplayMode); + kpcDisplayMode.BindValueChanged(mode => + { + ezKpcDisplay.CurrentKpcDisplayMode = mode.NewValue; + }, true); } protected override void PrepareForUse() @@ -208,7 +305,11 @@ namespace osu.Game.Screens.SelectV2 difficultyText.Text = beatmap.DifficultyName; authorText.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); + cachedScratchText = null; + + resetManiaAnalysisDisplay(); computeStarRating(); + computeManiaAnalysis(); updateKeyCount(); } @@ -222,6 +323,107 @@ namespace osu.Game.Screens.SelectV2 return rulesetInstance.CreateIcon(); } + private static bool isPlaceholderAnalysisResult(ManiaBeatmapAnalysisResult result) + => result.AverageKps == 0 + && result.MaxKps == 0 + && (result.KpsList.Count) == 0 + && (result.ColumnCounts.Count) == 0 + && (result.HoldNoteCounts.Count) == 0 + && string.IsNullOrEmpty(result.ScratchText) + && result.XxySr == null; + + private void queueManiaUiUpdate((double averageKps, double maxKps, List kpsList) result, Dictionary? columnCounts, Dictionary? holdNoteCounts) + { + pendingKpsResult = result; + pendingColumnCounts = columnCounts; + pendingHoldNoteCounts = holdNoteCounts; + hasPendingUiUpdate = true; + + if (scheduledManiaUiUpdate != null) + return; + + scheduledManiaUiUpdate = Scheduler.AddDelayed(() => + { + scheduledManiaUiUpdate = null; + + if (!hasPendingUiUpdate) + return; + + hasPendingUiUpdate = false; + updateKPs(pendingKpsResult, pendingColumnCounts, pendingHoldNoteCounts); + }, mania_ui_update_throttle_ms, false); + } + + private void resetManiaAnalysisDisplay() + { + cachedScratchText = null; + displayXxySR.Current.Value = null; + + if (ruleset.Value.OnlineID == 3) + { + ezKpcDisplay.Show(); + displayXxySR.Show(); + } + else + { + ezKpcDisplay.Hide(); + displayXxySR.Hide(); + } + } + + private void updateKPs((double averageKps, double maxKps, List kpsList) result, Dictionary? columnCounts, Dictionary? holdNoteCounts) + { + if (Item == null) + return; + + // 滚动过程中会有大量不可见/刚离屏的面板仍收到分析回调。 + // 这些面板的 UI 更新会造成明显 GC 压力与 Draw FPS 下降,因此先缓存为 pending,等再次可见时再应用。 + if (Item.IsVisible != true) + { + pendingKpsResult = result; + pendingColumnCounts = columnCounts; + pendingHoldNoteCounts = holdNoteCounts; + hasPendingUiUpdate = true; + return; + } + + var (averageKps, maxKps, kpsList) = result; + + ezKpsDisplay.SetKps(averageKps, maxKps); + + // Update KPS graph with the KPS list + if (kpsList.Count > 0) + { + ezKpsGraph.SetValues(kpsList); + } + + if (columnCounts != null) + { + // 注意:分析结果里的 ColumnCounts 只包含“出现过的列”。 + // 当某个 mod 删除了某一列的所有 notes 时,这一列会缺失, + // 直接显示会导致列号错位(看起来像“没有更新”)。 + // 这里把字典补齐到 0..keyCount-1,缺失列填 0。 + int keyCount = getCachedKpcKeyCount(); + ensureNormalizedCounts(keyCount); + + for (int i = 0; i < keyCount; i++) + { + normalizedColumnCounts![i] = columnCounts.GetValueOrDefault(i); + normalizedHoldNoteCounts![i] = holdNoteCounts?.GetValueOrDefault(i) ?? 0; + } + + int countsHash = computeCountsHash(normalizedColumnCounts!, normalizedHoldNoteCounts!, keyCount); + var mode = ezKpcDisplay.CurrentKpcDisplayMode; + + if (countsHash != lastKpcCountsHash || mode != lastKpcMode) + { + lastKpcCountsHash = countsHash; + lastKpcMode = mode; + ezKpcDisplay.UpdateColumnCounts(normalizedColumnCounts!, normalizedHoldNoteCounts!); + } + } + } + protected override void FreeAfterUse() { base.FreeAfterUse(); @@ -230,6 +432,91 @@ namespace osu.Game.Screens.SelectV2 starDifficultyBindable = null; starDifficultyCancellationSource?.Cancel(); + maniaAnalysisCancellationSource?.Cancel(); + maniaAnalysisBindable = null; + cachedScratchText = null; + + scheduledManiaUiUpdate?.Cancel(); + scheduledManiaUiUpdate = null; + hasPendingUiUpdate = false; + pendingColumnCounts = null; + pendingHoldNoteCounts = null; + + displayXxySR.Current.Value = null; + + cachedKpcKeyCount = -1; + cachedKpcRulesetId = -1; + cachedKpcModsHash = 0; + normalizedColumnCounts = null; + normalizedHoldNoteCounts = null; + normalizedCountsKeyCount = 0; + + lastKpcCountsHash = 0; + lastKpcMode = default; + } + + private int getCachedKpcKeyCount() + { + Guid beatmapId = beatmap.ID; + int rulesetId = ruleset.Value.OnlineID; + int modsHash = computeModsHash(mods.Value); + + if (cachedKpcKeyCount >= 0 + && cachedKpcBeatmapId == beatmapId + && cachedKpcRulesetId == rulesetId + && cachedKpcModsHash == modsHash) + return cachedKpcKeyCount; + + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + cachedKpcKeyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); + cachedKpcBeatmapId = beatmapId; + cachedKpcRulesetId = rulesetId; + cachedKpcModsHash = modsHash; + return cachedKpcKeyCount; + } + + private void ensureNormalizedCounts(int keyCount) + { + if (normalizedColumnCounts != null && normalizedHoldNoteCounts != null && normalizedCountsKeyCount == keyCount) + return; + + normalizedCountsKeyCount = keyCount; + normalizedColumnCounts = new Dictionary(keyCount); + normalizedHoldNoteCounts = new Dictionary(keyCount); + + for (int i = 0; i < keyCount; i++) + { + normalizedColumnCounts[i] = 0; + normalizedHoldNoteCounts[i] = 0; + } + } + + private static int computeModsHash(IReadOnlyList mods) + { + unchecked + { + int hash = 17; + for (int i = 0; i < mods.Count; i++) + hash = hash * 31 + mods[i].GetHashCode(); + + return hash; + } + } + + private static int computeCountsHash(Dictionary columnCounts, Dictionary holdCounts, int keyCount) + { + unchecked + { + int hash = 17; + + for (int i = 0; i < keyCount; i++) + { + hash = hash * 31 + columnCounts.GetValueOrDefault(i); + hash = hash * 31 + holdCounts.GetValueOrDefault(i); + } + + return hash; + } } private void computeStarRating() @@ -248,6 +535,37 @@ namespace osu.Game.Screens.SelectV2 }, true); } + private void computeManiaAnalysis() + { + maniaAnalysisCancellationSource?.Cancel(); + maniaAnalysisCancellationSource = new CancellationTokenSource(); + + if (Item == null) + return; + + // Reset UI to avoid showing stale data from previous beatmap + // resetManiaAnalysisDisplay(); + + maniaAnalysisBindable = maniaAnalysisCache.GetBindableAnalysis(beatmap, maniaAnalysisCancellationSource.Token, computationDelay: SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE); + maniaAnalysisBindable.BindValueChanged(result => + { + // Don't treat placeholder analysis results as real updates. + if (!isPlaceholderAnalysisResult(result.NewValue)) + { + // Update cached scratch text even when it's an empty string, so the UI can reflect + // changes such as columns becoming empty. Schedule a key-count update so the + // displayed text refreshes immediately instead of waiting for a mods/ruleset change. + cachedScratchText = result.NewValue.ScratchText; + Schedule(updateKeyCount); + } + + queueManiaUiUpdate((result.NewValue.AverageKps, result.NewValue.MaxKps, result.NewValue.KpsList), result.NewValue.ColumnCounts, result.NewValue.HoldNoteCounts); + + if (result.NewValue.XxySr != null) + displayXxySR.Current.Value = result.NewValue.XxySr; + }, true); + } + protected override void Update() { base.Update(); @@ -256,6 +574,18 @@ namespace osu.Game.Screens.SelectV2 { starDifficultyCancellationSource?.Cancel(); starDifficultyCancellationSource = null; + + // 离屏时取消 mania 分析(其中包含 xxy_SR),避免后台为不可见项占用计算预算。 + maniaAnalysisCancellationSource?.Cancel(); + maniaAnalysisCancellationSource = null; + } + else + { + // 重新可见时再触发一次计算 + if (maniaAnalysisCancellationSource == null && Item != null) + { + computeManiaAnalysis(); + } } // Dirty hack to make sure we don't take up spacing in parent fill flow when not displaying a rank. @@ -294,7 +624,8 @@ namespace osu.Game.Screens.SelectV2 int keyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); keyCountText.Alpha = 1; - keyCountText.Text = $"[{keyCount}K] "; + keyCountText.Text = cachedScratchText ?? $"[{keyCount}K] "; + keyCountText.Colour = Colour4.LightPink.ToLinear(); } else keyCountText.Alpha = 0; diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index 5071fe4aaa..d679039054 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -5,8 +5,11 @@ using System; using System.Collections.Generic; using System.Threading; using osu.Framework.Allocation; +using osu.Framework.Logging; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; @@ -18,11 +21,14 @@ using osu.Game.Graphics; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.LAsEzExtensions.UserInterface; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.SelectV2 { @@ -30,6 +36,10 @@ namespace osu.Game.Screens.SelectV2 { public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f; + private const int mania_ui_update_throttle_ms = 100; + private const int background_load_delay_ms = 50; + private const int metadata_text_delay_ms = 30; + [Resolved] private IBindable ruleset { get; set; } = null!; @@ -45,15 +55,51 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private BeatmapManager beatmaps { get; set; } = null!; + [Resolved] + private OsuColour colours { get; set; } = null!; + [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + [Resolved] + private EzBeatmapManiaAnalysisCache maniaAnalysisCache { get; set; } = null!; + + private IBindable? maniaAnalysisBindable; + private CancellationTokenSource? maniaAnalysisCancellationSource; + private bool applyNextManiaUiUpdateImmediately; + private string? cachedScratchText; + private EzKpsDisplay ezKpsDisplay = null!; + private EzDisplayLineGraph ezKpsGraph = null!; + private EzKpcDisplay ezKpcDisplay = null!; + + private EzDisplayXxySR displayXxySR = null!; + private IBindable? starDifficultyBindable; private CancellationTokenSource? starDifficultyCancellationSource; + private int cachedKpcKeyCount = -1; + private Guid cachedKpcBeatmapId; + private int cachedKpcRulesetId = -1; + private int cachedKpcModsHash; + + private Dictionary? normalizedColumnCounts; + private Dictionary? normalizedHoldNoteCounts; + private int normalizedCountsKeyCount; + + private int lastKpcCountsHash; + private EzKpcDisplay.KpcDisplayMode lastKpcMode; + private PanelSetBackground beatmapBackground = null!; private ScheduledDelegate? scheduledBackgroundRetrieval; + private ScheduledDelegate? scheduledMetadataTextUpdate; + + private ScheduledDelegate? scheduledManiaUiUpdate; + private (double averageKps, double maxKps, List kpsList) pendingKpsResult; + private Dictionary? pendingColumnCounts; + private Dictionary? pendingHoldNoteCounts; + private bool hasPendingUiUpdate; + private OsuSpriteText titleText = null!; private OsuSpriteText artistText = null!; private PanelUpdateBeatmapButton updateButton = null!; @@ -173,7 +219,22 @@ namespace osu.Game.Screens.SelectV2 Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft - } + }, + ezKpsDisplay = new EzKpsDisplay + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + Empty(), + ezKpsGraph = new EzDisplayLineGraph + { + Size = new Vector2(300, 20), + LineColour = Color4.CornflowerBlue.Opacity(0.8f), + Blending = BlendingParameters.Mixture, + Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.CornflowerBlue), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, } }, new FillFlowContainer @@ -189,12 +250,23 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.CentreLeft, Scale = new Vector2(0.875f), }, + displayXxySR = new EzDisplayXxySR + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.875f), + }, spreadDisplay = new SpreadDisplay { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, Enabled = { BindTarget = Selected } - } + }, + ezKpcDisplay = new EzKpcDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, }, } } @@ -208,8 +280,23 @@ namespace osu.Game.Screens.SelectV2 { base.LoadComplete(); - ruleset.BindValueChanged(_ => updateKeyCount()); - mods.BindValueChanged(_ => updateKeyCount(), true); + ruleset.BindValueChanged(_ => + { + cachedScratchText = null; + applyNextManiaUiUpdateImmediately = true; + + computeManiaAnalysis(); + updateKeyCount(); + }); + + mods.BindValueChanged(_ => + { + cachedScratchText = null; + applyNextManiaUiUpdateImmediately = true; + + computeManiaAnalysis(); + updateKeyCount(); + }, true); Selected.BindValueChanged(s => Expanded.Value = s.NewValue, true); } @@ -220,6 +307,14 @@ namespace osu.Game.Screens.SelectV2 var beatmapSet = beatmap.BeatmapSet!; + // Background/texture uploads are a major draw FPS limiter during fast scrolling. + // Delay background retrieval and only load if still visible and not pooled/reused. + beatmapBackground.Beatmap = null; + scheduleBackgroundLoad(); + + // Delay high-variance metadata text assignment to reduce glyph/atlas churn during fast scrolling. + scheduledMetadataTextUpdate?.Cancel(); + scheduledMetadataTextUpdate = null; scheduledBackgroundRetrieval = Scheduler.AddDelayed(b => beatmapBackground.Beatmap = beatmaps.GetWorkingBeatmap(b), beatmap, 50); titleText.Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title); @@ -234,17 +329,199 @@ namespace osu.Game.Screens.SelectV2 difficultyText.Text = beatmap.DifficultyName; authorText.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); + cachedScratchText = null; + + resetManiaAnalysisDisplay(); + computeManiaAnalysis(); computeStarRating(); spreadDisplay.Beatmap.Value = beatmap; updateKeyCount(); } + private void scheduleBackgroundLoad() + { + if (Item == null) + return; + + // Only attempt to load backgrounds for currently visible panels. + if (Item.IsVisible != true) + return; + + if (scheduledBackgroundRetrieval != null) + return; + + Guid scheduledBeatmapId = beatmap.ID; + + scheduledBackgroundRetrieval = Scheduler.AddDelayed(() => + { + scheduledBackgroundRetrieval = null; + + if (Item == null) + return; + + if (Item.IsVisible != true) + return; + + // Guard against pooled reuse. + if (beatmap.ID != scheduledBeatmapId) + return; + + beatmapBackground.Beatmap = beatmaps.GetWorkingBeatmap(beatmap); + }, background_load_delay_ms, false); + } + + private void computeManiaAnalysis() + { + maniaAnalysisCancellationSource?.Cancel(); + maniaAnalysisCancellationSource = new CancellationTokenSource(); + + if (Item == null) + return; + + // Reset UI to avoid showing stale data from previous beatmap + // resetManiaAnalysisDisplay(); + + maniaAnalysisBindable = maniaAnalysisCache.GetBindableAnalysis(beatmap, maniaAnalysisCancellationSource.Token, computationDelay: SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE); + maniaAnalysisBindable.BindValueChanged(result => + { + if (!isPlaceholderAnalysisResult(result.NewValue)) + { + // Update cached scratch text even when empty to reflect columns becoming empty. + cachedScratchText = result.NewValue.ScratchText; + Schedule(updateKeyCount); + } + + queueManiaUiUpdate((result.NewValue.AverageKps, result.NewValue.MaxKps, result.NewValue.KpsList), result.NewValue.ColumnCounts, result.NewValue.HoldNoteCounts); + + displayXxySR.Current.Value = result.NewValue.XxySr; + }, true); + } + + private static bool isPlaceholderAnalysisResult(ManiaBeatmapAnalysisResult result) + => result.AverageKps == 0 + && result.MaxKps == 0 + && (result.KpsList.Count) == 0 + && (result.ColumnCounts.Count) == 0 + && (result.HoldNoteCounts.Count) == 0 + && string.IsNullOrEmpty(result.ScratchText) + && result.XxySr == null; + + private void queueManiaUiUpdate((double averageKps, double maxKps, List kpsList) result, Dictionary? columnCounts, Dictionary? holdNoteCounts) + { + // After a mod/ruleset change, apply the first incoming result immediately to avoid a visible blank window. + if (applyNextManiaUiUpdateImmediately && Item?.IsVisible == true) + { + applyNextManiaUiUpdateImmediately = false; + scheduledManiaUiUpdate?.Cancel(); + scheduledManiaUiUpdate = null; + hasPendingUiUpdate = false; + updateUI(result, columnCounts, holdNoteCounts); + return; + } + + pendingKpsResult = result; + pendingColumnCounts = columnCounts; + pendingHoldNoteCounts = holdNoteCounts; + hasPendingUiUpdate = true; + + // Coalesce multiple incoming analysis updates into a single UI update. + if (scheduledManiaUiUpdate != null) + return; + + scheduledManiaUiUpdate = Scheduler.AddDelayed(() => + { + scheduledManiaUiUpdate = null; + + if (!hasPendingUiUpdate) + return; + + hasPendingUiUpdate = false; + updateUI(pendingKpsResult, pendingColumnCounts, pendingHoldNoteCounts); + }, mania_ui_update_throttle_ms, false); + } + + private void resetManiaAnalysisDisplay() + { + cachedScratchText = null; + displayXxySR.Current.Value = null; + + if (ruleset.Value.OnlineID == 3) + { + ezKpcDisplay.Show(); + displayXxySR.Show(); + } + else + { + ezKpcDisplay.Hide(); + displayXxySR.Hide(); + } + } + + private void updateUI((double averageKps, double maxKps, List kpsList) result, Dictionary? columnCounts, Dictionary? holdNoteCounts) + { + if (Item == null) + return; + + // 滚动过程中会有大量不可见/刚离屏的面板仍收到分析回调。 + // 这些面板的 UI 更新会造成明显 GC 压力与 Draw FPS 下降,因此先缓存为 pending,等再次可见时再应用。 + if (Item.IsVisible != true) + { + pendingKpsResult = result; + pendingColumnCounts = columnCounts; + pendingHoldNoteCounts = holdNoteCounts; + hasPendingUiUpdate = true; + return; + } + + var (averageKps, maxKps, kpsList) = result; + + ezKpsDisplay.SetKps(averageKps, maxKps); + + // Update KPS graph with the KPS list + if (kpsList.Count > 0) + { + ezKpsGraph.SetValues(kpsList); + } + + if (columnCounts != null) + { + // 同 PanelBeatmap:补齐缺失列为 0,避免列号错位。 + int keyCount = getCachedKpcKeyCount(); + ensureNormalizedCounts(keyCount); + + for (int i = 0; i < keyCount; i++) + { + normalizedColumnCounts![i] = columnCounts.GetValueOrDefault(i); + normalizedHoldNoteCounts![i] = holdNoteCounts?.GetValueOrDefault(i) ?? 0; + } + + int countsHash = computeCountsHash(normalizedColumnCounts!, normalizedHoldNoteCounts!, keyCount); + var mode = ezKpcDisplay.CurrentKpcDisplayMode; + + if (countsHash != lastKpcCountsHash || mode != lastKpcMode) + { + lastKpcCountsHash = countsHash; + lastKpcMode = mode; + ezKpcDisplay.UpdateColumnCounts(normalizedColumnCounts!, normalizedHoldNoteCounts!); + } + } + } + protected override void FreeAfterUse() { base.FreeAfterUse(); scheduledBackgroundRetrieval?.Cancel(); scheduledBackgroundRetrieval = null; + + scheduledMetadataTextUpdate?.Cancel(); + scheduledMetadataTextUpdate = null; + + scheduledManiaUiUpdate?.Cancel(); + scheduledManiaUiUpdate = null; + hasPendingUiUpdate = false; + pendingColumnCounts = null; + pendingHoldNoteCounts = null; beatmapBackground.Beatmap = null; updateButton.BeatmapSet = null; localRank.Beatmap = null; @@ -252,10 +529,90 @@ namespace osu.Game.Screens.SelectV2 spreadDisplay.Beatmap.Value = null; starDifficultyCancellationSource?.Cancel(); + maniaAnalysisCancellationSource?.Cancel(); + maniaAnalysisBindable = null; + cachedScratchText = null; + + displayXxySR.Current.Value = null; + + cachedKpcKeyCount = -1; + cachedKpcRulesetId = -1; + cachedKpcModsHash = 0; + normalizedColumnCounts = null; + normalizedHoldNoteCounts = null; + normalizedCountsKeyCount = 0; + + lastKpcCountsHash = 0; + lastKpcMode = default; + } + + private int getCachedKpcKeyCount() + { + Guid beatmapId = beatmap.ID; + int rulesetId = ruleset.Value.OnlineID; + int modsHash = computeModsHash(mods.Value); + + if (cachedKpcKeyCount >= 0 + && cachedKpcBeatmapId == beatmapId + && cachedKpcRulesetId == rulesetId + && cachedKpcModsHash == modsHash) + return cachedKpcKeyCount; + + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + cachedKpcKeyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); + cachedKpcBeatmapId = beatmapId; + cachedKpcRulesetId = rulesetId; + cachedKpcModsHash = modsHash; + return cachedKpcKeyCount; + } + + private void ensureNormalizedCounts(int keyCount) + { + if (normalizedColumnCounts != null && normalizedHoldNoteCounts != null && normalizedCountsKeyCount == keyCount) + return; + + normalizedCountsKeyCount = keyCount; + normalizedColumnCounts = new Dictionary(keyCount); + normalizedHoldNoteCounts = new Dictionary(keyCount); + + for (int i = 0; i < keyCount; i++) + { + normalizedColumnCounts[i] = 0; + normalizedHoldNoteCounts[i] = 0; + } + } + + private static int computeModsHash(IReadOnlyList mods) + { + unchecked + { + int hash = 17; + for (int i = 0; i < mods.Count; i++) + hash = hash * 31 + mods[i].GetHashCode(); + + return hash; + } + } + + private static int computeCountsHash(Dictionary columnCounts, Dictionary holdCounts, int keyCount) + { + unchecked + { + int hash = 17; + + for (int i = 0; i < keyCount; i++) + { + hash = hash * 31 + columnCounts.GetValueOrDefault(i); + hash = hash * 31 + holdCounts.GetValueOrDefault(i); + } + + return hash; + } } private void computeStarRating() { + // Logger.Log($"[PanelBeatmapStandalone] computeStarRating called for beatmap {beatmap.OnlineID}/{beatmap.ID}", LoggingTarget.Runtime, LogLevel.Debug); starDifficultyCancellationSource?.Cancel(); starDifficultyCancellationSource = new CancellationTokenSource(); @@ -265,6 +622,7 @@ namespace osu.Game.Screens.SelectV2 starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE); starDifficultyBindable.BindValueChanged(starDifficulty => { + // Logger.Log($"[PanelBeatmapStandalone] starDifficulty changed for beatmap {beatmap.OnlineID}/{beatmap.ID} stars={starDifficulty.NewValue.Stars}", LoggingTarget.Runtime, LogLevel.Debug); starRatingDisplay.Current.Value = starDifficulty.NewValue; spreadDisplay.StarDifficulty.Value = starDifficulty.NewValue; }, true); @@ -276,8 +634,47 @@ namespace osu.Game.Screens.SelectV2 if (Item?.IsVisible != true) { + scheduledBackgroundRetrieval?.Cancel(); + scheduledBackgroundRetrieval = null; + + scheduledMetadataTextUpdate?.Cancel(); + scheduledMetadataTextUpdate = null; + starDifficultyCancellationSource?.Cancel(); starDifficultyCancellationSource = null; + + // 离屏时取消 mania 分析(其中包含 xxy_SR),避免后台为不可见项占用计算预算。 + maniaAnalysisCancellationSource?.Cancel(); + maniaAnalysisCancellationSource = null; + } + else + { + if (beatmapBackground.Beatmap == null) + scheduleBackgroundLoad(); + + // 重新可见时再触发一次绑定/计算。 + if (maniaAnalysisCancellationSource == null && Item != null) + { + // 离屏期间我们会 cancel 掉分析(避免浪费计算预算)。 + // 重新变为可见时,必须先清空旧显示值,否则会短暂显示上一次谱面的结果(表现为 xxySR 跳变)。 + resetManiaAnalysisDisplay(); + computeManiaAnalysis(); + } + + // 如果离屏期间收到过分析结果(或刚好在离屏时更新被跳过),这里补一次 UI 应用。 + if (hasPendingUiUpdate && scheduledManiaUiUpdate == null) + { + scheduledManiaUiUpdate = Scheduler.AddDelayed(() => + { + scheduledManiaUiUpdate = null; + + if (!hasPendingUiUpdate) + return; + + hasPendingUiUpdate = false; + updateUI(pendingKpsResult, pendingColumnCounts, pendingHoldNoteCounts); + }, 0, false); + } } // Dirty hack to make sure we don't take up spacing in parent fill flow when not displaying a rank. @@ -306,10 +703,13 @@ namespace osu.Game.Screens.SelectV2 int keyCount = legacyRuleset.GetKeyCount(beatmap, mods.Value); keyCountText.Alpha = 1; - keyCountText.Text = $"[{keyCount}K] "; + keyCountText.Text = cachedScratchText ?? $"[{keyCount}K] "; + keyCountText.Colour = Colour4.LightPink.ToLinear(); } else keyCountText.Alpha = 0; + + computeStarRating(); } public override MenuItem[] ContextMenuItems diff --git a/osu.Game/Screens/SelectV2/Panel_EzKpcDisplay.cs b/osu.Game/Screens/SelectV2/Panel_EzKpcDisplay.cs new file mode 100644 index 0000000000..44d6ca3267 --- /dev/null +++ b/osu.Game/Screens/SelectV2/Panel_EzKpcDisplay.cs @@ -0,0 +1,422 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Globalization; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class EzKpcDisplay : CompositeDrawable + { + /// + /// 显示模式枚举 + /// + public enum KpcDisplayMode + { + /// + /// 数字(默认,最高性能) + /// + Numbers, + + /// + /// 柱状图 + /// + BarChart + } + + private readonly FillFlowContainer columnNotesContainer; + private KpcDisplayMode currentKpcDisplayMode = KpcDisplayMode.Numbers; + private Dictionary? currentColumnCounts; + private Dictionary? currentHoldNoteCounts; + + private int currentColumnCount; + private readonly List numberEntries = new List(); + private readonly List barEntries = new List(); + + /// + /// 当前显示模式 + /// + public KpcDisplayMode CurrentKpcDisplayMode + { + get => currentKpcDisplayMode; + set + { + if (currentKpcDisplayMode == value) + return; + + currentKpcDisplayMode = value; + + // 如果有数据,立即重新渲染 + if (currentColumnCounts != null) + { + updateDisplay(currentColumnCounts, currentHoldNoteCounts); + } + } + } + + public EzKpcDisplay() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new CircularContainer + { + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.6f), + }, + new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Horizontal = 8f }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 3f), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] + { + new[] + { + new OsuSpriteText + { + Text = "[Notes]", + Font = OsuFont.GetFont(size: 14), + Colour = Colour4.GhostWhite, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + Empty(), + columnNotesContainer = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + } + } + } + }; + } + + /// + /// 更新列音符数量显示 + /// + /// 每列的音符数量 + /// 面条数量 + public void UpdateColumnCounts(Dictionary columnNoteCounts, Dictionary? holdNoteCounts = null) + { + currentColumnCounts = columnNoteCounts; + currentHoldNoteCounts = holdNoteCounts; + updateDisplay(columnNoteCounts, holdNoteCounts); + } + + private void updateDisplay(Dictionary columnNoteCounts, Dictionary? holdNoteCounts = null) + { + int columns = columnNoteCounts.Count; + + if (columns == 0) + { + currentColumnCount = 0; + columnNotesContainer.Clear(); + numberEntries.Clear(); + barEntries.Clear(); + return; + } + + switch (currentKpcDisplayMode) + { + case KpcDisplayMode.Numbers: + updateNumbersDisplay(columnNoteCounts, holdNoteCounts); + break; + + case KpcDisplayMode.BarChart: + updateBarChartDisplay(columnNoteCounts, holdNoteCounts); + break; + } + } + + private void rebuildForModeIfNeeded(int columns) + { + if (currentColumnCount == columns) + return; + + currentColumnCount = columns; + columnNotesContainer.Clear(); + numberEntries.Clear(); + barEntries.Clear(); + + switch (currentKpcDisplayMode) + { + case KpcDisplayMode.Numbers: + for (int i = 0; i < columns; i++) + { + var entry = new NumberColumnEntry(i); + numberEntries.Add(entry); + columnNotesContainer.Add(entry.Container); + } + + break; + + case KpcDisplayMode.BarChart: + for (int i = 0; i < columns; i++) + { + var entry = new BarChartColumnEntry(i); + barEntries.Add(entry); + columnNotesContainer.Add(entry.Container); + } + + break; + } + } + + private void updateNumbersDisplay(Dictionary columnNoteCounts, Dictionary? holdNoteCounts = null) + { + rebuildForModeIfNeeded(columnNoteCounts.Count); + + // Expect 0..N-1 keys (PanelBeatmap normalizes). Fall back to ordered keys if needed. + if (columnNoteCounts.Count == numberEntries.Count && columnNoteCounts.ContainsKey(0)) + { + for (int i = 0; i < numberEntries.Count; i++) + { + int total = columnNoteCounts.GetValueOrDefault(i); + int hold = holdNoteCounts?.GetValueOrDefault(i) ?? 0; + numberEntries[i].SetValues(total, hold); + } + } + else + { + int idx = 0; + + foreach (var kvp in columnNoteCounts.OrderBy(k => k.Key)) + { + if (idx >= numberEntries.Count) + break; + + int hold = holdNoteCounts?.GetValueOrDefault(kvp.Key) ?? 0; + numberEntries[idx].SetValues(kvp.Value, hold); + idx++; + } + } + } + + private void updateBarChartDisplay(Dictionary columnNoteCounts, Dictionary? holdNoteCounts = null) + { + rebuildForModeIfNeeded(columnNoteCounts.Count); + + int maxCount = 0; + + for (int i = 0; i < currentColumnCount; i++) + { + int total = columnNoteCounts.GetValueOrDefault(i); + int hold = holdNoteCounts?.GetValueOrDefault(i) ?? 0; + int sum = total + hold; + if (sum > maxCount) + maxCount = sum; + } + + if (maxCount == 0) + { + // Nothing to show. + for (int i = 0; i < barEntries.Count; i++) + barEntries[i].SetValues(0, 0, 1); + return; + } + + for (int i = 0; i < barEntries.Count; i++) + { + int total = columnNoteCounts.GetValueOrDefault(i); + int hold = holdNoteCounts?.GetValueOrDefault(i) ?? 0; + barEntries[i].SetValues(total, hold, maxCount); + } + } + + /// + /// 清空显示 + /// + public void Clear() + { + currentColumnCounts = null; + columnNotesContainer.Clear(); + numberEntries.Clear(); + barEntries.Clear(); + currentColumnCount = 0; + } + + private class NumberColumnEntry + { + public readonly FillFlowContainer Container; + private readonly OsuSpriteText valueText; + private readonly OsuSpriteText holdText; + + private int lastTotal = int.MinValue; + private int lastHold = int.MinValue; + + public NumberColumnEntry(int index) + { + Container = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = $"{index + 1}/", + Font = OsuFont.GetFont(size: 12), + Colour = Color4.Gray, + }, + valueText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14), + Colour = Color4.LightCoral, + }, + holdText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Colour = Color4.LightGoldenrodYellow.Darken(0.2f), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } + } + }; + } + + public void SetValues(int total, int hold) + { + if (lastTotal != total) + { + lastTotal = total; + valueText.Text = total.ToString(CultureInfo.InvariantCulture); + } + + if (lastHold != hold) + { + lastHold = hold; + holdText.Text = hold > 0 ? $"/{hold.ToString(CultureInfo.InvariantCulture)} " : " "; + } + } + } + + private class BarChartColumnEntry + { + private const float max_bar_height = 30f; + private const float bar_width = 20f; + private const float bar_spacing = 2f; + private static readonly Color4 hold_note_color = Color4Extensions.FromHex("#FFD39B"); + + public readonly Container Container; + private readonly Box regularBox; + private readonly Box holdBox; + private readonly OsuSpriteText valueText; + + private int lastTotalNotes = int.MinValue; + private int lastHoldNotes = int.MinValue; + private int lastMaxCount = int.MinValue; + + public BarChartColumnEntry(int index) + { + Container = new Container + { + Size = new Vector2(bar_width, max_bar_height + 20), + Margin = new MarginPadding { Right = bar_spacing }, + }; + + regularBox = new Box + { + RelativeSizeAxes = Axes.X, + Colour = Color4.LightCoral, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding { Bottom = 15 } + }; + + holdBox = new Box + { + RelativeSizeAxes = Axes.X, + Colour = hold_note_color, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }; + + valueText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 10), + Colour = Color4.White, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }; + + Container.Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.X, + Height = max_bar_height, + Colour = Color4.Gray.Opacity(0.2f), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding { Bottom = 15 } + }, + regularBox, + holdBox, + new OsuSpriteText + { + Text = (index + 1).ToString(), + Font = OsuFont.GetFont(size: 12), + Colour = Color4.Gray, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding { Bottom = 2 } + }, + valueText, + }; + } + + public void SetValues(int totalNotes, int holdNotes, int maxCount) + { + // Avoid redundant work (and string allocations) when values are unchanged. + if (lastTotalNotes == totalNotes && lastHoldNotes == holdNotes && lastMaxCount == maxCount) + return; + + lastTotalNotes = totalNotes; + lastHoldNotes = holdNotes; + lastMaxCount = maxCount; + + int regularNotes = totalNotes - holdNotes; + + float totalHeight = maxCount > 0 ? (float)totalNotes / maxCount * max_bar_height : 0; + float regularHeight = maxCount > 0 ? (float)regularNotes / maxCount * max_bar_height : 0; + + regularBox.Height = regularHeight; + + holdBox.Height = totalHeight - regularHeight; + holdBox.Margin = new MarginPadding { Bottom = 15 + regularHeight }; + + valueText.Text = holdNotes > 0 + ? $"{totalNotes.ToString(CultureInfo.InvariantCulture)}({holdNotes.ToString(CultureInfo.InvariantCulture)})" + : totalNotes.ToString(CultureInfo.InvariantCulture); + valueText.Y = -(totalHeight + 17); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/Panel_EzKpsDisplay.cs b/osu.Game/Screens/SelectV2/Panel_EzKpsDisplay.cs new file mode 100644 index 0000000000..97058c4bf2 --- /dev/null +++ b/osu.Game/Screens/SelectV2/Panel_EzKpsDisplay.cs @@ -0,0 +1,214 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.LAsEzExtensions; +using osu.Game.LAsEzExtensions.Analysis; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class EzKpsDisplay : CompositeDrawable + { + private readonly OsuSpriteText kpsText; + // private readonly LineGraph kpsGraph; + + public static bool KpsCacheEnabled = true; + + private const int max_kps_cache_entries = 24; + private readonly Dictionary kpsList)> kpsCache = new Dictionary)>(); + private readonly Queue kpsCacheInsertionOrder = new Queue(); + private CancellationTokenSource? calculationCancellationSource; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + public event Action>? ColumnCountsUpdated; + public event Action? BeatmapUpdated; + + public EzKpsDisplay() + { + AutoSizeAxes = Axes.Both; + + InternalChild = kpsText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Colour = Color4.CornflowerBlue, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }; + + // 如果需要图表显示,可以取消注释 + // InternalChildren = new Drawable[] + // { + // kpsText = new OsuSpriteText + // { + // Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + // Colour = Color4.CornflowerBlue, + // Anchor = Anchor.CentreLeft, + // Origin = Anchor.CentreLeft + // }, + // kpsGraph = new LineGraph + // { + // Size = new Vector2(300, 20), + // Colour = OsuColour.Gray(0.25f), + // Anchor = Anchor.CentreLeft, + // Origin = Anchor.CentreLeft, + // Margin = new MarginPadding { Left = 100 } + // }, + // }; + } + + /// + /// 异步计算并显示KPS信息 + /// + /// 谱面信息 + /// 规则集 + /// 模组列表 + public void UpdateKpsAsync(BeatmapInfo beatmapInfo, RulesetInfo ruleset, IReadOnlyList? mods) + { + if (ruleset.OnlineID != 3) // 只在Mania模式下显示 + { + Hide(); + return; + } + + Show(); + + // 取消之前的计算 + calculationCancellationSource?.Cancel(); + calculationCancellationSource = new CancellationTokenSource(); + var cancellationToken = calculationCancellationSource.Token; + + // 生成缓存键 + string cacheKey = mods == null + ? $"{beatmapInfo.Hash}" + : ManiaBeatmapAnalysisCache.CreateCacheKey(beatmapInfo, ruleset, mods); + + // 检查缓存(可开关,便于对比测试) + if (KpsCacheEnabled && kpsCache.TryGetValue(cacheKey, out var cachedResult)) + { + updateUI(cachedResult, null, null); + return; + } + + // 显示计算中状态 + kpsText.Text = " KPS: calculating..."; + + // 异步计算 + Task.Run(() => + { + try + { + var workingBeatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo); + var playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset, mods, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + // 使用优化后的计算器一次性获取所有数据 + var (averageKps, maxKps, kpsList, columnCounts, holdNoteCounts) = OptimizedBeatmapCalculator.GetAllDataOptimized(playableBeatmap); + var kpsResult = (averageKps, maxKps, kpsList); + + cancellationToken.ThrowIfCancellationRequested(); + + // 缓存结果 + if (KpsCacheEnabled) + { + kpsCache[cacheKey] = kpsResult; + kpsCacheInsertionOrder.Enqueue(cacheKey); + + while (kpsCache.Count > max_kps_cache_entries && kpsCacheInsertionOrder.Count > 0) + { + string oldest = kpsCacheInsertionOrder.Dequeue(); + kpsCache.Remove(oldest); + } + } + + // 在UI线程中更新界面 + Schedule(() => + { + if (!cancellationToken.IsCancellationRequested) + { + updateUI(kpsResult, columnCounts, playableBeatmap); + } + }); + } + catch (OperationCanceledException) + { + // 计算被取消,忽略 + } + catch (Exception) + { + // 计算出错,显示默认值 + Schedule(() => + { + if (!cancellationToken.IsCancellationRequested) + { + updateUI((0, 0, new List()), null, null); + } + }); + } + }, cancellationToken); + } + + private void updateUI((double averageKps, double maxKps, List kpsList) kpsResult, + Dictionary? columnCounts, + IBeatmap? beatmap) + { + var (averageKps, maxKps, _) = kpsResult; + + // 更新KPS文本 + kpsText.Text = averageKps > 0 ? $" KPS: {averageKps:F1} ({maxKps:F1} Max)" : " KPS: calculating..."; + + // 更新图表(如果启用) + // kpsGraph.Values = kpsList.Count > 0 ? kpsList.Select(kps => (float)kps).ToArray() : new[] { 0f }; + + // 通知外部组件列数据已更新 + if (columnCounts != null) + { + ColumnCountsUpdated?.Invoke(columnCounts); + } + + // 通知外部组件beatmap已更新 + BeatmapUpdated?.Invoke(beatmap); + } + + /// + /// 设置KPS显示值 + /// + /// 平均KPS + /// 最大KPS + public void SetKps(double averageKps, double maxKps) + { + kpsText.Text = averageKps > 0 ? $" KPS: {averageKps:F1} ({maxKps:F1} Max)" : " KPS: calculating..."; + } + + /// + /// 清空显示并取消计算 + /// + public void Clear() + { + calculationCancellationSource?.Cancel(); + kpsText.Text = string.Empty; + Hide(); + } + + protected override void Dispose(bool isDisposing) + { + calculationCancellationSource?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index ecf8b3301b..346df39543 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -37,6 +37,8 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; +using osu.Game.LAsEzExtensions.Configuration; +using osu.Game.LAsEzExtensions.Select; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -162,11 +164,13 @@ namespace osu.Game.Screens.SelectV2 private Bindable configBackgroundBlur = null!; private Bindable showConvertedBeatmaps = null!; + private EzPreviewTrackManager? ezPreviewManager; + private Bindable keySoundPreview = null!; private IDisposable? modSelectOverlayRegistration; [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuConfigManager config) + private void load(AudioManager audio, OsuConfigManager config, Ez2ConfigManager ezConfig) { errorSample = audio.Samples.Get(@"UI/generic-error"); @@ -314,6 +318,7 @@ namespace osu.Game.Screens.SelectV2 updateBackgroundDim(); }); + keySoundPreview = ezConfig.GetBindable(Ez2Setting.KeySoundPreview); showConvertedBeatmaps = config.GetBindable(OsuSetting.ShowConvertedBeatmaps); } @@ -330,6 +335,22 @@ namespace osu.Game.Screens.SelectV2 queueBeatmapSelection(groupedBeatmaps.First(bug => bug.Beatmap.Equals(recommendedBeatmap))); } + private void updateLoopModEnabled() + { + try + { + // 当被选中的 mods 中包含可提供循环时间范围的 mod 时(ILoopTimeRangeMod),启用 DuplicateVirtualTrack + bool hasLoopMod = Mods.Value.OfType().Any(); + DuplicateVirtualTrack.Enabled = hasLoopMod; + } + catch (Exception ex) + { + Logger.Log($"SongSelect: updateLoopModEnabled error: {ex}", LoggingTarget.Runtime); + // 若出现异常,默认禁用以安全回退 + DuplicateVirtualTrack.Enabled = false; + } + } + /// /// Called when a selection is made to progress away from the song select screen. /// @@ -378,6 +399,9 @@ namespace osu.Game.Screens.SelectV2 inputManager = GetContainingInputManager()!; + // 仅设置允许标志;具体创建与销毁由 onArrivingAtScreen/onLeavingScreen 控制,避免在非当前 screen 启动预览。 + keySoundPreview.BindValueChanged(v => EzPreviewTrackManager.Enabled = v.NewValue, true); + filterControl.CriteriaChanged += criteriaChanged; modSelectOverlay.State.BindValueChanged(v => @@ -389,6 +413,9 @@ namespace osu.Game.Screens.SelectV2 logo?.FadeTo(v.NewValue == Visibility.Visible ? 0f : 1f, 200, Easing.OutQuint); }); + // 当 mod 变化时,只有在存在 Loop 类型的 mod 时才开启 DuplicateVirtualTrack 行为,避免状态混乱。 + Mods.BindValueChanged(_ => updateLoopModEnabled(), true); + Beatmap.BindValueChanged(_ => { if (!this.IsCurrentScreen()) @@ -523,19 +550,80 @@ namespace osu.Game.Screens.SelectV2 music.TrackChanged += ensureTrackLooping; } + private void createDuplicatePreview(IPreviewOverrideProvider provider, PreviewOverrideSettings overrides, IWorkingBeatmap beatmap) + { + removePreviewManager(); + + var duplicate = new DuplicateVirtualTrack + { + OverrideProvider = provider, + PendingOverrides = overrides + }; + ezPreviewManager = duplicate; + AddInternal(ezPreviewManager); + ezPreviewManager.StartPreview(beatmap); + } + + private void createEzPreview() + { + ezPreviewManager = new EzPreviewTrackManager(); + AddInternal(ezPreviewManager); + } + + private void removePreviewManager() + { + if (ezPreviewManager != null) + { + ezPreviewManager.StopPreview(); + RemoveInternal(ezPreviewManager, true); + } + } + private void endLooping() { // may be called multiple times during screen exit process. if (!isHandlingLooping) return; + removePreviewManager(); + music.CurrentTrack.Looping = isHandlingLooping = false; music.TrackChanged -= ensureTrackLooping; } private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) - => beatmap.PrepareTrackForPreview(true); + { + // 优化:查找第一个对该 beatmap 返回非空 overrides 的 provider(避免第一个 provider 无 overrides 时错过后续 provider) + var providerWithOverrides = Mods.Value.OfType() + .Select(p => (provider: p, overrides: p.GetPreviewOverrides(beatmap))) + .FirstOrDefault(x => x.overrides != null); + + if (providerWithOverrides.provider != null && providerWithOverrides.overrides != null) + { + if (!this.IsCurrentScreen()) return; + + createDuplicatePreview(providerWithOverrides.provider, providerWithOverrides.overrides, beatmap); + } + else if (keySoundPreview.Value && EzPreviewTrackManager.Enabled) + { + if (!this.IsCurrentScreen()) + return; + + createEzPreview(); + ezPreviewManager?.StartPreview(Beatmap.Value); + } + else + { + removePreviewManager(); + beatmap.PrepareTrackForPreview(true); + + // + // // restore default preview on the global music controller + // beatmaps.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo).PrepareTrackForPreview(true); + // ensurePlayingSelected(); + } + } #endregion @@ -743,6 +831,13 @@ namespace osu.Game.Screens.SelectV2 ensurePlayingSelected(); updateBackgroundDim(); fetchOnlineInfo(force: true); + + // Create simple ez preview on arrival if the user setting allows it and manager is enabled. + if (keySoundPreview.Value && EzPreviewTrackManager.Enabled) + { + createEzPreview(); + ezPreviewManager?.StartPreview(Beatmap.Value); + } } private void onLeavingScreen() diff --git a/osu.Game/Skinning/Ez2Skin.cs b/osu.Game/Skinning/Ez2Skin.cs new file mode 100644 index 0000000000..738bd61c7d --- /dev/null +++ b/osu.Game/Skinning/Ez2Skin.cs @@ -0,0 +1,253 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.JudgementCounter; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class Ez2Skin : Skin + { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.EZ2_SKIN, + Name = "LA's \"EzStyle\" Circle(2025)", + Creator = "SK_la", + Protected = true, + InstantiationInfo = typeof(Ez2Skin).GetInvariantInstantiationInfo() + }; + + protected readonly IStorageResourceProvider Resources; + + public Ez2Skin(IStorageResourceProvider resources) + : this(CreateInfo(), resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public Ez2Skin(SkinInfo skin, IStorageResourceProvider resources) + : base( + skin, + resources + ) + { + Resources = resources; + } + + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); + + public override ISample? GetSample(ISampleInfo sampleInfo) + { + return Resources.AudioManager?.Samples.Get("Gameplay/Ez/hit.wav"); + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + // Temporary until default skin has a valid hit lighting. + if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); + + switch (lookup) + { + case GlobalSkinnableContainerLookup containerLookup: + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.SongSelect: + var songSelectComponents = new DefaultSkinComponentsContainer(c => + { + var dim = c.OfType().FirstOrDefault(); + + if (dim != null) + { + dim.Anchor = Anchor.BottomCentre; + dim.Origin = Anchor.Centre; + dim.Position = new Vector2(-80, -150); + } + }) + { + // Children = new Drawable[] + // { + // new LAsSkinCom6DimPanel(), + // } + }; + + return songSelectComponents; + + case GlobalSkinnableContainers.MainHUDComponents: + + var mainHUDComponents = new DefaultSkinComponentsContainer(container => + { + var health = container.OfType().FirstOrDefault(); + var score = container.OfType().FirstOrDefault(); + var acc = container.OfType().FirstOrDefault(); + var pps = container.OfType().FirstOrDefault(); + var songProgress = container.OfType().FirstOrDefault(); + + const float x_offset = 20; + + if (health != null) + { + health.Anchor = Anchor.BottomLeft; + health.Origin = Anchor.CentreLeft; + // health.BypassAutoSizeAxes = Axes.Y; + health.Width = 0.5f; + // health.BarHeight.Value = 0f; + // health.Height = 0.4f; + health.Rotation = -90; + health.Position = new Vector2(0, 0); + + if (score != null) + { + score.Anchor = Anchor.TopLeft; + score.Origin = Anchor.TopLeft; + score.Position = new Vector2(x_offset, 20); + score.ShowLabel.Value = false; + // score.FontNameDropdown = + } + + if (acc != null) + { + acc.Position = new Vector2(-x_offset, 20); + acc.Anchor = Anchor.TopRight; + acc.Origin = Anchor.TopRight; + } + + if (pps != null && acc != null) + { + pps.Position = new Vector2(acc.X, acc.Y + acc.DrawHeight + 10); + pps.Anchor = Anchor.TopRight; + pps.Origin = Anchor.TopRight; + pps.Scale = new Vector2(0.8f); + } + + if (songProgress != null) + { + const float padding = 10; + songProgress.Position = new Vector2(0, -padding); + songProgress.Scale = new Vector2(0.9f, 1); + } + + var attributeTexts = container.OfType().ToArray(); + + if (attributeTexts.Length >= 4) + { + var attributeText = attributeTexts[0]; + var attributeText2 = attributeTexts[1]; + var attributeText3 = attributeTexts[2]; + var attributeText4 = attributeTexts[3]; + + if (pps != null) + { + attributeText.Anchor = Anchor.TopRight; + attributeText.Origin = Anchor.TopRight; + attributeText.Position = new Vector2(-x_offset, pps.Y + pps.DrawHeight * pps.Scale.Y + 10); + attributeText.Scale = new Vector2(0.65f); + attributeText.Attribute.Value = BeatmapAttribute.StarRating; + } + + attributeText2.Anchor = Anchor.TopRight; + attributeText2.Origin = Anchor.TopRight; + attributeText2.Position = new Vector2(-x_offset, attributeText.Y + attributeText.DrawHeight * attributeText.Scale.Y + 10); + attributeText2.Scale = new Vector2(0.65f); + attributeText2.Attribute.Value = BeatmapAttribute.DifficultyName; + attributeText2.Template.Value = "{Value}"; + + if (score != null) + { + attributeText3.Anchor = Anchor.TopLeft; + attributeText3.Origin = Anchor.TopLeft; + attributeText3.Scale = new Vector2(0.65f); + attributeText3.Position = new Vector2(x_offset, score.Y + score.DrawHeight * score.Scale.Y + 10); + attributeText3.Attribute.Value = BeatmapAttribute.Artist; + } + + attributeText4.Anchor = Anchor.TopLeft; + attributeText4.Origin = Anchor.TopLeft; + attributeText4.Scale = new Vector2(0.65f); + attributeText4.Position = new Vector2(x_offset, attributeText3.Y + attributeText3.DrawHeight * attributeText3.Scale.Y + 10); + attributeText4.Attribute.Value = BeatmapAttribute.Title; + } + } + }) + { + Children = new Drawable[] + { + new EzComScoreCounter(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + + new DefaultHealthDisplay(), + new ArgonAccuracyCounter + { + WireframeOpacity = { Value = 0 }, + }, + new ArgonPerformancePointsCounter + { + WireframeOpacity = { Value = 0 }, + }, + new ArgonSongProgress(), + new JudgementCounterDisplay + { + FillMode = FillMode.Fill, + FlowDirection = { Value = Direction.Vertical }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Position = new Vector2(20, 0), + }, + } + }; + + return mainHUDComponents; + } + + return null; + } + + return base.GetDrawableComponent(lookup); + } + + public override IBindable? GetConfig(TLookup lookup) + { + switch (lookup) + { + case GlobalSkinColours global: + switch (global) + { + case GlobalSkinColours.ComboColours: + { + LogLookupDebug(this, lookup, LookupDebugType.Hit); + return SkinUtils.As(new Bindable?>(Configuration.ComboColours)); + } + } + + break; + + case SkinComboColourLookup comboColour: + LogLookupDebug(this, lookup, LookupDebugType.Hit); + return SkinUtils.As(new Bindable(getComboColour(Configuration, comboColour.ColourIndex))); + } + + LogLookupDebug(this, lookup, LookupDebugType.Miss); + return null; + } + + private static Color4 getComboColour(IHasComboColours source, int colourIndex) + => source.ComboColours![colourIndex % source.ComboColours.Count]; + } +} diff --git a/osu.Game/Skinning/EzStyleProSkin.cs b/osu.Game/Skinning/EzStyleProSkin.cs new file mode 100644 index 0000000000..3ab24aac7b --- /dev/null +++ b/osu.Game/Skinning/EzStyleProSkin.cs @@ -0,0 +1,264 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.LAsEzExtensions.HUD; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.JudgementCounter; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + [Cached] + public class EzStyleProSkin : Skin + { + public const int EZ_STYLE_PRO_SKIN_ID = 999; + + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.EZ_STYLE_PRO_SKIN, + Name = "LA's \"Ez Style Pro\" Circle(2025)", + Creator = "SK_la", + Protected = true, + InstantiationInfo = typeof(EzStyleProSkin).GetInvariantInstantiationInfo() + }; + + protected readonly IStorageResourceProvider Resources; + + public EzStyleProSkin(IStorageResourceProvider resources) + : this(CreateInfo(), resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public EzStyleProSkin(SkinInfo skin, IStorageResourceProvider resources) + : base( + skin, + resources + ) + { + Resources = resources; + } + + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); + + public override ISample? GetSample(ISampleInfo sampleInfo) + { + foreach (string lookup in sampleInfo.LookupNames) + { + var sample = Samples?.Get(lookup) + ?? Resources.AudioManager?.Samples.Get(lookup.Replace(@"Gameplay/", @"Gameplay/Ez/")) + ?? Resources.AudioManager?.Samples.Get(lookup); + + if (sample != null) + return sample; + } + + return null; + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + // Temporary until default skin has a valid hit lighting. + if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); + + switch (lookup) + { + case GlobalSkinnableContainerLookup containerLookup: + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.SongSelect: + var songSelectComponents = new DefaultSkinComponentsContainer(c => + { + var dim = c.OfType().FirstOrDefault(); + + if (dim != null) + { + dim.Anchor = Anchor.BottomCentre; + dim.Origin = Anchor.Centre; + dim.Position = new Vector2(-80, -150); + } + }) + { + // Children = new Drawable[] + // { + // new LAsSkinCom6DimPanel(), + // } + }; + + return songSelectComponents; + + case GlobalSkinnableContainers.MainHUDComponents: + + var mainHUDComponents = new DefaultSkinComponentsContainer(container => + { + var health = container.OfType().FirstOrDefault(); + var score = container.OfType().FirstOrDefault(); + var acc = container.OfType().FirstOrDefault(); + var pps = container.OfType().FirstOrDefault(); + var songProgress = container.OfType().FirstOrDefault(); + + const float x_offset = 20; + + if (health != null) + { + health.Anchor = Anchor.BottomLeft; + health.Origin = Anchor.CentreLeft; + // health.BypassAutoSizeAxes = Axes.Y; + health.Width = 0.5f; + // health.BarHeight.Value = 0f; + // health.Height = 0.4f; + health.Rotation = -90; + health.Position = new Vector2(0, 0); + + if (score != null) + { + score.Anchor = Anchor.TopLeft; + score.Origin = Anchor.TopLeft; + score.Position = new Vector2(x_offset, 20); + score.ShowLabel.Value = false; + // score.FontNameDropdown = + } + + if (acc != null) + { + acc.Position = new Vector2(-x_offset, 20); + acc.Anchor = Anchor.TopRight; + acc.Origin = Anchor.TopRight; + } + + if (pps != null && acc != null) + { + pps.Position = new Vector2(acc.X, acc.Y + acc.DrawHeight + 10); + pps.Anchor = Anchor.TopRight; + pps.Origin = Anchor.TopRight; + pps.Scale = new Vector2(0.8f); + } + + if (songProgress != null) + { + const float padding = 10; + songProgress.Position = new Vector2(0, -padding); + songProgress.Scale = new Vector2(0.9f, 1); + } + + var attributeTexts = container.OfType().ToArray(); + + if (attributeTexts.Length >= 4) + { + var attributeText = attributeTexts[0]; + var attributeText2 = attributeTexts[1]; + var attributeText3 = attributeTexts[2]; + var attributeText4 = attributeTexts[3]; + + if (pps != null) + { + attributeText.Anchor = Anchor.TopRight; + attributeText.Origin = Anchor.TopRight; + attributeText.Position = new Vector2(-x_offset, pps.Y + pps.DrawHeight * pps.Scale.Y + 10); + attributeText.Scale = new Vector2(0.65f); + attributeText.Attribute.Value = BeatmapAttribute.StarRating; + } + + attributeText2.Anchor = Anchor.TopRight; + attributeText2.Origin = Anchor.TopRight; + attributeText2.Position = new Vector2(-x_offset, attributeText.Y + attributeText.DrawHeight * attributeText.Scale.Y + 10); + attributeText2.Scale = new Vector2(0.65f); + attributeText2.Attribute.Value = BeatmapAttribute.DifficultyName; + attributeText2.Template.Value = "{Value}"; + + if (score != null) + { + attributeText3.Anchor = Anchor.TopLeft; + attributeText3.Origin = Anchor.TopLeft; + attributeText3.Scale = new Vector2(0.65f); + attributeText3.Position = new Vector2(x_offset, score.Y + score.DrawHeight * score.Scale.Y + 10); + attributeText3.Attribute.Value = BeatmapAttribute.Artist; + } + + attributeText4.Anchor = Anchor.TopLeft; + attributeText4.Origin = Anchor.TopLeft; + attributeText4.Scale = new Vector2(0.65f); + attributeText4.Position = new Vector2(x_offset, attributeText3.Y + attributeText3.DrawHeight * attributeText3.Scale.Y + 10); + attributeText4.Attribute.Value = BeatmapAttribute.Title; + } + } + }) + { + Children = new Drawable[] + { + new EzComScoreCounter(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + + new DefaultHealthDisplay(), + new EzHUDAccuracyCounter(), + new ArgonPerformancePointsCounter + { + WireframeOpacity = { Value = 0 }, + }, + new ArgonSongProgress(), + new JudgementCounterDisplay + { + FillMode = FillMode.Fill, + FlowDirection = { Value = Direction.Vertical }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Position = new Vector2(20, 0), + }, + } + }; + + return mainHUDComponents; + } + + return null; + } + + return base.GetDrawableComponent(lookup); + } + + public override IBindable? GetConfig(TLookup lookup) + { + switch (lookup) + { + case GlobalSkinColours global: + switch (global) + { + case GlobalSkinColours.ComboColours: + { + LogLookupDebug(this, lookup, LookupDebugType.Hit); + return SkinUtils.As(new Bindable?>(Configuration.ComboColours)); + } + } + + break; + + case SkinComboColourLookup comboColour: + LogLookupDebug(this, lookup, LookupDebugType.Hit); + return SkinUtils.As(new Bindable(getComboColour(Configuration, comboColour.ColourIndex))); + } + + LogLookupDebug(this, lookup, LookupDebugType.Miss); + return null; + } + + private static Color4 getComboColour(IHasComboColours source, int colourIndex) + => source.ComboColours![colourIndex % source.ComboColours.Count]; + } +} diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index 76c2c4d7ec..e47ac464dc 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -125,6 +125,17 @@ namespace osu.Game.Skinning activeChannel.Looping = Looping; activeChannel.Play(); + // Framework-level latency instrumentation: best-effort playback event routing. + try + { + if (osu.Framework.Audio.EzLatency.EzLatencyManager.GLOBAL.Enabled.Value) + osu.Framework.Audio.EzLatency.EzLatencyManager.GLOBAL.RecordPlaybackEvent(); + } + catch (Exception ex) + { + osu.Framework.Logging.Logger.Log($"PoolableSkinnableSample: failed to record playback event: {ex.Message}", osu.Framework.Logging.LoggingTarget.Runtime, osu.Framework.Logging.LogLevel.Debug); + } + Played = true; } diff --git a/osu.Game/Skinning/SbISkin.cs b/osu.Game/Skinning/SbISkin.cs new file mode 100644 index 0000000000..7da3973ae7 --- /dev/null +++ b/osu.Game/Skinning/SbISkin.cs @@ -0,0 +1,254 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.JudgementCounter; +using osu.Game.Skinning.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class SbISkin : Skin + { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.SBI_SKIN, + Name = "LA's \"StrongBox \" for arisu(2025)", + Creator = "SK_la", + Protected = true, + InstantiationInfo = typeof(SbISkin).GetInvariantInstantiationInfo() + }; + + protected readonly IStorageResourceProvider Resources; + + public SbISkin(IStorageResourceProvider resources) + : this(CreateInfo(), resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public SbISkin(SkinInfo skin, IStorageResourceProvider resources) + : base( + skin, + resources + ) + { + Resources = resources; + } + + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); + + public override ISample? GetSample(ISampleInfo sampleInfo) + { + var sample = Samples?.Get("Gameplay/Ez/nil.wav"); + return sample; + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + // Temporary until default skin has a valid hit lighting. + if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); + + switch (lookup) + { + case GlobalSkinnableContainerLookup containerLookup: + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.SongSelect: + var songSelectComponents = new DefaultSkinComponentsContainer(c => + { + // var dim = c.OfType().FirstOrDefault(); + // + // if (dim != null) + // { + // dim.Anchor = Anchor.Centre; + // dim.Origin = Anchor.Centre; + // } + }) + { + // Children = new Drawable[] + // { + // new LAsSkinCom6DimPanel(), + // } + }; + + return songSelectComponents; + + case GlobalSkinnableContainers.MainHUDComponents: + + var mainHUDComponents = new DefaultSkinComponentsContainer(container => + { + var health = container.OfType().FirstOrDefault(); + var score = container.OfType().FirstOrDefault(); + var accuracy = container.OfType().FirstOrDefault(); + var performancePoints = container.OfType().FirstOrDefault(); + var songProgress = container.OfType().FirstOrDefault(); + + const float x_offset = 20; + + if (health != null) + { + health.Anchor = Anchor.BottomLeft; + health.Origin = Anchor.CentreLeft; + // health.RelativeSizeAxes = Axes.Y; + health.Width = 0.5f; + // health.BarHeight.Value = 0f; + // health.Height = 0.4f; + health.Rotation = -90; + health.Position = new Vector2(0, 0); + + if (score != null) + { + score.Origin = Anchor.TopLeft; + score.Position = new Vector2(x_offset, 20); + } + + if (accuracy != null) + { + accuracy.Position = new Vector2(-x_offset, 20); + accuracy.Anchor = Anchor.TopRight; + accuracy.Origin = Anchor.TopRight; + } + + if (performancePoints != null && accuracy != null) + { + performancePoints.Position = new Vector2(accuracy.X, accuracy.Y + accuracy.DrawHeight + 10); + performancePoints.Anchor = Anchor.TopRight; + performancePoints.Origin = Anchor.TopRight; + performancePoints.Scale = new Vector2(0.8f); + } + + if (songProgress != null) + { + const float padding = 10; + songProgress.Position = new Vector2(0, -padding); + songProgress.Scale = new Vector2(0.9f, 1); + } + + var attributeTexts = container.OfType().ToArray(); + + if (attributeTexts.Length >= 4) + { + var attributeText = attributeTexts[0]; + var attributeText2 = attributeTexts[1]; + var attributeText3 = attributeTexts[2]; + var attributeText4 = attributeTexts[3]; + + if (performancePoints != null) + { + attributeText.Anchor = Anchor.TopRight; + attributeText.Origin = Anchor.TopRight; + attributeText.Position = new Vector2(-x_offset, performancePoints.Y + performancePoints.DrawHeight * 0.8f + 10); + attributeText.Scale = new Vector2(0.65f); + attributeText.Attribute.Value = BeatmapAttribute.StarRating; + } + + attributeText2.Anchor = Anchor.TopRight; + attributeText2.Origin = Anchor.TopRight; + attributeText2.Position = new Vector2(-x_offset, attributeText.Y + attributeText.DrawHeight * 0.65f + 10); + attributeText2.Scale = new Vector2(0.65f); + attributeText2.Attribute.Value = BeatmapAttribute.DifficultyName; + attributeText2.Template.Value = "{Value}"; + + if (score != null) + { + attributeText3.Anchor = Anchor.TopLeft; + attributeText3.Origin = Anchor.TopLeft; + attributeText3.Scale = new Vector2(0.65f); + attributeText3.Position = new Vector2(x_offset, score.Y + score.DrawHeight + 10); + attributeText3.Attribute.Value = BeatmapAttribute.Artist; + } + + attributeText4.Anchor = Anchor.TopLeft; + attributeText4.Origin = Anchor.TopLeft; + attributeText4.Scale = new Vector2(0.65f); + attributeText4.Position = new Vector2(x_offset, attributeText3.Y + attributeText3.DrawHeight * 0.65f + 10); + attributeText4.Attribute.Value = BeatmapAttribute.Title; + } + } + }) + { + Children = new Drawable[] + { + new ArgonScoreCounter + { + ShowLabel = { Value = false }, + WireframeOpacity = { Value = 0 }, + }, + new DefaultHealthDisplay(), + new ArgonAccuracyCounter + { + WireframeOpacity = { Value = 0 }, + }, + new ArgonPerformancePointsCounter + { + WireframeOpacity = { Value = 0 }, + }, + new ArgonSongProgress(), + new JudgementCounterDisplay + { + FillMode = FillMode.Fill, + FlowDirection = { Value = Direction.Vertical }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Position = new Vector2(20, 0), + }, + new BeatmapAttributeText(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + new BeatmapAttributeText(), + } + }; + + return mainHUDComponents; + } + + return null; + } + + return base.GetDrawableComponent(lookup); + } + + public override IBindable? GetConfig(TLookup lookup) + { + // todo: this code is pulled from LegacySkin and should not exist. + // will likely change based on how databased storage of skin configuration goes. + switch (lookup) + { + case GlobalSkinColours global: + switch (global) + { + case GlobalSkinColours.ComboColours: + { + LogLookupDebug(this, lookup, LookupDebugType.Hit); + return SkinUtils.As(new Bindable?>(Configuration.ComboColours)); + } + } + + break; + + case SkinComboColourLookup comboColour: + LogLookupDebug(this, lookup, LookupDebugType.Hit); + return SkinUtils.As(new Bindable(getComboColour(Configuration, comboColour.ColourIndex))); + } + + LogLookupDebug(this, lookup, LookupDebugType.Miss); + return null; + } + + private static Color4 getComboColour(IHasComboColours source, int colourIndex) + => source.ComboColours![colourIndex % source.ComboColours.Count]; + } +} diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 4c9c16e721..f2dd45c001 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -22,6 +22,9 @@ namespace osu.Game.Skinning internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187"); internal static readonly Guid RETRO_SKIN = new Guid("0555C76A-CC6B-4BB4-9548-DF76BA72EF25"); internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908"); + internal static readonly Guid EZ2_SKIN = new Guid("fc372386-381d-4f8e-897a-c1d89ef39f9c"); + internal static readonly Guid EZ_STYLE_PRO_SKIN = new Guid("1E70839C-C0D8-4DBF-B747-0C08C89D412B"); + internal static readonly Guid SBI_SKIN = new Guid("fc372386-381d-4f8e-897a-c1d89ef39f2c"); [PrimaryKey] [JsonProperty] diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index e92d0d3d49..6e1536cee3 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -60,6 +60,8 @@ namespace osu.Game.Skinning private readonly IResourceStore userFiles; + private Skin ezProSkin { get; } + private Skin argonSkin { get; } private Skin trianglesSkin { get; } @@ -98,6 +100,9 @@ namespace osu.Game.Skinning trianglesSkin = new TrianglesSkin(this), argonSkin = new ArgonSkin(this), new ArgonProSkin(this), + new Ez2Skin(this), + ezProSkin = new EzStyleProSkin(this), + new SbISkin(this), }; // Ensure the default entries are present. @@ -370,6 +375,9 @@ namespace osu.Game.Skinning if (skinInfo == null) { + if (guid == SkinInfo.EZ_STYLE_PRO_SKIN) + skinInfo = ezProSkin.SkinInfo; + if (guid == SkinInfo.CLASSIC_SKIN) skinInfo = DefaultClassicSkin.SkinInfo; diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 25bc32eaf2..e3f059b91c 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -119,6 +119,9 @@ namespace osu.Game.Skinning // Temporarily used to exclude undesirable ISkin implementations static bool isUserSkin(ISkin skin) => skin.GetType() == typeof(TrianglesSkin) + || skin.GetType() == typeof(Ez2Skin) + || skin.GetType() == typeof(EzStyleProSkin) + || skin.GetType() == typeof(SbISkin) || skin.GetType() == typeof(ArgonProSkin) || skin.GetType() == typeof(ArgonSkin) || skin.GetType() == typeof(DefaultLegacySkin) diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 5ef6b30a82..4c4656f011 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -63,11 +63,14 @@ namespace osu.Game.Storyboards.Drawables Storyboard = storyboard; Mods = mods ?? Array.Empty(); - Size = new Vector2(640, 480); - bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).All(e => e is StoryboardVideo); - Width = Height * (storyboard.Beatmap.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); + if (!onlyHasVideoElements) + { + Size = new Vector2(640, 480); + // TODO:考虑更多比例的适配 + Width = Height * (storyboard.Beatmap.WidescreenStoryboard ? 16 / 9f : 4 / 3f); + } Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -107,6 +110,31 @@ namespace osu.Game.Storyboards.Drawables { base.LoadComplete(); + if (Storyboard.Layers.SelectMany(l => l.Elements).FirstOrDefault() is StoryboardVideo videoElement) + { + if (videoElement.CreateDrawable() is DrawableStoryboardVideo drawableVideo) + { + Schedule(() => + { + if (Parent != null) + { + float videoAspectRatio = drawableVideo.DrawSize.X / drawableVideo.DrawSize.Y; + + if (videoAspectRatio < 1.5f) + { + RelativeSizeAxes = Axes.X; + Width = 1f / DrawScale.X; + } + else + { + RelativeSizeAxes = Axes.Y; + Height = 1f / DrawScale.Y; + } + } + }); + } + } + health.BindValueChanged(val => passing.Value = val.NewValue >= 0.5, true); passing.BindValueChanged(_ => updateLayerVisibility(), true); } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4e0138afd1..5b5e1574ac 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -1,4 +1,4 @@ - + net8.0 Library @@ -35,8 +35,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + @@ -50,4 +50,8 @@ + + + + diff --git a/osu.sln b/osu.sln index 63da18c23e..eee09e3394 100644 --- a/osu.sln +++ b/osu.sln @@ -1,5 +1,4 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29424.173 MinimumVisualStudioVersion = 10.0.40219.1 @@ -27,79 +26,31 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Osu.Tests EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Tournament", "osu.Game.Tournament\osu.Game.Tournament.csproj", "{5672CA4D-1B37-425B-A118-A8DA26E78938}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Tournament.Tests", "osu.Game.Tournament.Tests\osu.Game.Tournament.Tests.csproj", "{5789E78D-38F9-4072-AB7B-978F34B2C17F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.iOS", "osu.iOS\osu.iOS.csproj", "{3F082D0B-A964-43D7-BDF7-C256D76A50D0}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tests.iOS", "osu.Game.Tests.iOS\osu.Game.Tests.iOS.csproj", "{65FF8E19-6934-469B-B690-23C6D6E56A17}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Taiko.Tests.iOS", "osu.Game.Rulesets.Taiko.Tests.iOS\osu.Game.Rulesets.Taiko.Tests.iOS.csproj", "{7E408809-66AC-49D1-AF4D-98834F9B979A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Osu.Tests.iOS", "osu.Game.Rulesets.Osu.Tests.iOS\osu.Game.Rulesets.Osu.Tests.iOS.csproj", "{6653CA6F-DB06-4604-A3FD-762E25C2AF96}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Mania.Tests.iOS", "osu.Game.Rulesets.Mania.Tests.iOS\osu.Game.Rulesets.Mania.Tests.iOS.csproj", "{39FD990E-B6CE-4B2A-999F-BC008CF2C64C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Catch.Tests.iOS", "osu.Game.Rulesets.Catch.Tests.iOS\osu.Game.Rulesets.Catch.Tests.iOS.csproj", "{4004C7B7-1A62-43F1-9DF2-52450FA67E70}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Android", "osu.Android\osu.Android.csproj", "{D1D5F9A8-B40B-40E6-B02F-482D03346D3D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Catch.Tests.Android", "osu.Game.Rulesets.Catch.Tests.Android\osu.Game.Rulesets.Catch.Tests.Android.csproj", "{C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Mania.Tests.Android", "osu.Game.Rulesets.Mania.Tests.Android\osu.Game.Rulesets.Mania.Tests.Android.csproj", "{531F1092-DB27-445D-AA33-2A77C7187C99}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Osu.Tests.Android", "osu.Game.Rulesets.Osu.Tests.Android\osu.Game.Rulesets.Osu.Tests.Android.csproj", "{90CAB706-39CB-4B93-9629-3218A6FF8E9B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Taiko.Tests.Android", "osu.Game.Rulesets.Taiko.Tests.Android\osu.Game.Rulesets.Taiko.Tests.Android.csproj", "{3701A0A1-8476-42C6-B5C4-D24129B4A484}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tests.Android", "osu.Game.Tests.Android\osu.Game.Tests.Android.csproj", "{5CC222DC-5716-4499-B897-DCBDDA4A5CF9}" -EndProject + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{10DF8F12-50FD-45D8-8A38-17BA764BF54D}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig Directory.Build.props = Directory.Build.props - osu.Android.props = osu.Android.props - osu.iOS.props = osu.iOS.props + CodeAnalysis\osu.ruleset = CodeAnalysis\osu.ruleset global.json = global.json osu.sln.DotSettings = osu.sln.DotSettings osu.TestProject.props = osu.TestProject.props EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Benchmarks", "osu.Game.Benchmarks\osu.Game.Benchmarks.csproj", "{93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Templates", "Templates", "{70CFC05F-CF79-4A7F-81EC-B32F1E564480}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Rulesets", "Rulesets", "{CA1DD4A8-FA22-48E0-860F-D57A7ED7D426}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-empty", "ruleset-empty", "{6E22BB20-901E-49B3-90A1-B0E6377FE568}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-example", "ruleset-example", "{7DBBBA73-6D84-4EBA-8711-EBC2939B04B5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-scrolling-empty", "ruleset-scrolling-empty", "{5CB72FDE-BA77-47D1-A556-FEB15AAD4523}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-scrolling-example", "ruleset-scrolling-example", "{0E0EDD4C-1E45-4E03-BC08-0102C98D34B3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyFreeform", "Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj", "{9014CA66-5217-49F6-8C1E-3430FD08EF61}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyFreeform.Tests", "Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform.Tests\osu.Game.Rulesets.EmptyFreeform.Tests.csproj", "{561DFD5E-5896-40D1-9708-4D692F5BAE66}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon", "Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj", "{B325271C-85E7-4DB3-8BBB-B70F242954F8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyScrolling", "Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj", "{AD923016-F318-49B7-B08B-89DED6DC2422}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyScrolling.Tests", "Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling.Tests\osu.Game.Rulesets.EmptyScrolling.Tests.csproj", "{B9B92246-02EB-4118-9C6F-85A0D726AA70}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon", "Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj", "{B9022390-8184-4548-9DB1-50EB8878D20A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{1743BF7C-E6AE-4A06-BAD9-166D62894303}" -EndProject + + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CodeAnalysis", "CodeAnalysis", "{FB156649-D457-4D1A-969C-D3A23FD31513}" ProjectSection(SolutionItems) = preProject CodeAnalysis\BannedSymbols.txt = CodeAnalysis\BannedSymbols.txt CodeAnalysis\osu.globalconfig = CodeAnalysis\osu.globalconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Resources", "..\osu-resources\osu.Game.Resources\osu.Game.Resources.csproj", "{18ED4ABC-6064-46F1-9A05-4E0BE88A635A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Framework", "..\osu-framework\osu.Framework\osu.Framework.csproj", "{E46405B5-643D-400C-A1F2-F770FBCC54B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Framework.NativeLibs", "..\osu-framework\osu.Framework.NativeLibs\osu.Framework.NativeLibs.csproj", "{F5037F74-E137-4940-8CE7-4D3E5DF0FB39}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -110,38 +61,42 @@ Global {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.Build.0 = Release|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.Deploy.0 = Release|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.Build.0 = Debug|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.ActiveCfg = Release|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.Build.0 = Release|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.Deploy.0 = Release|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.Build.0 = Debug|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.ActiveCfg = Release|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.Build.0 = Release|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.Deploy.0 = Release|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.Build.0 = Debug|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.ActiveCfg = Release|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.Build.0 = Release|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.Deploy.0 = Release|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.Build.0 = Debug|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.ActiveCfg = Release|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.Build.0 = Release|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.Deploy.0 = Release|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|Any CPU.Build.0 = Debug|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|Any CPU.Build.0 = Release|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|Any CPU.Build.0 = Debug|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|Any CPU.Build.0 = Debug|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|Any CPU.ActiveCfg = Release|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|Any CPU.Build.0 = Release|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|Any CPU.Deploy.0 = Release|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|Any CPU.Build.0 = Debug|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|Any CPU.ActiveCfg = Release|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|Any CPU.Build.0 = Release|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|Any CPU.Build.0 = Debug|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|Any CPU.Build.0 = Release|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|Any CPU.Build.0 = Debug|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|Any CPU.Build.0 = Debug|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -150,110 +105,26 @@ Global {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|Any CPU.Build.0 = Release|Any CPU + + {18ED4ABC-6064-46F1-9A05-4E0BE88A635A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18ED4ABC-6064-46F1-9A05-4E0BE88A635A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18ED4ABC-6064-46F1-9A05-4E0BE88A635A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18ED4ABC-6064-46F1-9A05-4E0BE88A635A}.Release|Any CPU.Build.0 = Release|Any CPU + {18ED4ABC-6064-46F1-9A05-4E0BE88A635A}.Release|Any CPU.Deploy.0 = Release|Any CPU + {E46405B5-643D-400C-A1F2-F770FBCC54B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E46405B5-643D-400C-A1F2-F770FBCC54B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E46405B5-643D-400C-A1F2-F770FBCC54B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E46405B5-643D-400C-A1F2-F770FBCC54B8}.Release|Any CPU.Build.0 = Release|Any CPU + {E46405B5-643D-400C-A1F2-F770FBCC54B8}.Release|Any CPU.Deploy.0 = Release|Any CPU + {F5037F74-E137-4940-8CE7-4D3E5DF0FB39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5037F74-E137-4940-8CE7-4D3E5DF0FB39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5037F74-E137-4940-8CE7-4D3E5DF0FB39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5037F74-E137-4940-8CE7-4D3E5DF0FB39}.Release|Any CPU.Build.0 = Release|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|Any CPU.Build.0 = Debug|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|Any CPU.ActiveCfg = Release|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|Any CPU.Build.0 = Release|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|Any CPU.Build.0 = Release|Any CPU - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|Any CPU.Build.0 = Release|Any CPU - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|Any CPU.ActiveCfg = Release|Any CPU - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|Any CPU.Build.0 = Release|Any CPU - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|Any CPU.Build.0 = Release|Any CPU - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|Any CPU.Build.0 = Release|Any CPU - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|Any CPU.Build.0 = Debug|Any CPU - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|Any CPU.Build.0 = Release|Any CPU - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|Any CPU.Build.0 = Release|Any CPU - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Build.0 = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Deploy.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Build.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Deploy.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Build.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Deploy.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Build.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Deploy.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Build.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Deploy.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Build.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Deploy.0 = Release|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.Build.0 = Debug|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.ActiveCfg = Release|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.Build.0 = Release|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|Any CPU.Build.0 = Release|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|Any CPU.Build.0 = Debug|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|Any CPU.ActiveCfg = Release|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|Any CPU.Build.0 = Release|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|Any CPU.Build.0 = Release|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|Any CPU.Build.0 = Release|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|Any CPU.Build.0 = Release|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|Any CPU.Build.0 = Release|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|Any CPU.Build.0 = Release|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|Any CPU.Build.0 = Release|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|Any CPU.Deploy.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -291,18 +162,18 @@ Global $2.scope = text/x-csharp EndGlobalSection GlobalSection(NestedProjects) = preSolution - {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} = {70CFC05F-CF79-4A7F-81EC-B32F1E564480} - {6E22BB20-901E-49B3-90A1-B0E6377FE568} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} - {9014CA66-5217-49F6-8C1E-3430FD08EF61} = {6E22BB20-901E-49B3-90A1-B0E6377FE568} - {561DFD5E-5896-40D1-9708-4D692F5BAE66} = {6E22BB20-901E-49B3-90A1-B0E6377FE568} - {7DBBBA73-6D84-4EBA-8711-EBC2939B04B5} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} - {B325271C-85E7-4DB3-8BBB-B70F242954F8} = {7DBBBA73-6D84-4EBA-8711-EBC2939B04B5} - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738} = {7DBBBA73-6D84-4EBA-8711-EBC2939B04B5} - {5CB72FDE-BA77-47D1-A556-FEB15AAD4523} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} - {0E0EDD4C-1E45-4E03-BC08-0102C98D34B3} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} - {AD923016-F318-49B7-B08B-89DED6DC2422} = {5CB72FDE-BA77-47D1-A556-FEB15AAD4523} - {B9B92246-02EB-4118-9C6F-85A0D726AA70} = {5CB72FDE-BA77-47D1-A556-FEB15AAD4523} - {B9022390-8184-4548-9DB1-50EB8878D20A} = {0E0EDD4C-1E45-4E03-BC08-0102C98D34B3} - {1743BF7C-E6AE-4A06-BAD9-166D62894303} = {0E0EDD4C-1E45-4E03-BC08-0102C98D34B3} + + + + + + + + + + + + + EndGlobalSection EndGlobal diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 99c42ec6f2..fab10d03fa 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -339,11 +339,13 @@ False False AABB + AC API ARGB BPM DDKK EF + EZ FPS GC GL @@ -356,6 +358,7 @@ HTML HUD ID + IIDX IL IOS IP @@ -363,9 +366,12 @@ JIT KDDK KKDD + LN + LR LTRB MD5 NS + OD OS PM RGB @@ -373,6 +379,7 @@ RNG SDL SHA + SR SRGB TK SS @@ -986,10 +993,12 @@ private void load() $END$ }; True + True True True True True + True True True True @@ -1004,8 +1013,10 @@ private void load() True True True + True True True + True True True True @@ -1014,16 +1025,21 @@ private void load() True True True + True True + True True True True + True True True True True True True + True + True True True True @@ -1034,6 +1050,8 @@ private void load() True True True + True + True True True True @@ -1049,13 +1067,16 @@ private void load() True True True + True True True True True True True + True True + True True True True @@ -1065,5 +1086,8 @@ private void load() True True True + True True - True + True + True + True diff --git a/publish.py b/publish.py new file mode 100644 index 0000000000..b9132e2713 --- /dev/null +++ b/publish.py @@ -0,0 +1,372 @@ +import subprocess +import os +import sys +import argparse +import shutil +import zipfile +import hashlib +from datetime import datetime + + +def run_publish(project_csproj: str, working_dir: str, config: str, out_dir: str) -> int: + cmd = ["dotnet", "publish", project_csproj, "-c", config, "-o", out_dir] + print("Running:", " ".join(cmd)) + res = subprocess.run(cmd, cwd=working_dir) + return res.returncode + + +def run_cleanup(script_path: str, target_dir: str) -> int: + # If an external script is provided and exists, run it. Otherwise use internal cleaner. + if script_path and os.path.exists(script_path): + print(f"Running external cleanup script: {script_path}") + res = subprocess.run(["python", script_path, target_dir]) + return res.returncode + else: + print("External cleanup script not found, using internal cleanup logic") + return clean_publish_folder(target_dir) + + +def clean_publish_folder(release_dir=None, platform=None): + from pathlib import Path + import fnmatch + + if release_dir is None: + release_dir = Path(__file__).parent / "Release" + else: + release_dir = Path(release_dir) + + if not release_dir.exists(): + print(f"Release folder does not exist: {release_dir}") + return 0 + + print(f"Starting cleanup of folder: {release_dir}") + + deleted_files = 0 + deleted_folders = 0 + + # 1. 删除 .pdb 文件 + for pdb_file in release_dir.rglob("*.pdb"): + try: + pdb_file.unlink() + print(f"Deleted PDB file: {pdb_file.name}") + deleted_files += 1 + except Exception as e: + print(f"Failed to delete {pdb_file}: {e}") + + # 2. 删除调试和诊断相关的XML文件(而不是所有XML文件) + debug_xml_patterns = [ + "*Microsoft*.xml", "*System*.xml", "*osu*.xml", "*Veldrid*.xml", "*MongoDB*.xml", "*Newtonsoft*.xml", "*TagLib*.xml", "*HtmlAgilityPack*.xml", "*DiscordRPC*.xml", "*FFmpeg*.xml", "*Sentry*.xml", "*Realm*.xml", "*NuGet*.xml" + ] + for pattern in debug_xml_patterns: + for xml_file in release_dir.rglob(pattern): + try: + xml_file.unlink() + print(f"Deleted documentation file: {xml_file.name}") + deleted_files += 1 + except Exception as e: + print(f"Failed to delete {xml_file}: {e}") + + # 清理 runtime 文件夹 + runtime_dir = release_dir / "runtimes" + if runtime_dir.exists(): + print(f"Processing runtime folder: {runtime_dir}") + # choose keep list based on platform if provided + if platform is None: + keep_runtimes = {"win-x64", "win-x86"} + else: + if platform == 'windows': + keep_runtimes = {"win-x64", "win-x86"} + elif platform == 'linux': + keep_runtimes = {"linux-x64"} + elif platform == 'macos': + keep_runtimes = {"osx-x64", "osx-arm64"} + else: + keep_runtimes = {"win-x64", "win-x86"} + + for runtime_folder in runtime_dir.iterdir(): + if runtime_folder.is_dir(): + runtime_name = runtime_folder.name + if runtime_name not in keep_runtimes: + try: + shutil.rmtree(runtime_folder) + print(f"Deleted runtime folder: {runtime_name}") + deleted_folders += 1 + except Exception as e: + print(f"Failed to delete runtime folder {runtime_name}: {e}") + else: + print(f"Keeping runtime folder: {runtime_name}") + + print(f"\nCleanup complete!") + print(f"Deleted files: {deleted_files}") + print(f"Deleted folders: {deleted_folders}") + return 0 + + +def _compute_sha256(path: str) -> str: + h = hashlib.sha256() + with open(path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b''): + h.update(chunk) + return h.hexdigest() + + +def zip_folder(src_dir: str, zip_path: str): + """Create a deterministic zip of src_dir at zip_path. + + Determinism achieved by: + - adding files in sorted order + - setting a fixed timestamp on all ZipInfo entries + - using ZIP_DEFLATED consistently + """ + if os.path.exists(zip_path): + os.remove(zip_path) + + FIXED_DATETIME = (1980, 1, 1, 0, 0, 0) # year >= 1980 required by zip spec + + def _iter_files(root_dir): + for root, dirs, files in os.walk(root_dir): + dirs.sort() + files.sort() + for f in files: + full = os.path.join(root, f) + rel = os.path.relpath(full, root_dir) + # normalize to forward slashes inside zip + arcname = rel.replace(os.path.sep, '/') + yield full, arcname + + compression = zipfile.ZIP_DEFLATED + with zipfile.ZipFile(zip_path, 'w', compression=compression) as zf: + for full, arcname in _iter_files(src_dir): + zi = zipfile.ZipInfo(arcname) + zi.date_time = FIXED_DATETIME + # set external attributes to a reasonable default (rw-r--r--) + zi.external_attr = 0o644 << 16 + with open(full, 'rb') as fh: + data = fh.read() + zf.writestr(zi, data, compress_type=compression) + + # print diagnostics: size and sha256 + try: + size = os.path.getsize(zip_path) + sha256 = _compute_sha256(zip_path) + print(f"Created zip: {zip_path}") + print(f"ZIP size: {size} bytes") + print(f"ZIP SHA256: {sha256}") + except Exception as e: + print(f"Created zip but failed to compute diagnostics: {e}") + + +def main(): + parser = argparse.ArgumentParser(description="Publish and package Ez2Lazer builds.") + # Prefer the GITHUB_WORKSPACE env when present (CI), otherwise use the script directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + gh_workspace = os.environ.get('GITHUB_WORKSPACE', script_dir) + parser.add_argument('--project', default=os.path.join(gh_workspace, 'osu.Desktop', 'osu.Desktop.csproj')) + parser.add_argument('--workdir', default=gh_workspace) + parser.add_argument('--cleanup-release', default=None) + parser.add_argument('--cleanup-debug', default=None) + parser.add_argument('--outroot', default=gh_workspace) + # Note: no local-only root option to keep publish.py CI-friendly + parser.add_argument('--zip-only', action='store_true', help='Only create zip files locally and do not attempt any remote operations') + parser.add_argument('--no-zip', action='store_true', help='Do not create zip files') + parser.add_argument('--tag', default=None, help='Optional tag to include in asset name') + parser.add_argument('--deps-path', default=None, help='Path to folder containing dependency DLLs to include') + parser.add_argument('--deps-pattern', default='*.dll', help='Glob pattern for dependency files to copy') + parser.add_argument('--deps-source', choices=['local','github','none'], default='local', help='Where to get dependency DLLs') + parser.add_argument('--deps-github-repo', default='SK-la/osu-framework', help='GitHub repo (owner/repo) to clone when --deps-source=github') + parser.add_argument('--deps-github-branch', default='locmain', help='Branch or ref to checkout when cloning deps github repo') + parser.add_argument('--deps-github-project', default='osu.Framework/osu.Framework.csproj', help='Path to csproj inside cloned deps repo to build') + parser.add_argument('--resources-github-repo', default='SK-la/osu-resources', help='GitHub repo for resources to clone') + parser.add_argument('--resources-github-path', default='osu.Game.Resources/Resources', help='Path inside resources repo to copy') + parser.add_argument('--resources-path', default=None, help='Local path to resources to include in package') + args = parser.parse_args() + + # Enforce that a tag is provided to avoid any implicit fallback tag generation + if not args.tag: + # default to today's date tag if not provided when running locally + today = datetime.utcnow() + args.tag = f"{today.year}-{today.month}-{today.day}" + print(f"No --tag provided; defaulting to {args.tag}") + + tag_suffix = f"_{args.tag}" + + # fixed folder names + # If running in zip-only (local) mode, place artifacts under the user-specified local root + base_out = args.outroot + + release_dir = os.path.join(base_out, 'Ez2Lazer_release_x64') + debug_dir = os.path.join(base_out, 'Ez2Lazer_debug_x64') + + # remove existing folders to ensure deterministic output + for d in (release_dir, debug_dir): + if os.path.exists(d): + print(f"Removing existing directory: {d}") + shutil.rmtree(d) + + # publish + print('Publishing Release...') + rc = run_publish(args.project, args.workdir, 'Release', release_dir) + if rc != 0: + print('Release publish failed with code', rc) + else: + print('Release publish succeeded') + # optional cleanup + run_cleanup(args.cleanup_release, release_dir) + + print('Publishing Debug...') + rc2 = run_publish(args.project, args.workdir, 'Debug', debug_dir) + if rc2 != 0: + print('Debug publish failed with code', rc2) + else: + print('Debug publish succeeded') + run_cleanup(args.cleanup_debug, debug_dir) + + # create zips with fixed base name + tag + artifacts_dir = os.path.join(base_out, 'artifacts') + # Try to create artifacts dir; if permission denied (e.g. running from system32), + # fall back to a safe location next to this script. + try: + os.makedirs(artifacts_dir, exist_ok=True) + except PermissionError: + fallback = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'artifacts') + print(f"Permission denied creating {artifacts_dir}, falling back to {fallback}") + os.makedirs(fallback, exist_ok=True) + artifacts_dir = fallback + + # Use asset names that match workflow-normalized names when tag present + release_zip = os.path.join(artifacts_dir, f"Ez2Lazer_release_x64{tag_suffix}.zip") + debug_zip = os.path.join(artifacts_dir, f"Ez2Lazer_debug_x64{tag_suffix}.zip") + + if not args.no_zip: + if os.path.exists(release_dir): + # handle deps source + temp_dirs = [] + deps_to_cleanup = [] + try: + if args.deps_source == 'local': + deps_src_path = args.deps_path + elif args.deps_source == 'github': + # clone deps repo and build; try to auto-detect csproj if path not exact + import tempfile + deps_owner, deps_repo = args.deps_github_repo.split('/') + tmp = tempfile.mkdtemp(prefix='deps-') + temp_dirs.append(tmp) + print(f"Cloning {args.deps_github_repo}@{args.deps_github_branch} into {tmp}") + res = subprocess.run(["git","clone","--depth","1","--branch",args.deps_github_branch,f"https://github.com/{args.deps_github_repo}.git", tmp]) + if res.returncode != 0: + raise RuntimeError('git clone failed') + + # Candidate project path if provided + candidate = os.path.join(tmp, *args.deps_github_project.split('/')) + proj_to_build = None + if os.path.exists(candidate): + proj_to_build = candidate + else: + # search for csproj files and prefer ones with osu.Framework in name or path + csproj_matches = [] + for root, dirs, files in os.walk(tmp): + for f in files: + if f.endswith('.csproj'): + csproj_matches.append(os.path.join(root, f)) + if csproj_matches: + preferred = None + for p in csproj_matches: + if 'osu.Framework' in os.path.basename(p) or 'osu.Framework' in p: + preferred = p + break + proj_to_build = preferred or csproj_matches[0] + + if proj_to_build: + print('Building dependency project', proj_to_build) + bres = subprocess.run(["dotnet","build",proj_to_build,"-c","Release","-f","net8.0"]) + if bres.returncode != 0: + raise RuntimeError('dotnet build of deps failed') + deps_src_path = os.path.join(os.path.dirname(proj_to_build), 'bin', 'Release', 'net8.0') + if not os.path.exists(deps_src_path): + # sometimes the project is in a subfolder; search upwards + parent = os.path.dirname(proj_to_build) + found = False + for _ in range(4): + candidate_bin = os.path.join(parent, 'bin', 'Release', 'net8.0') + if os.path.exists(candidate_bin): + deps_src_path = candidate_bin + found = True + break + parent = os.path.dirname(parent) + if not found: + print('Warning: could not find built outputs in expected locations') + else: + print('Project path not found in cloned repo, attempting to find bin folder...') + deps_src_path = os.path.join(tmp, 'bin', 'Release', 'net8.0') + else: + deps_src_path = None + + # If resources repo requested when using github, try clone and copy resources + if args.deps_source == 'github' and args.resources_github_repo: + import tempfile + tmpres = tempfile.mkdtemp(prefix='res-') + temp_dirs.append(tmpres) + print(f"Cloning resources {args.resources_github_repo}@{args.deps_github_branch} into {tmpres}") + subprocess.run(["git","clone","--depth","1","--branch",args.deps_github_branch,f"https://github.com/{args.resources_github_repo}.git", tmpres]) + srcres = os.path.join(tmpres, args.resources_github_path.replace('/','\\' if os.name=='nt' else '/')) + if os.path.exists(srcres): + destres = os.path.join(release_dir, 'Resources') + shutil.rmtree(destres, ignore_errors=True) + shutil.copytree(srcres, destres) + print(f"Copied resources to {destres}") + + # copy dependency DLLs from deps_src_path if available + if deps_src_path and os.path.exists(deps_src_path): + import glob + print(f"Copying dependency files from {deps_src_path} to release folder") + for f in glob.glob(os.path.join(deps_src_path, args.deps_pattern)): + try: + shutil.copy(f, release_dir) + print('Copied', f) + except Exception as e: + print('Copy failed', f, e) + # copy resources from explicit resources path if provided + if args.resources_path and os.path.exists(args.resources_path): + srcres = args.resources_path + destres = os.path.join(release_dir, 'Resources') + try: + shutil.rmtree(destres, ignore_errors=True) + shutil.copytree(srcres, destres) + print(f"Copied resources to {destres}") + except Exception as e: + print('Resource copy failed', e) + else: + print('No dependency source path available, skipping deps copy') + finally: + # cleanup temp dirs + for d in temp_dirs: + try: + shutil.rmtree(d) + except Exception: + pass + print('Zipping release ->', release_zip) + zip_folder(release_dir, release_zip) + else: + print('Release folder missing, skipping zip') + + if os.path.exists(debug_dir): + # for debug, repeat similar deps copy if deps_source is local + if args.deps_source == 'local' and args.deps_path and os.path.exists(args.deps_path): + import glob + print(f"Copying dependency files from {args.deps_path} to debug folder") + for f in glob.glob(os.path.join(args.deps_path, args.deps_pattern)): + try: + shutil.copy(f, debug_dir) + print('Copied', f) + except Exception as e: + print('Copy failed', f, e) + print('Zipping debug ->', debug_zip) + zip_folder(debug_dir, debug_zip) + else: + print('Debug folder missing, skipping zip') + + print('Done.') + + +if __name__ == '__main__': + main() diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000000..5736e1fd8b --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,6 @@ +version: "1.0" +linter: jetbrains/qodana-dotnet:2024.3 +profile: + name: qodana.recommended +include: + - name: CheckDependencyLicenses \ No newline at end of file