// 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 JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osuTK.Graphics; namespace osu.Game.Skinning { public class LegacySkin : Skin { [CanBeNull] protected TextureStore Textures; [CanBeNull] protected ISampleStore Samples; /// /// Whether texture for the keys exists. /// Used to determine if the mania ruleset is skinned. /// private readonly Lazy hasKeyTexture; protected virtual bool AllowManiaSkin => hasKeyTexture.Value; /// /// 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; public new LegacySkinConfiguration Configuration { get => base.Configuration as LegacySkinConfiguration; set => base.Configuration = value; } private readonly Dictionary maniaConfigurations = new Dictionary(); [CanBeNull] private readonly DefaultLegacySkin legacyDefaultFallback; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] public LegacySkin(SkinInfo skin, IStorageResourceProvider resources) : this(skin, new LegacySkinResourceStore(skin, resources.Files), resources, "skin.ini") { } /// /// Construct a new legacy skin instance. /// /// The model for this skin. /// A storage for looking up files within this skin using user-facing filenames. /// Access to raw game resources. /// The user-facing filename of the configuration file to be parsed. Can accept an .osu or skin.ini file. protected LegacySkin(SkinInfo skin, [CanBeNull] IResourceStore storage, [CanBeNull] IStorageResourceProvider resources, string configurationFilename) : base(skin, resources) { if (resources != null) legacyDefaultFallback = CreateFallbackSkin(storage, resources); using (var stream = storage?.GetStream(configurationFilename)) { if (stream != null) { using (LineBufferedReader reader = new LineBufferedReader(stream, true)) Configuration = new LegacySkinDecoder().Decode(reader); 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; } } else Configuration = new LegacySkinConfiguration(); } if (storage != null) { var samples = resources?.AudioManager?.GetSampleStore(storage); if (samples != null) samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; Samples = samples; Textures = new TextureStore(resources?.CreateTextureLoaderStore(storage)); (storage as ResourceStore)?.AddExtension("ogg"); } // todo: this shouldn't really be duplicated here (from ManiaLegacySkinTransformer). we need to come up with a better solution. hasKeyTexture = new Lazy(() => this.GetAnimation( lookupForMania(new LegacyManiaSkinConfigurationLookup(4, LegacyManiaSkinConfigurationLookups.KeyImage, 0))?.Value ?? "mania-key1", true, true) != null); } [CanBeNull] protected virtual DefaultLegacySkin CreateFallbackSkin(IResourceStore storage, IStorageResourceProvider resources) => new DefaultLegacySkin(resources); public override IBindable GetConfig(TLookup lookup) { 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 SkinCustomColourLookup customColour: return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString())); case LegacyManiaSkinConfigurationLookup maniaLookup: if (!AllowManiaSkin) break; var result = lookupForMania(maniaLookup); if (result != null) return result; break; case LegacySkinConfiguration.LegacySetting legacy: return legacySettingLookup(legacy); default: return genericLookup(lookup); } return legacyDefaultFallback?.GetConfig(lookup); } private IBindable lookupForMania(LegacyManiaSkinConfigurationLookup maniaLookup) { if (!maniaConfigurations.TryGetValue(maniaLookup.Keys, out var existing)) maniaConfigurations[maniaLookup.Keys] = existing = new LegacyManiaSkinConfiguration(maniaLookup.Keys); switch (maniaLookup.Lookup) { case LegacyManiaSkinConfigurationLookups.ColumnWidth: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value])); case LegacyManiaSkinConfigurationLookups.ColumnSpacing: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.TargetColumn.Value])); case LegacyManiaSkinConfigurationLookups.HitPosition: return SkinUtils.As(new Bindable(existing.HitPosition)); case LegacyManiaSkinConfigurationLookups.ScorePosition: return SkinUtils.As(new Bindable(existing.ScorePosition)); case LegacyManiaSkinConfigurationLookups.LightPosition: return SkinUtils.As(new Bindable(existing.LightPosition)); case LegacyManiaSkinConfigurationLookups.ShowJudgementLine: return SkinUtils.As(new Bindable(existing.ShowJudgementLine)); case LegacyManiaSkinConfigurationLookups.ExplosionImage: return SkinUtils.As(getManiaImage(existing, "LightingN")); case LegacyManiaSkinConfigurationLookups.ExplosionScale: Debug.Assert(maniaLookup.TargetColumn != null); if (GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); if (existing.ExplosionWidth[maniaLookup.TargetColumn.Value] != 0) return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.ColumnLineColour: return SkinUtils.As(getCustomColour(existing, "ColourColumnLine")); case LegacyManiaSkinConfigurationLookups.JudgementLineColour: return SkinUtils.As(getCustomColour(existing, "ColourJudgementLine")); case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.TargetColumn + 1}")); case LegacyManiaSkinConfigurationLookups.ColumnLightColour: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.TargetColumn + 1}")); case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); case LegacyManiaSkinConfigurationLookups.NoteImage: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}")); case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}H")); case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}T")); case LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}L")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightImage: return SkinUtils.As(getManiaImage(existing, "LightingL")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: Debug.Assert(maniaLookup.TargetColumn != null); if (GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); if (existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] != 0) return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.KeyImage: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}")); case LegacyManiaSkinConfigurationLookups.KeyImageDown: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}D")); case LegacyManiaSkinConfigurationLookups.LeftStageImage: return SkinUtils.As(getManiaImage(existing, "StageLeft")); case LegacyManiaSkinConfigurationLookups.RightStageImage: return SkinUtils.As(getManiaImage(existing, "StageRight")); case LegacyManiaSkinConfigurationLookups.BottomStageImage: return SkinUtils.As(getManiaImage(existing, "StageBottom")); case LegacyManiaSkinConfigurationLookups.LightImage: return SkinUtils.As(getManiaImage(existing, "StageLight")); case LegacyManiaSkinConfigurationLookups.HitTargetImage: return SkinUtils.As(getManiaImage(existing, "StageHint")); case LegacyManiaSkinConfigurationLookups.LeftLineWidth: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value])); case LegacyManiaSkinConfigurationLookups.RightLineWidth: Debug.Assert(maniaLookup.TargetColumn != null); return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value + 1])); case LegacyManiaSkinConfigurationLookups.Hit0: case LegacyManiaSkinConfigurationLookups.Hit50: case LegacyManiaSkinConfigurationLookups.Hit100: case LegacyManiaSkinConfigurationLookups.Hit200: case LegacyManiaSkinConfigurationLookups.Hit300: case LegacyManiaSkinConfigurationLookups.Hit300g: return SkinUtils.As(getManiaImage(existing, maniaLookup.Lookup.ToString())); case LegacyManiaSkinConfigurationLookups.KeysUnderNotes: return SkinUtils.As(new Bindable(existing.KeysUnderNotes)); } return null; } private IBindable getCustomColour(IHasCustomColours source, string lookup) => source.CustomColours.TryGetValue(lookup, out var col) ? new Bindable(col) : null; private IBindable getManiaImage(LegacyManiaSkinConfiguration source, string lookup) => source.ImageLookups.TryGetValue(lookup, out var image) ? new Bindable(image) : null; [CanBeNull] private IBindable legacySettingLookup(LegacySkinConfiguration.LegacySetting legacySetting) { switch (legacySetting) { case LegacySkinConfiguration.LegacySetting.Version: return SkinUtils.As(new Bindable(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION)); default: return genericLookup(legacySetting); } } [CanBeNull] private IBindable genericLookup(TLookup lookup) { try { if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString(), out var val)) { // special case for handling skins which use 1 or 0 to signify a boolean state. if (typeof(TValue) == typeof(bool)) val = val == "1" ? "true" : "false"; var bindable = new Bindable(); if (val != null) bindable.Parse(val); return bindable; } } catch { } return legacyDefaultFallback?.GetConfig(lookup); } public override Drawable GetDrawableComponent(ISkinComponent component) { if (base.GetDrawableComponent(component) is Drawable c) return c; switch (component) { case SkinnableTargetComponent target: switch (target.Target) { case SkinnableTarget.MainHUDComponents: var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); var combo = container.OfType().FirstOrDefault(); if (score != null && accuracy != null) { accuracy.Y = container.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; } var songProgress = container.OfType().FirstOrDefault(); var hitError = container.OfType().FirstOrDefault(); if (hitError != null) { hitError.Anchor = Anchor.BottomCentre; hitError.Origin = Anchor.CentreLeft; hitError.Rotation = -90; } if (songProgress != null) { if (hitError != null) hitError.Y -= SongProgress.MAX_HEIGHT; if (combo != null) combo.Y -= SongProgress.MAX_HEIGHT; } }) { Children = new[] { // TODO: these should fallback to the osu!classic skin. GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ComboCounter)) ?? new DefaultComboCounter(), GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ScoreCounter)) ?? new DefaultScoreCounter(), GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter)) ?? new DefaultAccuracyCounter(), GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.HealthDisplay)) ?? new DefaultHealthDisplay(), GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.SongProgress)) ?? new SongProgress(), GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.BarHitErrorMeter)) ?? new BarHitErrorMeter(), } }; return skinnableTargetWrapper; } return null; case GameplaySkinComponent sampleComponent: var applause = GetSample(new SampleInfo("applause")); switch (sampleComponent.Component) { case GameplaySkinSamples.Applause: if (applause != null) return new DrawableSample(applause); break; case GameplaySkinSamples.ResultScoreTick: case GameplaySkinSamples.ResultBadgeTick: case GameplaySkinSamples.ResultBadgeTickMax: case GameplaySkinSamples.ResultSwooshUp: case GameplaySkinSamples.ResultRank_D: case GameplaySkinSamples.ResultRank_B: case GameplaySkinSamples.ResultRank_C: case GameplaySkinSamples.ResultRank_A: case GameplaySkinSamples.ResultRank_S: case GameplaySkinSamples.ResultRank_SS: case GameplaySkinSamples.ResultApplause_D: case GameplaySkinSamples.ResultApplause_B: case GameplaySkinSamples.ResultApplause_C: case GameplaySkinSamples.ResultApplause_A: case GameplaySkinSamples.ResultApplause_S: case GameplaySkinSamples.ResultApplause_SS: if (applause != null) // Legacy skins don't have sounds for the result screen, but may instead have an 'applause' sound. // This lets a legacy skin's applause sound play instead of result screen sounds (as to not play over each other) return Drawable.Empty(); break; } break; case HUDSkinComponent hudComponent: { if (!this.HasFont(LegacyFont.Score)) return null; switch (hudComponent.Component) { case HUDSkinComponents.ComboCounter: return new LegacyComboCounter(); case HUDSkinComponents.ScoreCounter: return new LegacyScoreCounter(); case HUDSkinComponents.AccuracyCounter: return new LegacyAccuracyCounter(); case HUDSkinComponents.HealthDisplay: return new LegacyHealthDisplay(); } return null; } case GameplaySkinComponent resultComponent: Func createDrawable = () => getJudgementAnimation(resultComponent.Component); // kind of wasteful that we throw this away, but should do for now. if (createDrawable() != null) { var particle = getParticleTexture(resultComponent.Component); if (particle != null) return new LegacyJudgementPieceNew(resultComponent.Component, createDrawable, particle); else return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); } break; } var animation = this.GetAnimation(component.LookupName, false, false); if (animation != null) return animation; return legacyDefaultFallback?.GetDrawableComponent(component); } private Texture getParticleTexture(HitResult result) { switch (result) { case HitResult.Meh: return GetTexture("particle50"); case HitResult.Ok: return GetTexture("particle100"); case HitResult.Great: return GetTexture("particle300"); } return null; } private Drawable getJudgementAnimation(HitResult result) { switch (result) { case HitResult.Miss: return this.GetAnimation("hit0", true, false); case HitResult.Meh: return this.GetAnimation("hit50", true, false); case HitResult.Ok: return this.GetAnimation("hit100", true, false); case HitResult.Great: return this.GetAnimation("hit300", true, false); } return null; } public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { foreach (var name in getFallbackNames(componentName)) { float ratio = 2; var texture = Textures?.Get($"{name}@2x", wrapModeS, wrapModeT); if (texture == null) { ratio = 1; texture = Textures?.Get(name, wrapModeS, wrapModeT); } if (texture == null) continue; texture.ScaleAdjust = ratio; return texture; } return legacyDefaultFallback?.GetTexture(componentName, wrapModeS, wrapModeT); } public override ISample GetSample(ISampleInfo sampleInfo) { IEnumerable lookupNames; if (sampleInfo is HitSampleInfo hitSample) lookupNames = getLegacyLookupNames(hitSample); else { lookupNames = sampleInfo.LookupNames.SelectMany(getFallbackNames); } foreach (var lookup in lookupNames) { var sample = Samples?.Get(lookup); if (sample != null) return sample; } return legacyDefaultFallback?.GetSample(sampleInfo); } public override ISkin FindProvider(Func lookupFunction) { var source = base.FindProvider(lookupFunction); if (source != null) return source; return legacyDefaultFallback?.FindProvider(lookupFunction); } private IEnumerable getLegacyLookupNames(HitSampleInfo hitSample) { var lookupNames = hitSample.LookupNames.SelectMany(getFallbackNames); 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 (var 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 getFallbackNames(string componentName) { // May be something like "Gameplay/osu/approachcircle" from lazer, or "Arrows/note1" from a user skin. yield return componentName; // Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/osu/approachcircle" -> "approachcircle"). yield return componentName.Split('/').Last(); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); Textures?.Dispose(); Samples?.Dispose(); } } }