Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement skinning for ruleset components + mania's ColumnStart #27773

Closed
wants to merge 38 commits into from

Conversation

smoogipoo
Copy link
Contributor

Resolves #20252

ISerialisableDrawables that are part of SkinnableDrawbles can now be skinned.

2024-04-03.04-41-42.mp4

To demonstrate, I've implemented (and bundled) legacy and non-legacy skinning of mania's ColumnStart config. Note that this implementation is implemented as an overlay component because the Stage itself is not easily skinnable.

I created this diagram to visualise the structure. Maybe it'll be useful to someone else...
image

Comment on lines -15 to -17
protected override string RulesetPrefix => "catch"; // todo: use CatchRuleset.SHORT_NAME;

protected override string ComponentName => Component.ToString().ToLowerInvariant();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all of these GameplaySkinComponentLookup classes, these properties are unused - they were likely from a different time.

Comment on lines 45 to 51
// Lift TestRuleset and ToolboxInvisibleComponent into an isolated assembly containing both types at a top level.
AssemblyBuilder asmBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(Guid.NewGuid().ToString()), AssemblyBuilderAccess.Run);
ModuleBuilder moduleBuilder = asmBuilder.DefineDynamicModule(asmBuilder.GetName().Name!);

moduleBuilder.DefineType($"{nameof(ToolboxInvisibleComponent)}", TypeAttributes.Public | TypeAttributes.Class, typeof(ToolboxInvisibleComponent)).CreateType();
TypeBuilder rulesetType = moduleBuilder.DefineType("MockRuleset", TypeAttributes.Public | TypeAttributes.Class, typeof(TestRuleset));
Ruleset ruleset = (Ruleset)Activator.CreateInstance(rulesetType.CreateType())!;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these skin editor tests are not very flexible for anything other than integration testing. The intention here was to create an isolated context with the expectation being that this was to be used in more tests from the start, however that didn't end up happening.

However, this is still required specifically because of the "containing both types at a top level" part. See the following code:

public static Type[] GetAllAvailableDrawables(RulesetInfo? ruleset = null)
{
return (ruleset?.CreateInstance().GetType() ?? typeof(OsuGame))
.Assembly.GetTypes()
.Where(t => !t.IsInterface && !t.IsAbstract && t.IsPublic)
.Where(t => typeof(ISerialisableDrawable).IsAssignableFrom(t))
.OrderBy(t => t.Name)
.ToArray();
}

IsPublic == false for nested types, such as ToolboxInvisibleComponent. The alternative is to change this to also check for IsPublicNested, but I wasn't sure whether we want that.

@peppy peppy self-requested a review April 3, 2024 02:52
@bdach
Copy link
Collaborator

bdach commented Apr 3, 2024

Just as a starting position, without even looking at the code very much, but only from interacting with the branch: it was my expectation that this was going to allow for a much narrower set of controls.

Things that feel incorrect include:

  • The stage can be flipped horizontally => silent "mirror" mod

  • The stage can be flipped vertically => basically the upscroll setting in game settings (but more broken because judgement text is also upside-down).

    I actually wouldn't be opposed to removing the upscroll setting and telling people to vertically flip instead - in fact that's what probably should happen in the long run - but it needs more time in the oven. I wouldn't expose that functionality for now.

  • The stage can be rotated - I'm not sure about 90 degree rotations etc. and the impact of that on balance, but what definitely feels wrong is being able to rotate the stage 180 degrees, which is equivalent to vertical + horizontal flip.

I'll probably skim the code now and see how the structure looks, but generally I'd also be more confident in this being the correct direction if I knew the structure was extendable to something even more constrained than the above (i.e. for instance, in my opinion, osu! hitcircles should be selectable but have no movement/resize/rotation controls at all).

@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 19, 2024

Just for the record, I'm going to also look at your second implementation and make a followup post on that as well, but I do not wish to discuss either in this PR.

As I've mentioned to you privately, the way in which this entire thing was approached has been totally backwards. It would have been better to discuss this and what we want out of the skinning system first rather than misunderstanding intentions and implementing something completely orthogonal.

@peppy
Copy link
Sponsor Member

peppy commented Apr 19, 2024

I didn't focus that much on making attempt 1 work because attempt 2 was far better (as I hoped to touch on). I've since fixed multiple issues up with that so would appreciate you giving it a look.

