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