From 9a45d0e0eb2fe7bad53f89d4b1116ca647972291 Mon Sep 17 00:00:00 2001 From: LA <1245661240@qq.com> Date: Wed, 27 Aug 2025 23:57:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9CS=E8=BF=87=E6=BB=A4=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.idea.osu.Desktop/.idea/indexLayout.xml | 3 +- .idea/.idea.osu.Desktop/.idea/vcs.xml | 1 - 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 +++++ comparison_example.cs | 34 ++ fixed.txt | 142 +++++ nested_vs_toplevel_example.cs | 56 ++ osu.Android.props | 2 +- osu.Desktop.slnf | 1 - osu.Desktop/Program.cs | 17 +- osu.Desktop/lazer.ico | Bin 76679 -> 76679 bytes osu.Desktop/osu!.res | Bin 156596 -> 0 bytes .../CatchRateAdjustedDisplayDifficultyTest.cs | 9 +- .../Mods/TestSceneCatchModMovingFast.cs | 21 + osu.Game.Rulesets.Catch/CatchRuleset.cs | 35 +- .../Edit/CatchBeatmapVerifier.cs | 3 + .../Edit/Checks/CheckBananaShowerGap.cs | 2 +- .../CheckCatchAbnormalDifficultySettings.cs | 2 +- .../Checks/CheckCatchLowestDiffDrainTime.cs | 21 + .../Mods/CatchModAutoplay.cs | 4 + .../Mods/CatchModCinema.cs | 4 + .../Mods/CatchModFlashlight.cs | 2 +- .../Mods/CatchModFloatingFruits.cs | 3 +- .../Mods/CatchModMovingFast.cs | 82 +++ osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs | 4 + .../Objects/CatchHitObject.cs | 4 +- .../TestSceneLazerManiaPlayer.cs | 0 .../osu.Game.Rulesets.LazerMania.Tests.csproj | 0 .../Beatmaps/LazerManiaBeatmap.cs | 0 .../Beatmaps/LazerManiaBeatmapConverter.cs | 0 .../Beatmaps/StageDefinition.cs | 0 .../LazerManiaRulesetConfigManager.cs | 0 .../LazerManiaDifficultyAttributes.cs | 0 .../LazerManiaDifficultyCalculator.cs | 0 .../LazerManiaPerformanceCalculator.cs | 0 .../Blueprints/LazerManiaComposeBlueprints.cs | 0 .../Blueprints/LazerManiaSelectionHandler.cs | 0 .../Edit/LazerManiaBeatmapVerifier.cs | 0 .../Edit/LazerManiaHitObjectComposer.cs | 0 .../Judgements/LazerManiaJudgement.cs | 0 .../LazerManiaAction.cs | 0 .../LazerManiaInputManager.cs | 0 .../LazerManiaRuleset.cs | 0 .../LazerManiaRuleset.cs.new | 0 .../LazerManiaSettingsSubsection.cs | 0 .../Mods/LazerManiaModsBasic.cs | 0 .../Mods/LazerManiaModsSpecial.cs | 0 .../Drawables/DrawableLazerManiaHitObject.cs | 0 .../Objects/LazerManiaHitObject.cs | 0 .../LazerManiaHitObjectLifetimeEntry.cs | 0 .../Objects/LazerManiaNote.cs | 0 .../Properties/launchSettings.json | 0 .../LazerManiaFramedReplayInputHandler.cs | 0 .../Replays/LazerManiaReplayFrame.cs | 0 .../Scoring/LazerManiaHitWindows.cs | 0 .../SingleStageVariantGenerator.cs | 0 .../Legacy/LazerManiaLegacySkinTransformer.cs | 0 .../UI/DrawableLazerManiaNote.cs | 0 .../UI/DrawableLazerManiaRuleset.cs | 0 .../UI/LazerManiaInputManager.cs | 0 .../UI/LazerManiaPlayfield.cs | 0 .../UI/LazerManiaStage.cs | 0 .../UI/ManiaScrollVisualisationMethod.cs | 0 .../UI/PlayfieldBoundsConstraint.cs | 0 .../VariantMappingGenerator.cs | 0 .../VariantMappingGenerator.cs.new | 0 .../osu.Game.Rulesets.LazerMania.csproj | 0 .../Checks/CheckManiaConcurrentObjectsTest.cs | 22 +- .../ManiaBeatmapSampleConversionTest.cs | 4 + .../convert-samples-expected-conversion.json | 2 + .../mania-samples-expected-conversion.json | 2 + .../mania-slider-expected-conversion.json | 18 + .../Testing/Beatmaps/mania-slider.osu | 29 + .../TestSceneManiaTouchInput.cs | 2 +- .../TestSceneTimingBasedNoteColouring.cs | 2 - .../Beatmaps/ManiaBeatmapConverter.cs | 2 +- .../Legacy/PassThroughPatternGenerator.cs | 8 + .../Patterns/Legacy/SliderPatternGenerator.cs | 1 + .../ManiaRulesetConfigManager.cs | 16 + .../Edit/Checks/CheckKeyCount.cs | 2 +- .../CheckManiaAbnormalDifficultySettings.cs | 2 +- .../Checks/CheckManiaConcurrentObjects.cs | 18 +- .../Checks/CheckManiaLowestDiffDrainTime.cs | 21 + .../Edit/ManiaBeatmapVerifier.cs | 3 + .../ManiaFilterCriteria.cs | 4 +- osu.Game.Rulesets.Mania/ManiaMobileLayout.cs | 11 +- osu.Game.Rulesets.Mania/ManiaRuleset.cs | 44 +- .../ManiaSettingsSubsection.cs | 11 + .../Mods/ManiaModConstantSpeed.cs | 3 +- osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs | 3 + .../Mods/ManiaModDualStages.cs | 3 + .../Mods/ManiaModFadeIn.cs | 3 + .../Mods/ManiaModHoldOff.cs | 3 +- .../Mods/ManiaModInvert.cs | 4 +- osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs | 3 + osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs | 3 + .../Mods/ManiaModNoRelease.cs | 6 + .../Objects/Drawables/DrawableHoldNote.cs | 2 +- osu.Game.Rulesets.Mania/Objects/HoldNote.cs | 5 + .../Scoring/ManiaScoreProcessor.cs | 19 + osu.Game.Rulesets.Mania/UI/Column.cs | 6 +- osu.Game.Rulesets.Mania/UI/ColumnFlow.cs | 2 +- .../UI/DrawableManiaRuleset.cs | 22 +- .../Editor/TestSceneOsuEditorGrids.cs | 66 +++ .../Mods/TestSceneOsuModFlashlight.cs | 20 - .../OsuRateAdjustedDisplayDifficultyTest.cs | 12 +- .../Resources/old-skin/sliderpoint10.png | Bin 0 -> 2349 bytes .../Resources/old-skin/sliderpoint30.png | Bin 0 -> 2718 bytes .../TestSceneAimErrorMeter.cs | 162 +++++ .../TestSceneDrawableJudgementSliderTicks.cs | 160 +++++ .../TestSceneLegacyHitPolicy.cs | 2 +- .../Edit/Blueprints/GridPlacementBlueprint.cs | 7 +- .../Edit/Checks/CheckLowDiffOverlaps.cs | 2 +- .../Edit/Checks/CheckOffscreenObjects.cs | 2 +- .../CheckOsuAbnormalDifficultySettings.cs | 2 +- .../Checks/CheckOsuLowestDiffDrainTime.cs | 21 + .../Edit/Checks/CheckTimeDistanceEquality.cs | 2 +- .../Edit/Checks/CheckTooShortSliders.cs | 2 +- .../Edit/Checks/CheckTooShortSpinners.cs | 4 +- .../Edit/OsuBeatmapVerifier.cs | 1 + osu.Game.Rulesets.Osu/HUD/AimErrorMeter.cs | 475 +++++++++++++++ osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs | 3 +- .../Mods/OsuModApproachDifferent.cs | 3 +- osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs | 3 +- osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs | 3 + osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs | 4 + osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs | 3 +- osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs | 3 +- .../Mods/OsuModFlashlight.cs | 2 +- .../Mods/OsuModFreezeFrame.cs | 4 + osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs | 5 +- .../Mods/OsuModMagnetised.cs | 3 +- osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs | 5 +- osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs | 3 + osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs | 3 +- .../Mods/OsuModStrictTracking.cs | 3 + .../Mods/OsuModTargetPractice.cs | 2 +- osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs | 3 + osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs | 5 +- osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs | 5 +- .../Objects/Drawables/DrawableOsuJudgement.cs | 10 +- .../Objects/Drawables/DrawableSliderHead.cs | 11 - .../Objects/Drawables/SkinnableLighting.cs | 3 +- osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs | 4 +- osu.Game.Rulesets.Osu/Objects/Spinner.cs | 8 +- osu.Game.Rulesets.Osu/OsuRuleset.cs | 69 ++- .../Skinning/Argon/OsuArgonSkinTransformer.cs | 4 + .../LegacyJudgementPieceSliderTickHit.cs | 23 + .../Legacy/OsuLegacySkinTransformer.cs | 35 ++ .../Statistics/AccuracyHeatmap.cs | 60 +- osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs | 2 + .../CheckTaikoInconsistentSkipBarLineTest.cs | 220 +++++++ .../Mods/TestSceneTaikoModFlashlight.cs | 31 + .../TaikoRateAdjustedDisplayDifficultyTest.cs | 9 +- .../Beatmaps/TaikoBeatmapConverter.cs | 5 +- .../CheckTaikoAbnormalDifficultySettings.cs | 2 +- .../CheckTaikoInconsistentSkipBarLine.cs | 67 +++ .../Checks/CheckTaikoLowestDiffDrainTime.cs | 21 + .../Edit/TaikoBeatmapVerifier.cs | 6 + .../Mods/TaikoModConstantSpeed.cs | 3 +- .../Mods/TaikoModFlashlight.cs | 19 +- .../Mods/TaikoModSimplifiedRhythm.cs | 3 + .../Mods/TaikoModSingleTap.cs | 3 + osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs | 3 + osu.Game.Rulesets.Taiko/TaikoRuleset.cs | 47 +- .../Formats/LegacyScoreDecoderTest.cs | 3 + .../Checks/CheckConcurrentObjectsTest.cs | 54 +- .../Checks/CheckHitsoundsFormatTest.cs | 46 ++ .../Checks/CheckInconsistentAudioTest.cs | 151 +++++ .../Checks/CheckInconsistentMetadataTest.cs | 215 +++++++ .../Checks/CheckInconsistentSettingsTest.cs | 272 +++++++++ ...heckInconsistentTimingControlPointsTest.cs | 254 ++++++++ .../Checks/CheckLowestDiffDrainTimeTest.cs | 251 ++++++++ .../Checks/CheckMissingGenreLanguageTest.cs | 184 ++++++ .../Editing/Checks/CheckPreviewTimeTest.cs | 73 +-- .../Editing/Checks/CheckVideoUsageTest.cs | 179 ++++++ .../NonVisual/Filtering/FilterMatchingTest.cs | 231 ++++++++ .../Filtering/FilterQueryParserTest.cs | 22 + .../NonVisual/FirstAvailableHitWindowsTest.cs | 1 - .../NonVisual/TestSceneUpdateManager.cs | 7 + .../Online/Chat/MessageNotifierTest.cs | 26 +- .../Archives/modified-argon-20250809.osk | Bin 0 -> 1756 bytes .../Skins/SkinDeserialisationTest.cs | 6 +- .../Gameplay/TestSceneBeatmapOffsetControl.cs | 99 ++++ .../TestSceneFrameStabilityContainer.cs | 5 +- .../Gameplay/TestSceneGameplayLeaderboard.cs | 281 ++++++--- .../Visual/Gameplay/TestSceneHitErrorMeter.cs | 1 - .../Visual/Gameplay/TestScenePause.cs | 13 +- .../Visual/Gameplay/TestScenePlayerLoader.cs | 47 ++ .../Gameplay/TestScenePoolingRuleset.cs | 1 - .../Visual/Gameplay/TestSceneReplayPlayer.cs | 11 +- .../Gameplay/TestSceneUnstableRateCounter.cs | 55 +- .../TestSceneMultiplayerPositionDisplay.cs | 24 +- .../TestSceneStarRatingRangeDisplay.cs | 43 ++ .../Navigation/TestSceneScreenNavigation.cs | 42 +- .../TestSceneSkinEditorNavigation.cs | 21 +- .../TestSceneSongSelectNavigation.cs | 24 + .../Online/TestSceneBeatmapSetOverlay.cs | 6 +- .../Visual/Online/TestSceneMessageNotifier.cs | 15 +- .../Ranking/TestSceneStatisticsPanel.cs | 2 + .../Visual/Ranking/TestSceneUserTagControl.cs | 34 +- .../Settings/TestSceneTabletSettings.cs | 2 + .../SongSelect/TestSceneAdvancedStats.cs | 89 +-- .../SongSelect/TestSceneCollectionDropdown.cs | 10 +- .../BeatmapCarouselFilterGroupingTest.cs | 44 +- .../SongSelectV2/SongSelectTestScene.cs | 11 +- .../TestSceneBeatmapCarouselUpdateHandling.cs | 131 +++- .../TestSceneBeatmapLeaderboardSorting.cs | 155 +++++ .../TestSceneBeatmapMetadataWedge.cs | 185 +++--- .../TestSceneBeatmapTitleWedge.cs | 121 ++-- .../TestSceneCollectionDropdown.cs | 20 +- ...neSongSelectCurrentSelectionInvalidated.cs | 38 ++ .../TestSceneSongSelectGrouping.cs | 341 +++++++++++ .../Visual/UserInterface/TestSceneModIcon.cs | 32 +- .../TestSceneNotificationOverlay.cs | 12 - osu.Game/Beatmaps/APIBeatmapMetadataSource.cs | 4 +- osu.Game/Beatmaps/BeatmapMetadata.cs | 15 +- osu.Game/Beatmaps/BeatmapOnlineStatus.cs | 1 + .../Beatmaps/BeatmapUpdaterMetadataLookup.cs | 3 + .../Drawables/DifficultyIconTooltip.cs | 32 +- .../UpdateableOnlineBeatmapSetCover.cs | 3 +- .../LocalCachedBeatmapMetadataSource.cs | 102 +++- osu.Game/Beatmaps/OnlineBeatmapMetadata.cs | 6 + osu.Game/Beatmaps/WorkingBeatmap.cs | 13 +- .../Collections/CollectionFilterMenuItem.cs | 10 +- .../Collections/DrawableCollectionListItem.cs | 3 +- .../Collections/ManageCollectionsDialog.cs | 3 +- osu.Game/Configuration/OsuConfigManager.cs | 19 +- .../Database/BackgroundDataStoreProcessor.cs | 135 ++++- osu.Game/Database/RealmAccess.cs | 4 +- osu.Game/Database/RealmObjectExtensions.cs | 2 + osu.Game/Graphics/Carousel/Carousel.cs | 193 +----- .../Carousel/Carousel_ScrollContainer.cs | 301 ++++++++++ osu.Game/Graphics/Carousel/ICarouselFilter.cs | 5 + .../Graphics/Containers/ExpandingContainer.cs | 6 - osu.Game/Graphics/InputBlockingContainer.cs | 2 + osu.Game/Graphics/OsuIcon.cs | 321 +++++++++- .../UserInterfaceV2/ShearedDropdown.cs | 3 +- osu.Game/Localisation/AudioSettingsStrings.cs | 10 + .../BeatmapLeaderboardWedgeStrings.cs | 74 +++ osu.Game/Localisation/CollectionsStrings.cs | 49 ++ osu.Game/Localisation/CommonStrings.cs | 12 +- osu.Game/Localisation/EditorStrings.cs | 10 + .../GlobalActionKeyBindingStrings.cs | 4 +- .../Localisation/HUD/AimErrorMeterStrings.cs | 74 +++ osu.Game/Localisation/NotificationsStrings.cs | 15 +- .../ReplayFailIndicatorStrings.cs | 24 + .../Localisation/RulesetSettingsStrings.cs | 13 +- osu.Game/Localisation/SongSelectStrings.cs | 212 ++++++- osu.Game/Localisation/UserInterfaceStrings.cs | 5 + osu.Game/Online/API/APIAccess.cs | 4 +- osu.Game/Online/API/DummyAPIAccess.cs | 2 +- osu.Game/Online/API/IAPIProvider.cs | 3 +- .../API/Requests/Responses/SoloScoreInfo.cs | 4 + osu.Game/Online/Chat/MessageNotifier.cs | 108 +++- osu.Game/Online/FriendPresenceNotifier.cs | 45 +- osu.Game/Online/HubClientConnector.cs | 30 +- .../Online/Leaderboards/LeaderboardManager.cs | 23 +- .../Online/Leaderboards/UpdateableRank.cs | 9 +- .../Online/Metadata/OnlineMetadataClient.cs | 2 +- .../Online/Multiplayer/MultiplayerClient.cs | 25 +- ...gnalRDerivedTypeWorkaroundJsonConverter.cs | 61 -- osu.Game/Online/SignalRWorkaroundTypes.cs | 1 - osu.Game/OsuGameBase.cs | 2 +- osu.Game/Overlays/Chat/ChatLine.cs | 2 +- .../Mods/AdjustedAttributesTooltip.cs | 148 ----- .../Overlays/Mods/BeatmapAttributeTooltip.cs | 157 +++++ .../Overlays/Mods/BeatmapAttributesDisplay.cs | 49 +- .../Overlays/Mods/VerticalAttributeDisplay.cs | 135 +++-- .../Notifications/SimpleNotification.cs | 76 +-- .../Notifications/UserAvatarNotification.cs | 59 +- osu.Game/Overlays/OverlayScrollContainer.cs | 3 + .../Header/Components/ExtendedDetails.cs | 9 + .../Sections/Ranks/DrawableProfileScore.cs | 17 +- .../Ranks/DrawableProfileWeightedScore.cs | 5 +- .../Settings/Sections/Audio/OffsetSettings.cs | 6 + .../Sections/General/UpdateSettings.cs | 25 +- .../Difficulty/RulesetBeatmapDifficulty.cs | 67 +++ osu.Game/Rulesets/Edit/BeatmapVerifier.cs | 8 + .../Rulesets/Edit/BeatmapVerifierContext.cs | 73 ++- .../Rulesets/Edit/Checks/CheckAudioInVideo.cs | 8 +- .../Rulesets/Edit/Checks/CheckAudioQuality.cs | 6 +- .../Edit/Checks/CheckBackgroundQuality.cs | 10 +- osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs | 6 +- .../Edit/Checks/CheckConcurrentObjects.cs | 60 +- .../Edit/Checks/CheckDelayedHitsounds.cs | 6 +- .../Rulesets/Edit/Checks/CheckDrainLength.cs | 2 +- .../Rulesets/Edit/Checks/CheckFewHitsounds.cs | 6 +- .../Rulesets/Edit/Checks/CheckFilePresence.cs | 6 +- .../Edit/Checks/CheckHitsoundsFormat.cs | 20 +- .../Edit/Checks/CheckInconsistentAudio.cs | 53 ++ .../Edit/Checks/CheckInconsistentMetadata.cs | 105 ++++ .../Edit/Checks/CheckInconsistentSettings.cs | 81 +++ .../CheckInconsistentTimingControlPoints.cs | 144 +++++ .../Edit/Checks/CheckLowestDiffDrainTime.cs | 89 +++ .../Edit/Checks/CheckMissingGenreLanguage.cs | 70 +++ .../Rulesets/Edit/Checks/CheckMutedObjects.cs | 2 +- .../Rulesets/Edit/Checks/CheckPreviewTime.cs | 13 +- .../Rulesets/Edit/Checks/CheckSongFormat.cs | 10 +- .../Rulesets/Edit/Checks/CheckTitleMarkers.cs | 6 +- .../Edit/Checks/CheckTooShortAudioFiles.cs | 6 +- .../Edit/Checks/CheckUnsnappedObjects.cs | 4 +- .../Edit/Checks/CheckUnusedAudioAtEnd.cs | 20 +- .../Edit/Checks/CheckVideoResolution.cs | 8 +- .../Rulesets/Edit/Checks/CheckVideoUsage.cs | 137 +++++ .../Edit/Checks/CheckZeroByteFiles.cs | 6 +- .../Edit/Checks/CheckZeroLengthObjects.cs | 2 +- .../Edit/Checks/Components/AudioCheckUtils.cs | 4 +- .../Edit/Checks/Components/CheckMetadata.cs | 8 +- .../Edit/Checks/Components/CheckScope.cs | 23 + .../Checks/Components/ResourcesCheckUtils.cs | 50 ++ .../Checks/Components/TimingCheckUtils.cs | 38 ++ .../Rulesets/Mods/ModAccuracyChallenge.cs | 4 + osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs | 4 + osu.Game/Rulesets/Mods/ModAutoplay.cs | 2 +- osu.Game/Rulesets/Mods/ModBarrelRoll.cs | 3 + osu.Game/Rulesets/Mods/ModClassic.cs | 3 +- osu.Game/Rulesets/Mods/ModDaycore.cs | 3 +- osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs | 3 +- osu.Game/Rulesets/Mods/ModFlashlight.cs | 58 +- osu.Game/Rulesets/Mods/ModHalfTime.cs | 2 +- osu.Game/Rulesets/Mods/ModMirror.cs | 4 + osu.Game/Rulesets/Mods/ModMuted.cs | 3 +- osu.Game/Rulesets/Mods/ModNoMod.cs | 3 +- osu.Game/Rulesets/Mods/ModNoScope.cs | 3 +- osu.Game/Rulesets/Mods/ModRandom.cs | 2 +- osu.Game/Rulesets/Mods/ModScoreV2.cs | 3 + osu.Game/Rulesets/Mods/ModSynesthesia.cs | 3 + osu.Game/Rulesets/Mods/ModTouchDevice.cs | 2 +- osu.Game/Rulesets/Mods/ModType.cs | 4 +- osu.Game/Rulesets/Mods/ModWindDown.cs | 3 +- osu.Game/Rulesets/Mods/ModWindUp.cs | 3 +- osu.Game/Rulesets/Ruleset.cs | 29 +- osu.Game/Rulesets/Scoring/HealthProcessor.cs | 11 +- osu.Game/Rulesets/UI/DrawableRuleset.cs | 20 - .../Rulesets/UI/FrameStabilityContainer.cs | 26 +- osu.Game/Rulesets/UI/ModIcon.cs | 8 +- osu.Game/Rulesets/UI/ModSwitchSmall.cs | 5 +- .../Legacy/LegacyReplaySoloScoreInfo.cs | 4 + osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs | 3 + osu.Game/Scoring/ScoreInfo.cs | 2 + osu.Game/Scoring/ScoreInfoExtensions.cs | 32 + .../Backgrounds/EditorBackgroundScreen.cs | 19 +- osu.Game/Screens/Edit/BindableBeatDivisor.cs | 5 + osu.Game/Screens/Edit/Editor.cs | 2 +- .../Screens/Edit/Setup/MetadataSection.cs | 2 +- .../Submission/BeatmapSubmissionScreen.cs | 7 + .../Submission/ScreenSubmissionSettings.cs | 2 - osu.Game/Screens/Edit/Verify/IssueList.cs | 16 +- osu.Game/Screens/Edit/Verify/IssueSettings.cs | 1 + osu.Game/Screens/Edit/Verify/ScopeSection.cs | 27 + osu.Game/Screens/Edit/Verify/VerifyScreen.cs | 2 + osu.Game/Screens/Footer/ScreenFooter.cs | 6 + osu.Game/Screens/Import/FileImportScreen.cs | 53 +- .../Screens/LAsEzExtensions/EzSelectMode.cs | 234 -------- .../LAsEzExtensions/EzSelectModeTab.cs | 168 ------ osu.Game/Screens/Menu/MenuTipDisplay.cs | 39 +- .../Components/StarRatingRangeDisplay.cs | 10 +- .../Multiplayer/MultiplayerPlayer.cs | 10 +- .../Play/HUD/ArgonUnstableRateCounter.cs | 74 +++ .../Play/HUD/DrawableGameplayLeaderboard.cs | 16 +- .../HUD/DrawableGameplayLeaderboardScore.cs | 560 +++++++++--------- .../Screens/Play/HUD/UnstableRateCounter.cs | 69 +-- osu.Game/Screens/Play/Player.cs | 98 +-- osu.Game/Screens/Play/PlayerConfiguration.cs | 6 - osu.Game/Screens/Play/PlayerLoader.cs | 18 +- .../PlayerSettings/BeatmapOffsetControl.cs | 244 +++++--- osu.Game/Screens/Play/ReplayFailIndicator.cs | 174 ++++++ osu.Game/Screens/Play/ReplayPlayer.cs | 67 ++- osu.Game/Screens/Play/SubmittingPlayer.cs | 23 +- osu.Game/Screens/Ranking/CollectionPopover.cs | 3 +- osu.Game/Screens/Ranking/SoloResultsScreen.cs | 20 +- .../Ranking/Statistics/SimpleStatisticItem.cs | 21 +- .../Ranking/Statistics/StatisticsPanel.cs | 26 +- osu.Game/Screens/Ranking/UserTagControl.cs | 29 +- .../Select/Carousel/CarouselBeatmap.cs | 14 + .../Carousel/DrawableCarouselBeatmap.cs | 2 +- .../Screens/Select/Details/AdvancedStats.cs | 155 +++-- .../Screens/Select/Filter/EzToCollection.txt | 68 +++ osu.Game/Screens/Select/Filter/GroupMode.cs | 43 +- osu.Game/Screens/Select/Filter/SortMode.cs | 35 +- osu.Game/Screens/Select/FilterControl.cs | 137 +---- osu.Game/Screens/Select/FilterCriteria.cs | 66 ++- osu.Game/Screens/Select/FilterQueryParser.cs | 63 +- .../Leaderboards/BeatmapLeaderboardScope.cs | 13 +- .../IGameplayLeaderboardProvider.cs | 3 + .../Leaderboards/LeaderboardSortMode.cs | 26 + .../PlaylistsGameplayLeaderboardProvider.cs | 32 +- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 54 +- .../SelectV2/BeatmapCarouselFilterGrouping.cs | 128 +++- .../SelectV2/BeatmapCarouselFilterMatching.cs | 17 +- .../SelectV2/BeatmapCarouselFilterSorting.cs | 4 + .../Screens/SelectV2/BeatmapDetailsArea.cs | 7 + .../SelectV2/BeatmapDetailsArea_Header.cs | 96 +-- .../BeatmapDetailsArea_WedgeSelector.cs | 3 +- .../SelectV2/BeatmapLeaderboardScore.cs | 3 +- .../SelectV2/BeatmapLeaderboardWedge.cs | 93 ++- .../Screens/SelectV2/BeatmapMetadataWedge.cs | 123 ++-- .../BeatmapMetadataWedge_MetadataDisplay.cs | 8 +- .../SelectV2/BeatmapMetadataWedge_TagsLine.cs | 20 +- .../Screens/SelectV2/BeatmapTitleWedge.cs | 67 +-- .../BeatmapTitleWedge_DifficultyDisplay.cs | 67 +-- ...pTitleWedge_DifficultyStatisticsDisplay.cs | 11 +- .../BeatmapTitleWedge_FavouriteButton.cs | 167 +++++- .../BeatmapTitleWedge_StatisticDifficulty.cs | 16 +- .../BeatmapTitleWedge_StatisticPlayCount.cs | 5 +- .../Screens/SelectV2/CollectionDropdown.cs | 22 +- .../Components/KeyModeFilterTabControl.cs | 379 ------------ osu.Game/Screens/SelectV2/EzSelectMode.cs | 117 ++++ .../SelectV2/EzSelectTab_CircleSize.cs | 479 +++++++++++++++ osu.Game/Screens/SelectV2/FilterControl.cs | 163 +++-- osu.Game/Screens/SelectV2/FooterButtonMods.cs | 2 +- .../Screens/SelectV2/FooterButtonOptions.cs | 3 +- .../SelectV2/FooterButtonOptions_Popover.cs | 2 +- .../Screens/SelectV2/FooterButtonRandom.cs | 7 +- .../Screens/SelectV2/NoResultsPlaceholder.cs | 8 +- osu.Game/Screens/SelectV2/Panel.cs | 101 ++-- osu.Game/Screens/SelectV2/PanelBeatmap.cs | 237 ++++---- osu.Game/Screens/SelectV2/PanelBeatmapSet.cs | 46 +- .../SelectV2/PanelBeatmapStandalone.cs | 196 +++--- osu.Game/Screens/SelectV2/PanelGroup.cs | 49 +- .../SelectV2/PanelGroupStarDifficulty.cs | 46 +- .../Screens/SelectV2/PanelLocalRankDisplay.cs | 2 +- .../Screens/SelectV2/PanelSetBackground.cs | 34 +- .../SelectV2/PanelUpdateBeatmapButton.cs | 12 +- ...tesDisplay.cs => Panel_ManiaKpcDisplay.cs} | 6 +- ...KpsDisplay.cs => Panel_ManiaKpsDisplay.cs} | 6 +- .../RealmPopulatingOnlineLookupSource.cs | 116 ++++ osu.Game/Screens/SelectV2/SoloSongSelect.cs | 2 +- osu.Game/Screens/SelectV2/SongSelect.cs | 151 ++++- osu.Game/Screens/SelectV2/WedgeBackground.cs | 4 +- .../Components/BeatmapAttributeText.cs | 13 +- .../Skinning/LegacyDefaultComboCounter.cs | 12 +- osu.Game/Skinning/Skin.cs | 1 + .../Triangles/TrianglesUnstableRateCounter.cs | 79 +++ osu.Game/Tests/Gameplay/TestGameplayState.cs | 5 +- osu.Game/Tests/Visual/PlayerTestScene.cs | 8 - osu.Game/Users/ExtendedUserPanel.cs | 22 +- osu.iOS.props | 2 +- osu.iOS/OsuGameIOS.cs | 12 +- osu.iOS/osu.iOS.csproj | 18 +- test_example.cs | 0 468 files changed, 15777 insertions(+), 4142 deletions(-) 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 comparison_example.cs create mode 100644 fixed.txt create mode 100644 nested_vs_toplevel_example.cs delete mode 100644 osu.Desktop/osu!.res create mode 100644 osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModMovingFast.cs create mode 100644 osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs create mode 100644 osu.Game.Rulesets.Catch/Mods/CatchModMovingFast.cs create mode 100644 osu.Game.Rulesets.LazerMania.Tests/TestSceneLazerManiaPlayer.cs create mode 100644 osu.Game.Rulesets.LazerMania.Tests/osu.Game.Rulesets.LazerMania.Tests.csproj create mode 100644 osu.Game.Rulesets.LazerMania/Beatmaps/LazerManiaBeatmap.cs create mode 100644 osu.Game.Rulesets.LazerMania/Beatmaps/LazerManiaBeatmapConverter.cs create mode 100644 osu.Game.Rulesets.LazerMania/Beatmaps/StageDefinition.cs create mode 100644 osu.Game.Rulesets.LazerMania/Configuration/LazerManiaRulesetConfigManager.cs create mode 100644 osu.Game.Rulesets.LazerMania/Difficulty/LazerManiaDifficultyAttributes.cs create mode 100644 osu.Game.Rulesets.LazerMania/Difficulty/LazerManiaDifficultyCalculator.cs create mode 100644 osu.Game.Rulesets.LazerMania/Difficulty/LazerManiaPerformanceCalculator.cs create mode 100644 osu.Game.Rulesets.LazerMania/Edit/Blueprints/LazerManiaComposeBlueprints.cs create mode 100644 osu.Game.Rulesets.LazerMania/Edit/Blueprints/LazerManiaSelectionHandler.cs create mode 100644 osu.Game.Rulesets.LazerMania/Edit/LazerManiaBeatmapVerifier.cs create mode 100644 osu.Game.Rulesets.LazerMania/Edit/LazerManiaHitObjectComposer.cs create mode 100644 osu.Game.Rulesets.LazerMania/Judgements/LazerManiaJudgement.cs create mode 100644 osu.Game.Rulesets.LazerMania/LazerManiaAction.cs create mode 100644 osu.Game.Rulesets.LazerMania/LazerManiaInputManager.cs create mode 100644 osu.Game.Rulesets.LazerMania/LazerManiaRuleset.cs create mode 100644 osu.Game.Rulesets.LazerMania/LazerManiaRuleset.cs.new create mode 100644 osu.Game.Rulesets.LazerMania/LazerManiaSettingsSubsection.cs create mode 100644 osu.Game.Rulesets.LazerMania/Mods/LazerManiaModsBasic.cs create mode 100644 osu.Game.Rulesets.LazerMania/Mods/LazerManiaModsSpecial.cs create mode 100644 osu.Game.Rulesets.LazerMania/Objects/Drawables/DrawableLazerManiaHitObject.cs create mode 100644 osu.Game.Rulesets.LazerMania/Objects/LazerManiaHitObject.cs create mode 100644 osu.Game.Rulesets.LazerMania/Objects/LazerManiaHitObjectLifetimeEntry.cs create mode 100644 osu.Game.Rulesets.LazerMania/Objects/LazerManiaNote.cs create mode 100644 osu.Game.Rulesets.LazerMania/Properties/launchSettings.json create mode 100644 osu.Game.Rulesets.LazerMania/Replays/LazerManiaFramedReplayInputHandler.cs create mode 100644 osu.Game.Rulesets.LazerMania/Replays/LazerManiaReplayFrame.cs create mode 100644 osu.Game.Rulesets.LazerMania/Scoring/LazerManiaHitWindows.cs create mode 100644 osu.Game.Rulesets.LazerMania/SingleStageVariantGenerator.cs create mode 100644 osu.Game.Rulesets.LazerMania/Skinning/Legacy/LazerManiaLegacySkinTransformer.cs create mode 100644 osu.Game.Rulesets.LazerMania/UI/DrawableLazerManiaNote.cs create mode 100644 osu.Game.Rulesets.LazerMania/UI/DrawableLazerManiaRuleset.cs create mode 100644 osu.Game.Rulesets.LazerMania/UI/LazerManiaInputManager.cs create mode 100644 osu.Game.Rulesets.LazerMania/UI/LazerManiaPlayfield.cs create mode 100644 osu.Game.Rulesets.LazerMania/UI/LazerManiaStage.cs create mode 100644 osu.Game.Rulesets.LazerMania/UI/ManiaScrollVisualisationMethod.cs create mode 100644 osu.Game.Rulesets.LazerMania/UI/PlayfieldBoundsConstraint.cs create mode 100644 osu.Game.Rulesets.LazerMania/VariantMappingGenerator.cs create mode 100644 osu.Game.Rulesets.LazerMania/VariantMappingGenerator.cs.new create mode 100644 osu.Game.Rulesets.LazerMania/osu.Game.Rulesets.LazerMania.csproj create mode 100644 osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider-expected-conversion.json create mode 100644 osu.Game.Rulesets.Mania.Tests/Resources/Testing/Beatmaps/mania-slider.osu create mode 100644 osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs create mode 100644 osu.Game.Rulesets.Osu.Tests/Resources/old-skin/sliderpoint10.png create mode 100644 osu.Game.Rulesets.Osu.Tests/Resources/old-skin/sliderpoint30.png create mode 100644 osu.Game.Rulesets.Osu.Tests/TestSceneAimErrorMeter.cs create mode 100644 osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgementSliderTicks.cs create mode 100644 osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs create mode 100644 osu.Game.Rulesets.Osu/HUD/AimErrorMeter.cs create mode 100644 osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyJudgementPieceSliderTickHit.cs create mode 100644 osu.Game.Rulesets.Taiko.Tests/Editor/Checks/CheckTaikoInconsistentSkipBarLineTest.cs create mode 100644 osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoInconsistentSkipBarLine.cs create mode 100644 osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs create mode 100644 osu.Game.Tests/Editing/Checks/CheckInconsistentAudioTest.cs create mode 100644 osu.Game.Tests/Editing/Checks/CheckInconsistentMetadataTest.cs create mode 100644 osu.Game.Tests/Editing/Checks/CheckInconsistentSettingsTest.cs create mode 100644 osu.Game.Tests/Editing/Checks/CheckInconsistentTimingControlPointsTest.cs create mode 100644 osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs create mode 100644 osu.Game.Tests/Editing/Checks/CheckMissingGenreLanguageTest.cs create mode 100644 osu.Game.Tests/Editing/Checks/CheckVideoUsageTest.cs create mode 100644 osu.Game.Tests/Resources/Archives/modified-argon-20250809.osk create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardSorting.cs create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs create mode 100644 osu.Game/Graphics/Carousel/Carousel_ScrollContainer.cs create mode 100644 osu.Game/Localisation/BeatmapLeaderboardWedgeStrings.cs create mode 100644 osu.Game/Localisation/CollectionsStrings.cs create mode 100644 osu.Game/Localisation/HUD/AimErrorMeterStrings.cs create mode 100644 osu.Game/Localisation/ReplayFailIndicatorStrings.cs delete mode 100644 osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs delete mode 100644 osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs create mode 100644 osu.Game/Overlays/Mods/BeatmapAttributeTooltip.cs create mode 100644 osu.Game/Rulesets/Difficulty/RulesetBeatmapDifficulty.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckInconsistentAudio.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckInconsistentMetadata.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckInconsistentSettings.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckInconsistentTimingControlPoints.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckMissingGenreLanguage.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckVideoUsage.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/Components/CheckScope.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/Components/ResourcesCheckUtils.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/Components/TimingCheckUtils.cs create mode 100644 osu.Game/Screens/Edit/Verify/ScopeSection.cs delete mode 100644 osu.Game/Screens/LAsEzExtensions/EzSelectMode.cs delete mode 100644 osu.Game/Screens/LAsEzExtensions/EzSelectModeTab.cs create mode 100644 osu.Game/Screens/Play/HUD/ArgonUnstableRateCounter.cs create mode 100644 osu.Game/Screens/Play/ReplayFailIndicator.cs create mode 100644 osu.Game/Screens/Select/Filter/EzToCollection.txt create mode 100644 osu.Game/Screens/Select/Leaderboards/LeaderboardSortMode.cs delete mode 100644 osu.Game/Screens/SelectV2/Components/KeyModeFilterTabControl.cs create mode 100644 osu.Game/Screens/SelectV2/EzSelectMode.cs create mode 100644 osu.Game/Screens/SelectV2/EzSelectTab_CircleSize.cs rename osu.Game/Screens/SelectV2/{Components/ColumnNotesDisplay.cs => Panel_ManiaKpcDisplay.cs} (98%) rename osu.Game/Screens/SelectV2/{Components/KpsDisplay.cs => Panel_ManiaKpsDisplay.cs} (98%) create mode 100644 osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs create mode 100644 osu.Game/Skinning/Triangles/TrianglesUnstableRateCounter.cs create mode 100644 test_example.cs diff --git a/.idea/.idea.osu.Desktop/.idea/indexLayout.xml b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml index 15b401e4b2..7b08163ceb 100644 --- a/.idea/.idea.osu.Desktop/.idea/indexLayout.xml +++ b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml @@ -3,7 +3,6 @@ - - + \ 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 1d4a38a072..3de04b744c 100644 --- a/.idea/.idea.osu.Desktop/.idea/vcs.xml +++ b/.idea/.idea.osu.Desktop/.idea/vcs.xml @@ -12,6 +12,5 @@ - \ No newline at end of file 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/comparison_example.cs b/comparison_example.cs new file mode 100644 index 0000000000..ba249525cb --- /dev/null +++ b/comparison_example.cs @@ -0,0 +1,34 @@ +namespace Example +{ + // 主文件 + public partial class FilterControl + { + private string privateField = "只有嵌套类能访问"; + private void privateMethod() { } + } + + // 情况1:正确的嵌套类写法 + public partial class FilterControl + { + public partial class DifficultyRangeSlider + { + public void AccessParent() + { + // ✅ 可以访问外层类的私有成员 + var field = privateField; // 编译成功 + privateMethod(); // 编译成功 + } + } + } + + // 情况2:错误的独立类写法 + public partial class DifficultyRangeSlider // 这是独立的类,不是嵌套类 + { + public void AccessParent() + { + // ❌ 无法访问 FilterControl 的私有成员 + // var field = privateField; // 编译错误! + // privateMethod(); // 编译错误! + } + } +} diff --git a/fixed.txt b/fixed.txt new file mode 100644 index 0000000000..b7a080ebf4 --- /dev/null +++ b/fixed.txt @@ -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; +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Audio; +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 LogLevel = osu.Framework.Logging.LogLevel; +using osu.Game.Skinning.Components; +using osuTK; + +namespace osu.Game.Screens +{ + public partial class EzNoteFactory : CompositeDrawable, IPreviewable + { + public Bindable TextureNameBindable { get; } = new Bindable("evolve"); + public string TextureBasePath { get; } = @"EzResources\note"; + + private readonly TextureStore textureStore; + private readonly EzSkinSettingsManager ezSkinConfig; + + private string? notesPath = string.Empty; + + private const float fps = 60; + + /// + /// 简化后的构造函数,直接使用TextureStore + /// + /// 用于加载纹理的TextureStore + /// 皮肤设置管理器 + /// 自定义纹理路径(可选) + public EzNoteFactory(TextureStore textureStore, EzSkinSettingsManager ezSkinConfig, string? customTexturePath = null) + { + this.textureStore = textureStore; + this.ezSkinConfig = ezSkinConfig; + + if (!string.IsNullOrEmpty(customTexturePath)) + TextureBasePath = customTexturePath; + + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Blending = new BlendingParameters + { + Source = BlendingType.SrcAlpha, + Destination = BlendingType.One, + }; + + Initialize(); + } + private void Initialize() + { + // 我们不再需要获取完整路径和创建目录,因为我们将直接使用TextureStore + // 直接从配置中获取纹理名称 + TextureNameBindable.Value = ezSkinConfig.Get(EzSkinSetting.NoteSetName); + + ezSkinConfig.GetBindable(EzSkinSetting.NoteSetName).BindValueChanged(e => + TextureNameBindable.Value = e.NewValue, true); + + var gif = new DrawableAnimation + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + DefaultFrameLength = 1000 / fps, + Loop = true, + }; + + AddInternal(gif); + } + + // 保留BackgroundDependencyLoader方法来维持兼容性,但它不再必需 + [BackgroundDependencyLoader] + private void load() + { + // 所有初始化逻辑都已移动到Initialize()方法中 + } + + public virtual Drawable CreateAnimation(string component) + { + // 规范化 + string noteSetName = TextureNameBindable.Value; + string normalizedComponent = component.Replace('/', Path.DirectorySeparatorChar); + + var animation = new TextureAnimation + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + DefaultFrameLength = 1000 / fps, + Loop = false + }; + + // 为了适配TextureStore的方式,我们尝试加载序列帧(001.png, 002.png等) + for (int i = 1; i <= 60; i++) // 假设最多60帧 + { + string framePath = $"{TextureBasePath}/{noteSetName}/{normalizedComponent}/{i:D3}"; + var texture = textureStore.Get(framePath); + + if (texture == null) + { + // 如果找不到更多帧,退出循环 + if (i == 1) + Logger.Log($"EzNoteFactory: No frames found for {framePath}", LoggingTarget.Runtime, LogLevel.Warning); + else + Logger.Log($"EzNoteFactory: Found {i-1} frames for {component}", LoggingTarget.Runtime, LogLevel.Debug); + break; + } + + animation.AddFrame(texture); + } + + // 如果没有找到任何帧,尝试加载单张图片 + if (animation.FrameCount == 0) + { + string singleImagePath = $"{TextureBasePath}/{noteSetName}/{normalizedComponent}"; + var texture = textureStore.Get(singleImagePath); + + if (texture != null) + { + Logger.Log($"EzNoteFactory: Loaded single image for {component}", LoggingTarget.Runtime, LogLevel.Debug); + animation.AddFrame(texture); + } + else + { + Logger.Log($"EzNoteFactory: Failed to load any textures for {component}", LoggingTarget.Runtime, LogLevel.Warning); + } + } + + return animation; + } + } +} diff --git a/nested_vs_toplevel_example.cs b/nested_vs_toplevel_example.cs new file mode 100644 index 0000000000..b56dd4a492 --- /dev/null +++ b/nested_vs_toplevel_example.cs @@ -0,0 +1,56 @@ +// 示例:嵌套类 vs 顶级类的区别 + +namespace Example +{ + // ========== 情况1:嵌套类设计(osu! 当前使用) ========== + public partial class FilterControl : OverlayContainer + { + private readonly Dictionary controlState = new(); + private bool isInitialized = false; + + // 嵌套类 - 逻辑上是 FilterControl 的一部分 + public partial class KeyModeFilterTabControl : CompositeDrawable + { + public void AccessParentState() + { + // ✅ 可以访问外层类的私有成员 + // 注意:需要通过外层类实例访问非静态成员 + } + + public void UpdateParentControl(FilterControl parent) + { + // ✅ 可以访问私有成员 + parent.controlState["keyMode"] = "updated"; + if (parent.isInitialized) { /* ... */ } + } + } + + // 其他相关的嵌套类 + public partial class DifficultyRangeSlider : ShearedRangeSlider { } + public partial class SongSelectSearchTextBox : ShearedFilterTextBox { } + } + + // ========== 情况2:顶级类设计(替代方案) ========== + public class FilterControl : OverlayContainer + { + internal readonly Dictionary controlState = new(); // 必须改为 internal + internal bool isInitialized = false; // 必须改为 internal + } + + // 独立的顶级类 + public class KeyModeFilterTabControl : CompositeDrawable + { + public void UpdateParentControl(FilterControl parent) + { + // ✅ 只能访问 internal/public 成员 + parent.controlState["keyMode"] = "updated"; + if (parent.isInitialized) { /* ... */ } + + // ❌ 无法访问 private 成员 + // parent.somePrivateField = value; // 编译错误 + } + } + + public class DifficultyRangeSlider : ShearedRangeSlider { } + public class SongSelectSearchTextBox : ShearedFilterTextBox { } +} diff --git a/osu.Android.props b/osu.Android.props index 0509d86b0a..40a9b454ce 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + + $([System.String]::Copy('$(Version)').Split('-')[0]) + + $(VersionNoSuffix) + $(VersionNoSuffix) @@ -18,4 +22,14 @@ + + + + $(AppBundleDir)/Info.plist + OsuVersion + + + diff --git a/test_example.cs b/test_example.cs new file mode 100644 index 0000000000..e69de29bb2