Why do DefaultStageConfiguration and LegacyStageConfiguration exist if they aren't used. Were they left over or are they intended to do something with further implementation?

Yeah just leftover, I've cleaned this up in the second attempt branch.

The if (drawable is DefaultStageConfiguration stage) check in the legacy transformer cannot exist. Even if (drawable is ManiaStageConfigurationContainer) cannot exist.

Can you elaborate on this one? It does work, so does it conceptually not work for you?

What about multiple keys? Legacy skins have 4k/5k/6k/etc configurations - not every stage in mania is going to be positioned and anchored at the same place. Your lookup method isn't going to work because it relies on type information. Coincidentally, we have something that already handles this - ISkinComponentLookup.

I was aiming to keep things simple without having a lookup type, but this can be added back. The main thing I was trying to demonstrate was the structure and storage I'd see working.

I can imagine multiple ways we could handle multiple key layouts (highly depending on how we want it to be presented to the user), but I don't believe this was covered in this PR and I wasn't focusing on that.

@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 19, 2024

Attempt 2

I'm not going to spend much time on this. Again, it's handling one very specific thing but doesn't do anything to actually support skinning (just configuration). This is not going to work as soon as there's an ISerialisableDrawable with configuration knobs inside it, it will only work with static, always-present, always-global-for-every-skin, configuration.

Also, now there's another layer that does things in a totally different way. Not only do we have SkinnableDrawable that constructs different types, SkinComponentsContainer that basically constructs containers, but now SkinConfigurationApplier that works transparently behind the scenes to apply configuration to everything. To me, this is a mess, especially with the issues that I mentioned in the first paragraph.

@peppy
Copy link
Sponsor Member

peppy commented Apr 19, 2024

This is not going to work as soon as there's an ISerialisableDrawable with configuration knobs inside it, it will only work with static, always-present, always-global-for-every-skin, configuration.

Can you explain why it won't work? Because it did seem to work in testing.

Also, now there's another layer that does things in a totally different way

Yep, that's intentional in the way I structured the proposal.

The existing system defines "targets" (SkinComponentsContainer) where the user can place well defined "components" (ISerialisableDrawable where IsPlaceable == true).

The new system defines "components" which can be configured and (in some cases) moved. These components can exist anywhere (although to keep things simple, let's assume all are within DrawableRuleset for now, which is the Configuration target I added in the proposal).

The configuration layer feels like it will be far simpler to handle separately because the goals are different.


It feels we're still not seeing eye-to-eye on the goals of the system, because your feedback isn't making too much sense to me. Maybe it would be good to start from a clean slate and outline how we want the system to work, and then take things from there?

I'm open to a voice call to go through this.

@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 19, 2024

Attempt 1

I was aiming to keep things simple without having a lookup type, but this can be added back. The main thing I was trying to demonstrate was the structure and storage I'd see working.

It's "simple" until you have to change every other file in skinning in order to do that. My implementation can actually handle this very simply, because from the very beginning the point has always been to treat SkinnableDrawable and SkinComponentsContainer as doing fundamentally the exact same thing. This would be handled directly by ManiaSkinComponentLookup.

That's where my implementation comes from - I'm not doing anything new, I'm using existing structures and everything else comes naturally.

Can you elaborate on this one? It does work, so does it conceptually not work for you?

Conceptually this doesn't work for me.

  • Nothing else deals with drawables in this class.
  • Why are we applying legacy skin lookups on non-legacy skins? Think about what happens when a skin configuration exists in both skin.ini and the json files?
  • It doesn't handle different key count configurations.

When I think of skin configuration, I believe there has to be a legacy skin implementation that deals very specifically with the legacy configuration and legacy components.

Attempt 2

Can you explain why it won't work? Because it did seem to work in testing.

I can tell you that it's not going to work just from reading the code. I haven't actually run it. Here's what I'm referring to:

https://github.com/ppy/osu/compare/master...peppy:osu:skin-configurable-2?expand=1#diff-77393cc84be1b95f04df26234a4f61be366b80c0cdeff56d661b71cdbcc4846dR23-R31

This is your constructor, which iterates once and once ever through all ISerialisableDrawables. At this point, let's say you have a new SkinnableDrawable(, () => new DefaultCirclePiece()), where the matching drawable is the DefaultCirclePiece component. When you switch skins the component then becomes a LegacyCirclePiece(), but it will not show up in that list/iteration. Actually, the issue occurs even earlier than that, because the component is removed as soon as the skin is changed, which means even if you switch back to the same skin the configuration won't be applied.

Also, how is this going to work when SkinnableDrawable() also handles configuration? Are they going to just ricochet off each other and set their own configuration knobs from whatever pathway of the configuration they read from? And we're definitely not removing that because we need it in other areas of the game.

@peppy
Copy link
Sponsor Member

peppy commented Apr 19, 2024

This is your constructor

This was just for simplicity, I'm more intending to iron out the storage and structure than the intricacies of implementation. But let's put that aside – again my branches aren't the "way forward", just intended to show potential structures.


To clarify, the case you're imagining is:

diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs
index bcea33f63c..cb75da7bd3 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs
@@ -9,11 +9,12 @@
 using osu.Game.Rulesets.Objects.Drawables;
 using osu.Game.Rulesets.Osu.Objects;
 using osu.Game.Rulesets.Osu.Objects.Drawables;
+using osu.Game.Skinning;
 using osuTK.Graphics;
 
 namespace osu.Game.Rulesets.Osu.Skinning.Default
 {
-    public partial class MainCirclePiece : CompositeDrawable
+    public partial class MainCirclePiece : CompositeDrawable, IConfigurableDrawable
     {
         private readonly CirclePiece circle;
         private readonly RingPiece ring;
@@ -114,5 +115,12 @@ protected override void Dispose(bool isDisposing)
             if (drawableObject.IsNotNull())
                 drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
         }
+
+        public bool UsesFixedAnchor { get; set; }
+
+        public void ApplyDefaults()
+        {
+            throw new System.NotImplementedException();
+        }
     }
 }

where individual skin types (legacy, argon, triangles) have different settings from each other?

Are there any other cases you have in mind? These will help build a list of requirements.

@bdach
Copy link
Collaborator

bdach commented Apr 19, 2024

I don't necessarily want to butt in too much here and derail this discussion further but I do want to highlight one thing:

My implementation can actually handle this very simply, because from the very beginning the point has always been to treat SkinnableDrawable and SkinComponentsContainer as doing fundamentally the exact same thing

We've kinda already talked about this and to a degree I still don't find myself agreeing with that premise which I think may be a factor as to why reaching a conclusion may be difficult. I think HUD skinning and gameplay element skinning are fundamentally different usecases because while users are free to plaster their HUD with whatever they want on there (which is what SkinComponentsContainer is good and intended for), adding the same sort of extensibility to plain gameplay elements has large implications on factors like performance or fairness to a degree where I am afraid of going down a rabbit hole to need to restrain and optimise such a system properly.

I think that position is consistent with all of my prior reviews and requests therein - I was willing to give this PR a go as a compromise given that it looked like it was viable to bend the existing system into a such a constrained shape, but only as long as there was universal support from all of us here.

@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 19, 2024

I think HUD skinning and gameplay element skinning are fundamentally different usecases because while users are free to plaster their HUD with whatever they want on there

What I brought up in voice is that I believe rulesets should be able to define their own SkinComponentsContainer. There's no reason why someone shouldn't be able to "rice up" their playfield if they want to add stars to the column backgrounds, for example.

If you want to consider fairness then I honestly think we should just scrap skinning from gameplay entirely and make it only configuration knobs. Players are able to inject their own arbitrary textures into hitcircles and cheat with things like taikomania, but players adding sprites to static layers on the screen is the sudden blocker for fairness? I can't accept that.

In osu!stable we have combo bursts for mania, every X combo a pippi (or maria?) pops out from the sides of the screen. I imagine a future in which a player is able to do that:

  • On either side of the playfield.
  • Multiple times per combo burst (you want a rain of pippis? a galaxy of stars? sure!)

performance

No comment.

Rather than defining a separate `DefaultStageConfiguration` and
`LegacyStageConfiguration`, we can create a single-instance
`SkinnableDrawable` which is reused.

The implementation of `reload()` is a little bit temporary at this
stage. The follow-up is to add a `Skin.ConfigureComponent()` or
`Skin.GetConfiguration()`.

Furthermore, the implementations of `Add()` and `Remove()` are no longer
(/were never?) required because the types in this "container" never
change. Undo/redo is handled directly by `SkinEditorChangeHandler`.
@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 19, 2024

I'm not satisfied with either of the two implementations provided above, so I will be continuing this PR. I was hoping to take things in incremental steps rather than refactoring the entire game in one go, but this is what I've been brought to.

I will also begin splitting things out where I find appropriate.

@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 19, 2024

@peppy @bdach please take a look at 4100ed2 and poke me immediately if you are or are not convinced on the direction. As a result of everything above, from now on I will be taking things into my own hands and requesting regular reviews/opinions and vocal feedback on specific things.

The most contentious thing in the PR has been the introduction of the
`base.GetDrawableComponent()` calls. This follows in the footsteps of
"attempt 2" and adds a method specific to configuration.

One question I had to answer is "what about the defaults". Consider the
following:
- The classic skin is curently selected which positions ManiaPlayfield
  with TopLeft anchors and somewhere towards the left of the screen.
- The argon skin is selected which wants Centre anchors and a zeroed
  position.
- A third hypothetical, is that the triangles skin is selected that wants
  totally separate defaults from all of those.

Attempt 2 handled this by exposing an `ApplyDefaults()` method on the
`IConfigurableDrawable` (in this case, `ManiaPlayfield` itself). This
method was implemented something as such:

```
Anchor = Anchors.TopLeft;
Origin = Anchors.TopLeft;
Position = Vector2.Zero;
Rotation = 0;
```

Now I ask you, dear reader, to tell me why these specific properties
need to be reset. Why should `Scale` not also? What about any of the
many other properties of `Drawable`? This logic presents an issue to
me - that there is something, _somewhere_, doing magical things and
we somehow need to be omniscient of that.

In this case, we need to know what each skin transformer wants, as well
as what what the `SkinEditor` is capable of doing. It turns out `Scale`
not being reset is a mistake. Whoops!

I foresee this going wrong in the future as well. If the `SkinEditor`
receives some additional functionality, we will conscious of the fact
that _every single implementation_ will need to be updated to reset that
property. Every usage is _required_ to have this implementation.

A second minor issue, to me, is that this looks very awkward. I imagine
the question would come up at some point of why we need this when we have
the constructor in the first place.
I think that's a very good question, for which the only answer I have is
that "it be like it do". One good property about my original
implementation in this PR is that the `Drawable` was always
reconstructed and had control of its own defaults.
This commit is still not quite as "neat" as that, in my opinion, but at
least it avoids the bigger issue above.
@smoogipoo
Copy link
Contributor Author

@peppy @bdach please also take a look at 2f348cc, it removes the base.GetDrawableComponent() calls and implements my attempt at the configuration from "attempt 2" while resolving issues along the way.

@bdach
Copy link
Collaborator

bdach commented Apr 19, 2024

I'm definitely glad to see the base calls go as they were quite scary to understand complexity-wise but I'll attempt to cast a wider net here because I probably failed to do so the last time properly.

To sum up, if I'm getting the intention correctly, with the latest commit the state of affairs is such that from a skin, you can get:

  • An unconfigured skinnable (GetDrawableComponent()).
  • A structure describing how to configure the skinnable (GetConfiguration()).
  • A texture (GetTexture()).
  • A sample (GetSample()).
  • A legacy config value (GetConfig<TLookup, TValue>()).

Structurally this feels definitely less bendy-breaky than what was here before but I do find myself having a few questions still. Possibly rhetorical, possibly obvious, possibly ones for later.

  • Is it really needed to be able to retrieve an unconfigured drawable? Could configuration be somehow inlined in the drawable retrieval itself? What happens if a drawable forgets to apply its own configuration? (In proposal 2 as it was stated, this was a bit less of an issue because of the SkinConfigurationApplier doing this itself for everything consistently, however flawed it was for not actually looking for new drawables dynamically as things change on screen.)

  • And to begin with, is the separation of configuration retrieval and drawable representation retrieval like that desirable? How is that gonna play out with the whole transformer / skin fallback mechanism? Will it be possible to confuse things when you have config A specified for element X on the user skin, and config B specified for element Y on the beatmap skin, but then they end up mixing in completely undesirable way by applying config B on X?

    Or, conversely, is more granular control of configuration going to be required wherein you'd want to be merging some of config A with some of config B? (This ties into the next question.)

  • Why do two methods of configuration retrieval exist? Is GetConfiguration() supposed to gradually supersede GetConfig<TLookup, TValue>()?

After that, my attention is drawn to the callers of GetConfiguration(). There is currently one, and it's SkinnableDrawable. SkinnableContainers cannot be configured, which may be fine going forward, or it may not. Kinda depends on what we want out of the system. For existing usages we don't want that for sure because there's nothing useful to configure and there will not be, so I don't necessarily disagree with not caring about having to support that for whatever reason going forward.

The last thing I wanna highlight here - because this is probably overlong already - is how this interfaces with the skin editor. The PlayfieldGrid is a plain SkinnableDrawable, which is likely okay - I don't expect it to be anything else. That said, the skin editor needs to find all possible skinnable targets somehow, and it does this by enumerating all ISerialisableDrawableContainer implementors at load of each screen (more or less, this can also be screen change or whatever).

This is going to work for an ever-present drawable like the mania stage, but is probably not going to work for ephemeral objects like hitcircles or mania notes or even like explosions or whatever - because not only is it going to miss new instances as they appear on screen, but every single instance will get its own dropdown entry which feels pretty wrong given that HUD layer containers are single targets grouping multiple skinnables. So I do wonder how this is going to match expectations in those areas, because I'd expect this new skinning system to service these usecases (and, to be clear, the lack of dynamically instantiated skinnable support is already a qualm I had and continue to have with the SkinConfigurationApplier thing, too).

I feel like I've typed up a way overlong nerd essay that won't be read but feedback was requested so here's feedback...? Hopefully it's useful?

@peppy
Copy link
Sponsor Member

peppy commented Apr 19, 2024

Not going to add further review just yet, but

but is probably not going to work for ephemeral objects like hitcircles or mania notes or even like explosions

I'm not sure we want the skin editor to work for these cases. To edit such elements, my plan was to add new "scene"s to the skin editor which have each of the elements on screen (always) to allow editing, rather than having the user try to click them during gameplay.

A ruleset would be able to create one or more scenes to show game components in a grid or otherwise where the user can easily make configuration changes to them.

@peppy
Copy link
Sponsor Member

peppy commented Apr 20, 2024

One reason I was pushing for interfaces (which I mentioned on voice but want to convey in text as I think it is quite important) is that I really dislike relying on base classes / inheritance as a requirement for behaviours.

As one example, I was trying to proof-of-concept adding a setting to SoloGameplayLeaderboard, but can't using this PR's implementation because it necessarily derives from GameplayLeaderboard. Would be interested in thoughts on how such a setting would be implemented. Hopefully not a nested Configuration class.

@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 20, 2024

but can't using this PR's implementation because it necessarily derives from GameplayLeaderboard

You do it just like you add settings to any other ISerialisableDrawables - you wrap it in a SkinnableDrawable: https://gist.github.com/smoogipoo/7decf39d51bf897a23d4dce913a18730

(Keep in mind that SkinnableContainerLookup to me is "GlobalSkinComponents", and I've used it assuming that but it could be its own class or whatever - it makes no difference)

@smoogipoo
Copy link
Contributor Author

smoogipoo commented Apr 20, 2024

Also, I don't want to harp on too much longer than I already have, but this is very similar to how you could also do this with SkinComponentsContainer:
https://gist.github.com/smoogipoo/b37be2b0c403e78095e1c6175066aeb4

I hope this illustrates two things:

  1. That this is not anything new. It is only supporting implementation.
  2. Why I believe SkinComponentsContainer and SkinnableDrawable serve much the same purpose from a developer's point of view (Rename SkinComponentsContainer -> SkinnableContainer #27932), save for of course that one is a container that allows adds/removals.

Encountered this when flipping back and forth between an implementating
using `SkinnableDrawable` and `SkinnableContainer` via the same lookup
key.
@peppy
Copy link
Sponsor Member

peppy commented Apr 21, 2024

I guess if that works, just feels a bit ugly (and not how I really intended SkinnableDrawable to be used originally). I'm sure you can agree that an attached interface is cleaner than this, right? Ignoring that it's not-how-it's-currently-done and adding-a-new-system.

@smoogipoo
Copy link
Contributor Author

What do you want me to say? That less code is prettier? Sure? But does it work?

This PR's taken the energy and motivation I've had to work on this away from me, and I think there are still issues with things like revert to default that would've been handled correctly by my original changes, that I don't possibly see how to handle nicely here.

I'm going to close this PR to reduce my own stress.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

osu!mania playfield position in legacy skins is not applied
3 participants