1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-27 15:30:44 +08:00

Compare commits

..

30 Commits

  • Fix client not sending data relevant to replay to spectator server (#37919)
    - Related to https://github.com/ppy/osu/issues/37818, but of no material
    help to it at this point (too late for that)
    
    As noted in
    https://github.com/ppy/osu/pull/37845#discussion_r3297203361.
    
    Upon comparison of replays recorded by the client and by the server the
    affected fields are: total score without mods, and the list of user
    pauses. Additionally, the date of setting the score may differ -
    server-side it seems to be written with UTC+0 while client-side it's
    written using the local timezone offset. Not really interested in fixing
    that last issue at this time.
    
    Also included is an intentionally loud disclaimer in
    `LegacyScoreEncoder` to tread with caution when treating the class. Not
    sure it'll help, and it's a bit late for it as pretty much every single
    versioning primitive has been ravaged to the brink of unusability, but
    maybe it'll help someone in the future.
    
    This also cleans up an unnecessary nullable on `FrameHeader.Mods` (added
    in https://github.com/ppy/osu/pull/30137). This change can be only done
    if users on releases earlier than 2024.1023.0 can no longer connect to
    spectator server. I leave it to reviewers to determine this as I have no
    visibility over current spectator server configuration. Inspecting the
    `osu_builds` table may help confirm this. If it provokes unease, I can
    back this change out.
  • Replace usages of Mod.ScoreMultiplier with new score multiplier API (#37845)
    - Part of https://github.com/ppy/osu/issues/37818
    
    During review, I would like to direct particular attention to the
    following changes:
    
    ## [Migrate song select to new score multiplier
    API](https://github.com/ppy/osu/commit/945fd78539da3ae57d1550a5bbfb0f859d153cc4)
    
    This was a confusing change to write because of the way song selects
    hook their mod overlays up to global bindables. In particular different
    things happen in different circumstances.
    
    - When going through `SongSelect.CreateModOverlay()`, which is called by
    the base `SongSelect`, the mod overlay is automatically bound to global
    bindables via `SongSelect.on{ArrivingAt,Leaving}Screen()`.
    - For multiplayer user mod select overlays, which are bolted on by
    subclasses of `SongSelect`, manual hook-up is required.
    - As for free mod select overlays, they don't show mod multipliers at
    all, and don't have easy access to the ruleset, and thus the hookup is
    skipped entirely as redundant.
    
    ## [Fix score multiplier registrations being shared between
    implementations via superclass static
    fields](https://github.com/ppy/osu/commit/ba0a7ad421e0c84c2d8162b6bbdd3a0683f5a6a6)
    
    Revealed by `ScoreMultiplierCalculatorTest` starting to fail due to
    interference from `OsuScoreMultiplierCalculator`.
    
    It's not ideal from a performance standpoint but it's the simplest
    choice for now. Tricks could be pulled to salvage the static. One is
    
    ```csharp
    public class ScoreMultiplierCalculator<T>
    	where T : ScoreMultiplierCalculator<T>
    {
    }
    ```
    
    This works because of generics internals; static instance members are
    not shared between different specialisations of a generic class. It is
    also very unintuitive, so I would rather not. (It trips a ReSharper
    inspection too, which would have to be silenced.)
    
    From a performance standpoint this is not ideal, but a significant chunk
    of migrated usages already precede the construction of the calculator
    via the known-expensive `RulesetInfo.CreateInstance()`, and the paths
    that actually construct the calculator do not appear to be that hot. If
    need be, this can be handled by actually caching ruleset instances and
    their derivative subcomponents.
    
    ## [Introduce passing of context to score multiplier
    calculator](https://github.com/ppy/osu/pull/37845/changes/9e9242b3221dddacd226f4b3b9c5632d7350e998)
    
    This is required for two reasons:
    
    - The upcoming mod rebalance will require out-of-band supplementary
    information that is not available for reading from the mod instances
    themselves for calculating the multiplier.
    - This context, namely passing of `ScoreInfo`, will be used for
    implementing backwards compatibility with old scores and their score
    multipliers. This is required because it has turned out under inspection
    that all server-side lazer replays recorded until now are missing
    `TotalScoreWithoutMods` due to an omission of not sending it across the
    wire to spectator server.
    
    Because the score import flow uses replays, filtered through
    `LegacyScoreDecoder`, to populate total score in the realm database, it
    is basically impossible to ignore scores that are missing
    `TotalScoreWithoutMods`, because that will result in bug reports that
    the scores do not have the new score multipliers applied.
    
    Thus, passing of `ScoreInfo` will facilitate implementation of
    versioning score multipliers, which should result in less breakage than
    not doing so.
    
    An example of this is added in 341b2d6e55,
    which should handle the case of mania mod multipliers having been
    changed without any attempt to facilitate for it in
    https://github.com/ppy/osu/pull/30506.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Replace new combo button icons with ruleset-specifc ones (#37848)
    - Depends on https://github.com/ppy/osu-resources/pull/425.
    - Closes https://github.com/ppy/osu/issues/37874
    
    This makes the new combo button use the new icons added in
    https://github.com/ppy/osu/pull/37804. Instead of having four separate
    icons per ruleset, the "sparkle" texture is overlaid on top of the
    appropriate icon.
    
    I'm not sure if I've overdone it with how every ruleset copypastes the
    same code for the icon (in `<ruleset>BlueprintContainer`), so that can
    be scaled down if necessary.
    
    | osu | taiko | catch | mania |
    |--------|--------|--------|--------|
    | <img width="200" height="67" alt="image"
    src="https://github.com/user-attachments/assets/88a31611-f200-4da8-8490-39e6803a452c"
    /> | <img width="194" height="69" alt="image"
    src="https://github.com/user-attachments/assets/fbe5c7c0-2a53-4f3f-9c80-67c8769dfb52"
    /> | <img width="194" height="69" alt="image"
    src="https://github.com/user-attachments/assets/dbfbd183-0469-4b57-9059-40351604aa64"
    /> | <img width="190" height="68" alt="image"
    src="https://github.com/user-attachments/assets/708fc2e0-34fb-4983-b696-8c23431f8af4"
    /> |
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Follow-up fixes for client-side slots implementation (#37868)
    Fell out of full-stack testing with
    https://github.com/ppy/osu-server-spectator/pull/513.
    
    - **Fix missing property copy in multiplayer client**
    Would cause the participant count limit to not update on the multiplayer
    match screen.
      
    
    - **Fix hard crash when user is kicked from a room with slots active**
    The kicked user is unsubscribed from receiving room state updates before
    their slot is vacated, which then would lead this code to attempt to
    look the local, kicked user via the unvacated slot and thus fail because
    `client.Room.Users` does *not* contain the user anymore.
      
    This is a bit of a dicey change but I think it's less dicey than to try
    to wiggle ordering server-side.
  • Fix legacy beatmap export dropping background specification (#37892)
    - Closes https://github.com/ppy/osu/issues/37884
    - Closes https://github.com/ppy/osu/pull/37890
    
    Due to lack of population of `Storyboard.Beatmap` and
    `Storyboard.BeatmapInfo` post-decoding, `LegacyBeatmapExporter` would
    completely drop background specifications on exported beatmap packages.
    This affects both direct legacy export to file (`.osz`) as well as
    beatmap submission.
    
    I will not pretend that the API here is optimal but I do not see very
    easy opportunities to curtail misuse. Storyboards can be treated as
    either parts of a beatmap or standalone entities, and if a requirement
    is added to forcibly provide a beatmap and its info when encoding out a
    storyboard, I also foresee a requirement to bypass this later when
    design mode is implemented, which would be a return to square one.
    
    There is likely room for cleanup around `Storyboard` to maybe make this
    nicer (remove passing of both `Beatmap` and `BeatmapInfo` and just pass
    `Beatmap` instead, maybe shuffle some properties from `Beatmap` to
    `Storyboard` to remove the requirement of having to bolt the beatmap on
    to begin with). I leave voicing opinions on that, and how soon that
    should be done, to reviewers. My primary intent at this time is to
    hotfix a major issue in a released build.
    
    The external editing feature is not involved in this bug and any
    attempts to claim so are misdirections.
  • Fix "Click to see what's new!" notification no longer appearing (#37875)
    - Regressed with https://github.com/ppy/osu/pull/37839.
    - Closes https://github.com/ppy/osu/issues/37870
    
    I feel like `UpdateManager` should remain a background component, so
    moving this notification into a new stable execution path is best to me.
  • Increase minimum size of video/storyboard icons globally (#37866)
    They were shockingly small.
    
    | Before | After |
    | :---: | :---: |
    | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 19 27"
    src="https://github.com/user-attachments/assets/4e3ea756-2970-4b7a-b59d-0e684d5bfe49"
    /> | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 17 23"
    src="https://github.com/user-attachments/assets/f0ce6385-6da8-4cb3-837c-0199f0034529"
    /> |
    | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 19 34"
    src="https://github.com/user-attachments/assets/97bb06ed-4563-4fbc-a897-490abb593151"
    /> | <img width="1358" height="601" alt="osu! 2026-05-22 at 08 17 16"
    src="https://github.com/user-attachments/assets/294a14f9-09b5-480a-a383-15d82bde1aa0"
    /> |
  • Handle background offset when encoding/decoding beatmaps (#37841)
    - Part of https://github.com/ppy/osu/issues/14238 (doesn't close,
    because the property doesn't do anything yet).
    - Supersedes / closes https://github.com/ppy/osu/pull/37467.
  • Add ability to add videos in editor (#37857)
    https://github.com/user-attachments/assets/bc329cca-dfa1-4149-9760-121626cf1cb4
    
    ---
    
    - Closes https://github.com/ppy/osu/issues/36326
    
    Currently lacks the ability to specify custom video offset that isn't
    manual .osu editing (defaults to 0) but I'm starting here and listening
    to requirements.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Make experimental audio the new default (#37856)
    Internal offset adjust is based on [survey
    results](https://docs.google.com/forms/d/1bWdwN9LPB4dJsqh2NO4z0HBiMiod1k8-tJN5oca-1Iw/edit)
    results (mean: 17.95, median: 24.5) with slight skewing based on
    cherry-picking results and personal experiences.
    
    For users which have had the setting disabled:
    
    <img width="1380" height="774" alt="osu! 2026-05-21 at 09 19 06"
    src="https://github.com/user-attachments/assets/0526517a-fa0b-485b-ac1b-b61b2fccd2af"
    />
    
    For users which are already using it:
    
    <img width="1380" height="774" alt="osu! 2026-05-21 at 09 20 24"
    src="https://github.com/user-attachments/assets/1bd77b39-5d75-43e8-8fe1-b324064b25fa"
    />
    
    Note the button is intentionally hidden to avoid any confusion (it's
    inverse now, so some users may mistakenly click it). Assume if a user is
    already on the new engine, they are happy with it.
    
    Test migration dialog in startup game flow using:
    
    ```diff
    diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
    index 4bd5ab83a3..091af6d428 100644
    --- a/osu.Game/OsuGame.cs
    +++ b/osu.Game/OsuGame.cs
    @@ -1315,6 +1315,8 @@ protected override void LoadComplete()
             /// </remarks>
             private void applyConfigMigrations()
             {
    +            dialogOverlay.Push(new MigrateNewAudioDialog(true));
    +
                 // arrives as 2020.123.0-lazer
                 string rawVersion = LocalConfig.Get<string>(OsuSetting.Version);
     
    
    ```
    
    ---------
    
    Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
  • Add client-side support for slots in multiplayer rooms (#37741)
    - Part of https://github.com/ppy/osu-server-spectator/issues/405
    
    <img width="1624" height="900" alt="Screenshot 2026-05-13 at 12 40 07"
    src="https://github.com/user-attachments/assets/a7f36d54-4cc6-49c9-8e89-ee0d049bb637"
    />
    <img width="1624" height="900" alt="Screenshot 2026-05-13 at 12 31 40"
    src="https://github.com/user-attachments/assets/0c054dfd-addd-4d00-bbca-119b4c3ec3cb"
    />
    
    Will not work until relevant server-side support is in.
    
    ---
    
    I was in two minds whether to PR this all at once or to PR only
    https://github.com/ppy/osu/commit/693e4ef4b095042f7a171672224e10f9c4f47c1c
    to begin with to unblock server-side implementation. In the end I opted
    for one PR because usage informs the model, so I find everything else
    relevant as part of review of the model design. If there are concerns
    about this making it into a release without server-side support and
    therefore things looking broken I will split the commit out on request.
    
    I put in some effort to add relevant logic in test multiplayer client to
    simulate the server side but I may well have missed something.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Move configuration migrations to OsuGame (#37839)
    As we go forward, migrations are going to likely become more complex,
    requiring access to more components and also at a point in time where
    they are ready.
    
    In the upcoming case, `DialogOverlay` and `Audio` are important. Access
    to `Audio` is a killer as migrations were run before the `GameHost` has
    a chance to initialise it.
    
    ---------
    
    Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
  • Fix popup dialogs not appearing if pushed when OverlayActivationMode is wrong (#37838)
    This change is a prerequisite for making a migration dialog which runs
    on startup to let users know that something hs changed.
    
    A few cases this could happen:
    
    - During start (intro still playing)
    - During gameplay
    
    Basically making dialogs get poofed without the user ever seeing them.
    
    Arguably, we should also change the way dialogs are still poofed when
    activation mode becomes not-`All` (deferring for later response rather
    than dismissing?).
  • Allow rulesets to override PlacementReplacesExisting (#37789)
    Custom rulesets may have different conditions for replacement, and may
    not even use the `IHasColumn` interface.
  • Add legacy storyboard encoder (#37790)
    - Closes https://github.com/ppy/osu/issues/37757
    
    Commit-by-commit reading is recommended. Commits will be split to PRs on
    request but I consider this to be the minimal viable functional
    increment.
    
    ## Done
    
    - This adds a first version of a full storyboard encoder
    (a66dc406f498e35d4e0c8f2a462e946a9a1aeccc). I expect there to be hiccups
    due to weird corners of the `.osb` format; this is only intended to be
    somewhat correct as a start to build upon. Storyboarders are asked to
    file issues as necessary.
    - Due to the fact that storyboard definitions can reside both in the
    `.osu` and the `.osb`, b60698a95c4de1bfeb36fbb159fd5a6028920832 adds the
    required storage to be able to tell which storyboard element lives
    where, so that it can be decoded properly later.
    - In c9d3e04a4135886b5b0943c85f3cc6f4fe99c84c, the storyboard decoder is
    weaved into the beatmap decoder to handle the `.osu` part of the
    storyboard, via the
    `LegacyStoryboardEncoder.Encode{General,Events}ToBeatmap()` methods. For
    `.osb`s, `LegacyStoryboardEncoder.EncodeStandaloneStoryboard()` is
    intended, but for now is not used outside tests.
    - Because of the above, dd1c4e43dc51154cd67860f096712f8b4f229661 removes
    `Beatmap.UnhandledEventLines` as no longer required.
    - 26ac417ed98a8937c42e5f52c4e15ef065a48902 adds tests. They are mostly
    handwritten to ensure basic encode-decode roundtripping. Using existing
    storyboards is difficult, see "Known issues" section as to why.
    - 5cc542366db7caac38eb0729260d884905a2c0d5 fixes a bug in the storyboard
    decoder where the trigger group number was not properly negated on
    decode (see inline comment reference to relevant stable code).
    
    ## Known issues
    
    - Any and all variables in the `[Variables]` section are inlined into
    their usages by `LegacyStoryboardDecoder`, and as such
    `LegacyStoryboardEncoder` will end up inlining them and discarding the
    `[Variables]` section. As far as I can tell stable will also do this.
    - `LegacyStoryboardDecoder` splits all `M` (move) commands into
    `MX`/`MY` commands. Therefore, `LegacyStoryboardEncoder` will write out
    things in the same split way. I did not put in effort to attempt to
    reconcile this, for reasons of part laziness, part not wanting to bloat
    this already-large diff.
    - Ordering of storyboard samples on decode may not match the order on
    decode. I'm crossing fingers this doesn't matter.
  • Add custom editor toolbox icons for taiko, mania, and catch (#37804)
    Requires https://github.com/ppy/osu-resources/pull/424.
    
    Replaces the editor toolbox icons for taiko, mania, and catch rulesets
    with new designs, courtesy of [Adarin](https://osu.ppy.sh/users/118360).
    
    | taiko | catch | mania |
    |--------|--------|--------|
    | <img width="369" height="401" alt="image"
    src="https://github.com/user-attachments/assets/25dc6259-02b4-4a23-9a80-529a366c270e"
    /> | <img width="369" height="401" alt="image"
    src="https://github.com/user-attachments/assets/4bf67bf9-8a45-4075-8d33-f0501cdb08cb"
    /> | <img width="369" height="317" alt="image"
    src="https://github.com/user-attachments/assets/d130a97e-c082-4b01-a88a-d3209c05e33b"
    /> |
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • Add score multiplier calculator API (#37822)
    - Part of https://github.com/ppy/osu/issues/37818
    - Continued from
    https://github.com/ppy/osu/pull/37355#issuecomment-4449248107
    
    ## Overview
    
    This PR introduces an alternative API, `ScoreMultiplierCalculator`, to
    be used going forward for calculating mod multipliers.
    
    The reason for introducing this new API is that it has been requested
    that:
    - For any two given mods, it should be possible to have the combined mod
    multipliers of them in combination be *different* than the product of
    the individual mods' multipliers in isolation, i.e. $mult( \\{ A, B \\}
    ) \neq mult( \\{ A \\} ) \cdot mult( \\{ B \\} )$.
    - For an individual mod, it should be possible to have the mod
    multipliers depend on a quantity that is *not* the presence of another
    mod or the direct value of a setting on the mod.
    
    This capability is being demonstrated in this PR via the
    `osu.Game.Tests.Rulesets.Scoring.ScoreMultiplierCalculatorTest` test
    fixture.
    
    ## Parity with `Mod.ScoreMultiplier`
    
    This PR contains a `ScoreMultiplierCalculator` implementation for each
    of the built-in four rulesets.
    
    The abstract `osu.Game.Tests.Rulesets.RulesetScoreMultiplierTest` and
    its four derived ruleset-specific test fixtures were written to ensure
    that the new implementations do not diverge from the current state of
    affairs.
    
    `Mod.ScoreMultiplier` is not removed in this diff to keep size low. It
    will be removed as a follow-up.
    
    ## Performance
    
    This PR contains a benchmark comparing the current implementation via
    `Mod.ScoreMultiplier` and the new `ScoreMultiplierCalculator` API.
    Results below.
    
    <details>
    
    | Method | Times | Mods | Mean | Error | StdDev | Gen0 | Allocated |
    |---------------------- |------ |---------------------
    |--------------:|------------:|------------:|--------:|----------:|
    | ViaModScoreMultiplier | 1 | mods (...)tings [27] | 121.171 ns | 1.5284
    ns | 1.4297 ns | 0.0782 | 656 B |
    | ViaCalculator | 1 | mods (...)tings [27] | 248.509 ns | 1.9313 ns |
    1.6127 ns | 0.1364 | 1144 B |
    | ViaModScoreMultiplier | 1 | multiple mods | 128.357 ns | 0.4282 ns |
    0.4006 ns | 0.0782 | 656 B |
    | ViaCalculator | 1 | multiple mods | 252.953 ns | 1.2860 ns | 1.2029 ns
    | 0.1364 | 1144 B |
    | ViaModScoreMultiplier | 1 | no mods | 3.007 ns | 0.0345 ns | 0.0288 ns
    | - | - |
    | ViaCalculator | 1 | no mods | 14.802 ns | 0.0616 ns | 0.0576 ns |
    0.0134 | 112 B |
    | ViaModScoreMultiplier | 1 | single mod | 40.271 ns | 0.1238 ns |
    0.1098 ns | 0.0258 | 216 B |
    | ViaCalculator | 1 | single mod | 113.033 ns | 0.3140 ns | 0.2937 ns |
    0.0842 | 704 B |
    | ViaModScoreMultiplier | 1 | single mod 2 | 3.653 ns | 0.0384 ns |
    0.0359 ns | 0.0038 | 32 B |
    | ViaCalculator | 1 | single mod 2 | 78.172 ns | 0.0680 ns | 0.0603 ns |
    0.0621 | 520 B |
    | ViaModScoreMultiplier | 10 | mods (...)tings [27] | 1,169.609 ns |
    4.3058 ns | 4.0276 ns | 0.7839 | 6560 B |
    | ViaCalculator | 10 | mods (...)tings [27] | 2,575.264 ns | 21.2705 ns
    | 19.8964 ns | 1.3657 | 11440 B |
    | ViaModScoreMultiplier | 10 | multiple mods | 1,171.775 ns | 6.2332 ns
    | 5.2050 ns | 0.7839 | 6560 B |
    | ViaCalculator | 10 | multiple mods | 2,579.593 ns | 22.1010 ns |
    20.6733 ns | 1.3657 | 11440 B |
    | ViaModScoreMultiplier | 10 | no mods | 35.943 ns | 0.1665 ns | 0.1476
    ns | - | - |
    | ViaCalculator | 10 | no mods | 154.980 ns | 0.2381 ns | 0.1988 ns |
    0.1338 | 1120 B |
    | ViaModScoreMultiplier | 10 | single mod | 404.185 ns | 1.3190 ns |
    1.2338 ns | 0.2580 | 2160 B |
    | ViaCalculator | 10 | single mod | 1,167.279 ns | 6.1641 ns | 5.7659 ns
    | 0.8411 | 7040 B |
    | ViaModScoreMultiplier | 10 | single mod 2 | 42.128 ns | 0.2878 ns |
    0.2692 ns | 0.0382 | 320 B |
    | ViaCalculator | 10 | single mod 2 | 775.435 ns | 2.3318 ns | 2.1811 ns
    | 0.6208 | 5200 B |
    | ViaModScoreMultiplier | 100 | mods (...)tings [27] | 11,623.346 ns |
    51.7174 ns | 43.1863 ns | 7.8430 | 65600 B |
    | ViaCalculator | 100 | mods (...)tings [27] | 25,252.987 ns | 44.4352
    ns | 39.3906 ns | 13.6719 | 114400 B |
    | ViaModScoreMultiplier | 100 | multiple mods | 11,928.536 ns | 35.2079
    ns | 32.9334 ns | 7.8430 | 65600 B |
    | ViaCalculator | 100 | multiple mods | 25,399.378 ns | 152.4597 ns |
    127.3108 ns | 13.6719 | 114400 B |
    | ViaModScoreMultiplier | 100 | no mods | 328.158 ns | 0.5827 ns |
    0.5165 ns | - | - |
    | ViaCalculator | 100 | no mods | 1,517.485 ns | 10.2304 ns | 9.5695 ns
    | 1.3390 | 11200 B |
    | ViaModScoreMultiplier | 100 | single mod | 3,986.251 ns | 24.2523 ns |
    21.4991 ns | 2.5787 | 21600 B |
    | ViaCalculator | 100 | single mod | 11,479.514 ns | 23.3738 ns |
    20.7203 ns | 8.4076 | 70400 B |
    | ViaModScoreMultiplier | 100 | single mod 2 | 385.679 ns | 3.5190 ns |
    3.2917 ns | 0.3824 | 3200 B |
    | ViaCalculator | 100 | single mod 2 | 7,658.646 ns | 21.8274 ns |
    19.3494 ns | 6.2103 | 52000 B |
    
    </details>
    
    While the calculator is obviously slower, in my view it is not
    egregiously so. The main overheads both time- and memory-wise are
    collection allocations for the dictionary and the set which I consider
    to be directly caused by the requested additional complexity and as such
    I don't really consider them eliminable.
    
    I have tried and applied some micro-optimisations
    (e2469ce338,
    cb33abec17), albeit with negligible
    effect. I have also tried to key the mods by `Acronym` instead of by
    `Type` and the difference was basically nil.
    
    <details>
    <summary>patch for keying by acronym</summary>
    
    ```diff
    diff --git a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
    index 772f9d178b..7f5907cbda 100644
    --- a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
    +++ b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
    @@ -13,26 +13,26 @@ namespace osu.Game.Rulesets.Scoring
         /// </summary>
         public class ScoreMultiplierCalculator
         {
    -        private static readonly List<(Type[] mods, Func<Mod[], double> multiplier)> combination_multipliers = [];
    -        private static readonly Dictionary<Type, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
    -        private static readonly Dictionary<Type, Func<Mod, double>> single_multipliers = [];
    +        private static readonly List<(string[] modAcronyms, Func<Mod[], double> multiplier)> combination_multipliers = [];
    +        private static readonly Dictionary<string, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
    +        private static readonly Dictionary<string, Func<Mod, double>> single_multipliers = [];
     
             /// <summary>
             /// Defines a flat, setting-independent score multiplier for the given <typeparamref name="TMod"/>.
             /// </summary>
             public static void Single<TMod>(double hasMultiplier)
    -            where TMod : Mod
    +            where TMod : Mod, new()
             {
    -            single_multipliers[typeof(TMod)] = _ => hasMultiplier;
    +            single_multipliers[new TMod().Acronym] = _ => hasMultiplier;
             }
     
             /// <summary>
             /// Defines a setting-dependent score multiplier for the given <typeparamref name="TMod"/>.
             /// </summary>
             public static void Single<TMod>(Func<TMod, double> hasMultiplier)
    -            where TMod : Mod
    +            where TMod : Mod, new()
             {
    -            single_multipliers[typeof(TMod)] = mod => hasMultiplier.Invoke((TMod)mod);
    +            single_multipliers[new TMod().Acronym] = mod => hasMultiplier.Invoke((TMod)mod);
             }
     
             /// <summary>
    @@ -40,20 +40,20 @@ public static void Single<TMod>(Func<TMod, double> hasMultiplier)
             /// The multiplier calculation is given additional context to calculate the multiplier via the <typeparamref name="TContext"/> type instance.
             /// </summary>
             public static void Single<TMod, TContext>(Func<TMod, TContext, double> hasMultiplier)
    -            where TMod : Mod
    +            where TMod : Mod, new()
                 where TContext : ScoreMultiplierCalculator
             {
    -            single_multipliers_with_context[typeof(TMod)] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
    +            single_multipliers_with_context[new TMod().Acronym] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
             }
     
             /// <summary>
             /// Defines a score multiplier specific to when both <typeparamref name="T1"/> and <typeparamref name="T2"/> mods are present.
             /// </summary>
             public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
    -            where T1 : Mod
    -            where T2 : Mod
    +            where T1 : Mod, new()
    +            where T2 : Mod, new()
             {
    -            combination_multipliers.Add(([typeof(T1), typeof(T2)], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
    +            combination_multipliers.Add(([new T1().Acronym, new T2().Acronym], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
             }
     
             /// <summary>
    @@ -61,7 +61,7 @@ public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
             /// </summary>
             public double CalculateFor(IEnumerable<Mod> mods)
             {
    -            var allModsByType = mods.ToDictionary(m => m.GetType());
    +            var allModsByType = mods.ToDictionary(m => m.Acronym);
     
                 if (allModsByType.Count == 0)
                     return 1;
    @@ -83,7 +83,7 @@ public double CalculateFor(IEnumerable<Mod> mods)
                     }
                 }
     
    -            foreach (var modType in remainingModTypes)
    +            foreach (string modType in remainingModTypes)
                 {
                     if (single_multipliers.TryGetValue(modType, out var multiplier))
                         result *= multiplier(allModsByType[modType]);
    
    ```
    
    </details>
    
    One particular parallel thread that may warrant follow-up is that
    `Mod.UsesDefaultConfiguration` is disproportionately expensive due to
    calling into regexes via Humanizer internals.
    
    <img width="1517" height="517" alt="Screenshot_2026-05-19_at_10 58 30"
    src="https://github.com/user-attachments/assets/68309a8c-74e7-4f96-8ef9-62868eeca337"
    />
  • Add slider velocity control to toolbox (#37746)
    https://github.com/user-attachments/assets/2a511e0d-51f8-4abf-a3ab-de0992618b6b
    
    This implements a rather opinionated UX that's designed to be a middle
    ground between the previous lazer behaviour of unconditionally
    inheriting the last slider's velocity and just a textbox that you'd need
    to manually fiddle with every time.
    
    As to what that means, precisely:
    
    - By default, the control follows the last slider's velocity (updates on
    seeks as well as changes to existing objects).
    - When the slider control in the toolbox is manually adjusted, the
    control decouples from the last slider's velocity and instead uses the
    last manually-specified value.
    - There is a button that allows the user to couple back to the last
    slider's velocity if they consider to have made a mistake in adjusting
    it.
    - Upon successful placement of a slider, the control reverts to
    following the last slider's velocity.
    
    Of note, this control *only interacts with and affects the next placed
    slider*. It is in no way coupled to any selected objects. This may be
    confusing to users but was an intentional choice to limit complexity
    (what if there are multiple selected objects with multiple velocities?)
    
    For adjusting existing objects you can use the green pieces on the
    timeline, which notably do support changing multiple selected objects at
    once.
    
    ---
    
    - Closes https://github.com/ppy/osu/issues/36844
    - Supersedes / closes https://github.com/ppy/osu/pull/33707
  • Add editor hotkeys for beatmap submit and edit externally (#37782)
    Addresses https://github.com/ppy/osu/discussions/37777.
    
    Note: please don't merge until after the imminent release. Would rather
    not add new features at this point.
  • Stop applying UI scale to ranked play (#37779)
    We already [did this for quick
    play](https://github.com/ppy/osu/pull/36025) but it was never carried
    across. Some of the new UI *could* work with UI scale with more
    consideration, but for now, let's disable it globally to fix cases like
    the results screen which people completely unusable at higher scales.
    
    ---
    
    @ppy/team-client would hope to get this into today's build, if priority
    review could be given to it.
    
    Closes https://github.com/ppy/osu/issues/37407.
    
    - Note that this adds a UI scale slider to all `ScreenTestScenes`. In
    some testing, this seems to work just fine.
    - May be worth reading 6c8dd589f7384d46377620340d300e53aec7eed5 commit
    message for one future concern i have.
178 changed files with 4417 additions and 670 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.513.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.521.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
@@ -0,0 +1,66 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using NUnit.Framework;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Benchmarks
{
public class BenchmarkScoreMultiplierCalculator : BenchmarkTest
{
private ScoreMultiplierCalculator calculator = null!;
[Params(1, 10, 100)]
public int Times { get; set; }
public record ModTestCase(string Description, IEnumerable<Mod> Mods)
{
public override string ToString() => Description;
}
public static IEnumerable<ModTestCase> ValuesForMods =>
[
new ModTestCase("no mods", []),
new ModTestCase("single mod", [new OsuModHardRock()]),
new ModTestCase("single mod 2", [new OsuModEasy()]),
new ModTestCase("multiple mods", [new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime()]),
new ModTestCase("mods with adjusted settings", [
new OsuModDoubleTime { SpeedChange = { Value = 2 } },
new OsuModHidden { OnlyFadeApproachCircles = { Value = true } },
new OsuModHardRock()
]),
];
[ParamsSource(nameof(ValuesForMods))]
public ModTestCase Mods { get; set; } = null!;
public override void SetUp()
{
base.SetUp();
calculator = new OsuRuleset().CreateScoreMultiplierCalculator(new ScoreMultiplierContext());
}
[Benchmark]
public double ViaCalculator()
=> viaCalculator(Times, Mods);
[Test]
public void ViaCalculator([Values(100)] int times, [ValueSource(nameof(ValuesForMods))] ModTestCase mods)
=> viaCalculator(times, mods);
private double viaCalculator(int times, ModTestCase mods)
{
double scoreMultiplier = 1;
for (int i = 0; i < times; ++i)
scoreMultiplier = calculator.CalculateFor(mods.Mods);
return scoreMultiplier;
}
}
}
@@ -0,0 +1,155 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Tests.Rulesets;
namespace osu.Game.Rulesets.Catch.Tests
{
public class CatchScoreMultiplierTest : RulesetScoreMultiplierTest
{
public CatchScoreMultiplierTest()
: base(new CatchRuleset())
{
}
private static readonly object[][] test_cases =
[
#region Difficulty Reduction
[new Mod[] { new CatchModEasy() }, 0.5],
[new Mod[] { new CatchModNoFail() }, 0.5],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new CatchModHalfTime { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new CatchModDaycore { SpeedChange = { Value = 0.99 } } }, 0.5],
#endregion
#region Difficulty Increase
[new Mod[] { new CatchModHardRock() }, 1.12],
[new Mod[] { new CatchModSuddenDeath() }, 1],
[new Mod[] { new CatchModPerfect() }, 1],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new CatchModDoubleTime { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new CatchModNightcore { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new CatchModHidden() }, 1.06],
[new Mod[] { new CatchModFlashlight() }, 1.12],
[new Mod[] { new CatchModFlashlight { ComboBasedSize = { Value = false } } }, 1],
[new Mod[] { new ModAccuracyChallenge() }, 1],
#endregion
#region Conversion
[new Mod[] { new CatchModDifficultyAdjust() }, 0.5],
[new Mod[] { new CatchModClassic() }, 0.96],
[new Mod[] { new CatchModMirror() }, 1],
#endregion
#region Automation
[new Mod[] { new CatchModAutoplay() }, 1],
[new Mod[] { new CatchModCinema() }, 1],
[new Mod[] { new CatchModRelax() }, 0.1],
#endregion
#region Fun
[new Mod[] { new ModWindUp() }, 0.5],
[new Mod[] { new ModWindDown() }, 0.5],
[new Mod[] { new CatchModFloatingFruits() }, 1],
[new Mod[] { new CatchModMuted() }, 1],
[new Mod[] { new CatchModNoScope() }, 1],
[new Mod[] { new CatchModMovingFast() }, 1],
[new Mod[] { new CatchModSynesthesia() }, 0.8],
#endregion
#region System
[new Mod[] { new ModScoreV2() }, 1],
#endregion
#region Combinations
[new Mod[] { new CatchModHidden(), new CatchModHardRock() }, 1.06 * 1.12]
#endregion
];
[TestCaseSource(nameof(test_cases))]
public void TestMultipliers(Mod[] mods, double expectedMultiplier)
=> TestModCombination(mods, expectedMultiplier);
}
}
+2
View File
@@ -169,6 +169,8 @@ namespace osu.Game.Rulesets.Catch
}
}
public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator(ScoreMultiplierContext context) => new CatchScoreMultiplierCalculator(context);
public override string Description => "osu!catch";
public override string ShortName => SHORT_NAME;
@@ -2,9 +2,9 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
@@ -13,11 +13,11 @@ namespace osu.Game.Rulesets.Catch.Edit
public class BananaShowerCompositionTool : CompositionTool
{
public BananaShowerCompositionTool()
: base(nameof(BananaShower))
: base("Banana shower")
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorBananaShower };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
}
@@ -3,11 +3,16 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
@@ -22,6 +27,29 @@ namespace osu.Game.Rulesets.Catch.Edit
{
}
protected override Drawable CreateNewComboButton() => new NewComboTernaryButton
{
Current = NewCombo,
CreateIcon = () => new Container
{
Children = new Drawable[]
{
new SpriteIcon
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Icon = OsuIcon.EditorFruit,
Size = new Vector2(15),
},
new SpriteIcon
{
Icon = OsuIcon.EditorNewComboSparkles,
Size = new Vector2(20),
}
},
},
};
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new CatchSelectionHandler();
public override HitObjectSelectionBlueprint? CreateHitObjectBlueprintFor(HitObject hitObject)
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
@@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorFruit };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
}
@@ -2,9 +2,9 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Edit.Blueprints;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
@@ -13,11 +13,11 @@ namespace osu.Game.Rulesets.Catch.Edit
public class JuiceStreamCompositionTool : CompositionTool
{
public JuiceStreamCompositionTool()
: base(nameof(JuiceStream))
: base("Juice stream")
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorJuiceStream };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
}
@@ -0,0 +1,86 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Catch.Scoring
{
public class CatchScoreMultiplierCalculator : ScoreMultiplierCalculator
{
public CatchScoreMultiplierCalculator(ScoreMultiplierContext context)
: base(context)
{
#region Difficulty Reduction
Single<CatchModEasy>(hasMultiplier: 0.5);
Single<CatchModNoFail>(hasMultiplier: 0.5);
Single<CatchModHalfTime>(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value));
Single<CatchModDaycore>(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value));
#endregion
#region Difficulty Increase
Single<CatchModHardRock>(hasMultiplier: hardRock => hardRock.UsesDefaultConfiguration ? 1.12 : 1);
// Sudden Death
// Perfect
Single<CatchModDoubleTime>(hasMultiplier: doubleTime => rateAdjustMultiplier(doubleTime.SpeedChange.Value));
Single<CatchModNightcore>(hasMultiplier: nightcore => rateAdjustMultiplier(nightcore.SpeedChange.Value));
Single<CatchModHidden>(hasMultiplier: hidden => hidden.UsesDefaultConfiguration ? 1.06 : 1);
Single<CatchModFlashlight>(hasMultiplier: flashlight => flashlight.UsesDefaultConfiguration ? 1.12 : 1);
// Accuracy Challenge
#endregion
#region Conversion
Single<CatchModDifficultyAdjust>(hasMultiplier: 0.5);
Single<CatchModClassic>(hasMultiplier: 0.96);
// Mirror
#endregion
#region Automation
// Autoplay
// Cinema
Single<CatchModRelax>(hasMultiplier: 0.1);
#endregion
#region Fun
Single<ModWindUp>(hasMultiplier: 0.5);
Single<ModWindDown>(hasMultiplier: 0.5);
// Floating Fruits
// Muted
// No Scope
// Moving Fast
Single<CatchModSynesthesia>(hasMultiplier: 0.8);
#endregion
#region System
// Score V2
#endregion
}
private static double rateAdjustMultiplier(double speedChange)
{
// Round to the nearest multiple of 0.1.
double value = (int)(speedChange * 10) / 10.0;
// Offset back to 0.
value -= 1;
if (speedChange >= 1)
return 1 + value / 5;
else
return 0.6 + value;
}
}
}
@@ -36,8 +36,8 @@ namespace osu.Game.Rulesets.Mania.Tests
var decoded = DecodeFromLegacy(beatmaps_resource_store.GetStream($"Resources/Testing/Beatmaps/{name}.osu"), beatmaps_resource_store, name);
var decodedAfterEncode = DecodeFromLegacy(EncodeToLegacy(decoded), beatmaps_resource_store, name);
Sort(decoded.beatmap);
Sort(decodedAfterEncode.beatmap);
Sort(decoded.Beatmap);
Sort(decodedAfterEncode.Beatmap);
CompareBeatmaps(decoded, decodedAfterEncode);
}
@@ -0,0 +1,223 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Rulesets;
namespace osu.Game.Rulesets.Mania.Tests
{
public class ManiaScoreMultiplierTest : RulesetScoreMultiplierTest
{
public ManiaScoreMultiplierTest()
: base(new ManiaRuleset())
{
}
private static readonly object[][] test_cases =
[
#region Difficulty Reduction
[new Mod[] { new ManiaModEasy() }, 0.5],
[new Mod[] { new ManiaModNoFail() }, 0.5],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new ManiaModHalfTime { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new ManiaModDaycore { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new ManiaModNoRelease() }, 0.9],
#endregion
#region Difficulty Increase
[new Mod[] { new ManiaModHardRock() }, 1],
[new Mod[] { new ManiaModSuddenDeath() }, 1],
[new Mod[] { new ManiaModPerfect() }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.01 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.05 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.10 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.15 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.20 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.25 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.30 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.35 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.40 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.45 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.50 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.55 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.60 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.65 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.70 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.75 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.80 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.85 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.90 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 1.95 } } }, 1],
[new Mod[] { new ManiaModDoubleTime { SpeedChange = { Value = 2.00 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.01 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.05 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.10 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.15 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.20 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.25 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.30 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.35 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.40 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.45 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.50 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.55 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.60 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.65 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.70 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.75 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.80 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.85 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.90 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 1.95 } } }, 1],
[new Mod[] { new ManiaModNightcore { SpeedChange = { Value = 2.00 } } }, 1],
[new Mod[] { new ManiaModFadeIn() }, 1],
[new Mod[] { new ManiaModHidden() }, 1],
[new Mod[] { new ManiaModCover() }, 1],
[new Mod[] { new ManiaModFlashlight() }, 1],
[new Mod[] { new ModAccuracyChallenge() }, 1],
#endregion
#region Conversion
[new Mod[] { new ManiaModRandom() }, 1],
[new Mod[] { new ManiaModDualStages() }, 1],
[new Mod[] { new ManiaModMirror() }, 1],
[new Mod[] { new ManiaModDifficultyAdjust() }, 0.5],
[new Mod[] { new ManiaModClassic() }, 0.96],
[new Mod[] { new ManiaModInvert() }, 1],
[new Mod[] { new ManiaModConstantSpeed() }, 0.9],
[new Mod[] { new ManiaModHoldOff() }, 0.9],
[new Mod[] { new ManiaModKey1() }, 0.9],
[new Mod[] { new ManiaModKey2() }, 0.9],
[new Mod[] { new ManiaModKey3() }, 0.9],
[new Mod[] { new ManiaModKey4() }, 0.9],
[new Mod[] { new ManiaModKey5() }, 0.9],
[new Mod[] { new ManiaModKey6() }, 0.9],
[new Mod[] { new ManiaModKey7() }, 0.9],
[new Mod[] { new ManiaModKey8() }, 0.9],
[new Mod[] { new ManiaModKey9() }, 0.9],
[new Mod[] { new ManiaModKey10() }, 0.9],
#endregion
#region Automation
[new Mod[] { new ManiaModAutoplay() }, 1],
[new Mod[] { new ManiaModCinema() }, 1],
#endregion
#region Fun
[new Mod[] { new ModWindUp() }, 0.5],
[new Mod[] { new ModWindDown() }, 0.5],
[new Mod[] { new ManiaModMuted() }, 1],
[new Mod[] { new ModAdaptiveSpeed() }, 0.5],
#endregion
#region System
[new Mod[] { new ManiaModScoreV2() }, 1],
#endregion
#region Combinations
[new Mod[] { new ManiaModEasy(), new ManiaModKey4() }, 0.5 * 0.9]
#endregion
];
[TestCaseSource(nameof(test_cases))]
public void TestMultipliers(Mod[] mods, double expectedMultiplier)
=> TestModCombination(mods, expectedMultiplier);
private static readonly object[][] key_mod_multiplier_test_cases =
[
// score end date, client version, expected multiplier
// scores verifiably from old clients.
[new DateTimeOffset(2024, 1, 31, 11, 0, 0, TimeSpan.Zero), "2024.130.2", 1],
[new DateTimeOffset(2024, 12, 9, 11, 0, 0, TimeSpan.Zero), "2024.1208.0", 1],
[new DateTimeOffset(2025, 6, 12, 11, 0, 0, TimeSpan.Zero), "2025.605.3", 1],
[new DateTimeOffset(2025, 6, 28, 11, 0, 0, TimeSpan.Zero), "2025.625.0-tachyon", 1],
[new DateTimeOffset(2025, 7, 11, 11, 0, 0, TimeSpan.Zero), "2025.710.0-lazer", 1],
[new DateTimeOffset(2025, 7, 15, 11, 0, 0, TimeSpan.Zero), "2025.711.0-tachyon", 1],
// scores without explicit client versions, predating the change of multiplier.
// those MUST have used the old multiplier.
[new DateTimeOffset(2024, 1, 31, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2024, 12, 9, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 6, 12, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 6, 28, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 7, 11, 11, 0, 0, TimeSpan.Zero), "", 1],
[new DateTimeOffset(2025, 7, 15, 11, 0, 0, TimeSpan.Zero), "", 1],
// scores without explicit client versions, AFTER the change of multiplier.
// there is NO way of verifying whether these scores use the new or old multiplier, therefore GUESS that it's the new one.
// "thankfully" the window of opportunity for this occurring *should* be slim
// (from client release with new key mod multipliers on July 18, 2025
// until spectator server release which added client version writing to server-side replays on August 1, 2025).
[new DateTimeOffset(2025, 7, 19, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2025, 7, 23, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2025, 8, 19, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2026, 6, 18, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
[new DateTimeOffset(2026, 7, 18, 0, 20, 15, 0, TimeSpan.Zero), "", 0.9],
// scores verifiably from new clients.
[new DateTimeOffset(2025, 7, 19, 0, 20, 15, 0, TimeSpan.Zero), "2025.718.0-tachyon", 0.9],
[new DateTimeOffset(2025, 7, 23, 0, 20, 15, 0, TimeSpan.Zero), "2025.721.0-tachyon", 0.9],
[new DateTimeOffset(2025, 8, 19, 0, 20, 15, 0, TimeSpan.Zero), "2025.816.0-lazer", 0.9],
[new DateTimeOffset(2026, 6, 18, 0, 20, 15, 0, TimeSpan.Zero), "2026.518.0-lazer", 0.9],
[new DateTimeOffset(2026, 7, 18, 0, 20, 15, 0, TimeSpan.Zero), "2026.522.1-tachyon", 0.9],
];
[TestCaseSource(nameof(key_mod_multiplier_test_cases))]
public void TestKeyModMultiplierCompatibility(DateTimeOffset endDate, string clientVersion, double expectedMultiplier)
{
var calculator = Ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(new ScoreInfo
{
Date = endDate,
ClientVersion = clientVersion
}));
Assert.That(calculator.CalculateFor([new ManiaModKey4()]), Is.EqualTo(expectedMultiplier).Within(Precision.DOUBLE_EPSILON));
}
}
}
@@ -8,8 +8,10 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Mods
@@ -54,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
Mod = doubleTime,
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_000 * doubleTime.ScoreMultiplier),
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_000 * new ManiaScoreMultiplierCalculator(new ScoreMultiplierContext()).CalculateFor([doubleTime])),
Autoplay = false,
CreateBeatmap = () => new Beatmap
{
@@ -9,6 +9,8 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
using osuTK.Input;
@@ -90,5 +92,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
private float getNoteHeight(Column resultPlayfield) =>
resultPlayfield.ToScreenSpace(new Vector2(DefaultNotePiece.NOTE_HEIGHT)).Y -
resultPlayfield.ToScreenSpace(Vector2.Zero).Y;
public override bool ReplacesExistingObject(HitObject existing)
=> base.ReplacesExistingObject(existing) && HitObject.Column == ((IHasColumn)existing).Column;
}
}
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
@@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorHoldNote };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
}
@@ -3,11 +3,16 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
@@ -22,6 +27,29 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
protected override Drawable CreateNewComboButton() => new NewComboTernaryButton
{
Current = NewCombo,
CreateIcon = () => new Container
{
Children = new Drawable[]
{
new SpriteIcon
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Icon = OsuIcon.EditorNote,
Size = new Vector2(15),
},
new SpriteIcon
{
Icon = OsuIcon.EditorNewComboSparkles,
Size = new Vector2(20),
}
},
},
};
public override HitObjectSelectionBlueprint? CreateHitObjectBlueprintFor(HitObject hitObject)
{
switch (hitObject)
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
@@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorNote };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
}
+2
View File
@@ -307,6 +307,8 @@ namespace osu.Game.Rulesets.Mania
}
}
public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator(ScoreMultiplierContext context) => new ManiaScoreMultiplierCalculator(context);
public override string Description => "osu!mania";
public override string ShortName => SHORT_NAME;
@@ -0,0 +1,146 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mania.Scoring
{
public class ManiaScoreMultiplierCalculator : ScoreMultiplierCalculator
{
public ManiaScoreMultiplierCalculator(ScoreMultiplierContext context)
: base(context)
{
#region Difficulty Reduction
Single<ManiaModEasy>(hasMultiplier: 0.5);
Single<ManiaModNoFail>(hasMultiplier: 0.5);
Single<ManiaModHalfTime>(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value));
Single<ManiaModDaycore>(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value));
Single<ManiaModNoRelease>(hasMultiplier: 0.9);
#endregion
#region Difficulty Increase
// Hard Rock
// Sudden Death
// Perfect
// Double Time
// Nightcore
// Fade In
// Hidden
// Cover
// Flashlight
// Accuracy Challenge
#endregion
#region Conversion
// Random
// Dual Stages
// Mirror
Single<ManiaModDifficultyAdjust>(hasMultiplier: 0.5);
Single<ManiaModClassic>(hasMultiplier: 0.96);
// Invert
Single<ManiaModConstantSpeed>(hasMultiplier: 0.9);
Single<ManiaModHoldOff>(hasMultiplier: 0.9);
Single<ManiaModKey1>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey2>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey3>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey4>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey5>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey6>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey7>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey8>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey9>(hasMultiplier: keyModMultiplier(Context.Score));
Single<ManiaModKey10>(hasMultiplier: keyModMultiplier(Context.Score));
#endregion
#region Automation
// Autoplay
// Cinema
#endregion
#region Fun
Single<ModWindUp>(hasMultiplier: 0.5);
Single<ModWindDown>(hasMultiplier: 0.5);
// Muted
Single<ModAdaptiveSpeed>(hasMultiplier: 0.5);
#endregion
#region System
// Score V2
#endregion
}
private static double rateAdjustMultiplier(double speedChange)
{
// Round to the nearest multiple of 0.1.
double value = (int)(speedChange * 10) / 10.0;
// Offset back to 0.
value -= 1;
if (speedChange >= 1)
return 1 + value / 5;
else
return 0.6 + value;
}
private const double old_key_mod_multiplier = 1;
private const double new_key_mod_multiplier = 0.9;
/// <summary>
/// <para>
/// The mod multiplier was changed from 1.0x to 0.9x in https://github.com/ppy/osu/pull/30506
/// which was included in the https://osu.ppy.sh/home/changelog/tachyon/2025.718.0 release.
/// The replay version was not bumped in the change, meaning that the only usable indicator
/// of the mod multiplier changing is the client version.
/// </para>
/// <para>
/// Unfortunately not even the client version is available on server-side recorded replays
/// recorded prior to https://github.com/ppy/osu-server-spectator/pull/290,
/// which does not appear to have been deployed until August 1
/// (https://github.com/ppy/osu-server-spectator/releases/tag/2025.801.0).
/// </para>
/// </summary>
private double keyModMultiplier(ScoreInfo? scoreInfo)
{
if (scoreInfo == null)
return new_key_mod_multiplier;
string clientVersion = scoreInfo.ClientVersion;
if (!string.IsNullOrEmpty(clientVersion))
{
string[] pieces = clientVersion.Split('.');
if (int.TryParse(pieces[0], out int year) && int.TryParse(pieces[1], out int monthDay))
{
if (year < 2025 || (year == 2025 && monthDay < 718))
return old_key_mod_multiplier;
}
return new_key_mod_multiplier;
}
// Client version not available, fallback to doing the best we can with the score's timestamp.
if (scoreInfo.Date < new DateTimeOffset(2025, 7, 18, 0, 0, 0, TimeSpan.Zero))
return old_key_mod_multiplier;
return new_key_mod_multiplier;
}
}
}
@@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestTouchInputPlaceHitCircleDirectly()
{
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "HitCircle")));
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Hit circle")));
AddStep("tap to place circle", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddAssert("circle placed correctly", () =>
@@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestTouchInputPlaceCircleAfterTouchingComposeArea()
{
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "HitCircle")));
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Hit circle")));
AddStep("tap playfield", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddAssert("circle placed", () => EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate) is HitCircle);
@@ -7,6 +7,9 @@ using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
@@ -130,5 +133,74 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("slider has correct velocity", () => slider!.Velocity, () => Is.EqualTo(velocityBefore));
AddAssert("slider has correct duration", () => slider!.Duration, () => Is.EqualTo(durationBefore));
}
[Test]
public void TestVelocityToolbox()
{
ExpandableSlider<double> velocitySlider = null!;
ExpandableButton useLastSliderButton = null!;
AddStep("enter editor", () => Game.ScreenStack.Push(new EditorLoader()));
AddUntilStep("wait for editor load", () => editor?.ReadyForUse == true);
AddStep("retrieve controls", () =>
{
var toolbox = this.ChildrenOfType<OsuSliderVelocityToolboxGroup>().Single();
velocitySlider = toolbox.ChildrenOfType<ExpandableSlider<double>>().Single();
useLastSliderButton = toolbox.ChildrenOfType<ExpandableButton>().Single();
});
AddAssert("velocity slider at 1x", () => velocitySlider.Current.Value, () => Is.EqualTo(1));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False);
AddStep("seek to 5000", () => editorClock.Seek(5000));
AddStep("set 2x velocity", () => velocitySlider.Current.Value = 2);
placeSlider();
AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(2));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True);
AddStep("seek to 6000", () => editorClock.Seek(6000));
placeSlider();
AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(2));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True);
AddStep("seek to 9000", () => editorClock.Seek(9000));
AddStep("set 3x velocity", () => velocitySlider.Current.Value = 3);
placeSlider();
AddAssert("placed slider has 3x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(3));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True);
AddStep("seek to 10000", () => editorClock.Seek(10000));
AddStep("set 1x velocity", () => velocitySlider.Current.Value = 1);
AddStep("use last slider velocity instead", () => useLastSliderButton.TriggerClick());
placeSlider();
AddAssert("placed slider has 3x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().Last().SliderVelocityMultiplier, () => Is.EqualTo(3));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False);
AddStep("seek back to 7000", () => editorClock.Seek(7000));
placeSlider();
AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType<Slider>().ElementAt(2).SliderVelocityMultiplier, () => Is.EqualTo(2));
AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType<ExpandingToolboxContainer>().Last()));
AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True);
AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False);
void placeSlider()
{
AddStep("enter slider placement mode", () => InputManager.Key(Key.Number3));
AddStep("move mouse to top left", () => InputManager.MoveMouseTo(editor.ChildrenOfType<Playfield>().First().ScreenSpaceDrawQuad.TopLeft + new Vector2(50)));
AddStep("start placement", () => InputManager.Click(MouseButton.Left));
AddStep("move mouse to bottom right", () => InputManager.MoveMouseTo(editor.ChildrenOfType<Playfield>().First().ScreenSpaceDrawQuad.BottomRight - new Vector2(50)));
AddStep("end placement", () => InputManager.Click(MouseButton.Right));
}
}
}
}
@@ -0,0 +1,179 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Tests.Rulesets;
namespace osu.Game.Rulesets.Osu.Tests
{
public class OsuScoreMultiplierTest : RulesetScoreMultiplierTest
{
public OsuScoreMultiplierTest()
: base(new OsuRuleset())
{
}
private static readonly object[][] test_cases =
[
#region Difficulty Reduction
[new Mod[] { new OsuModEasy() }, 0.5],
[new Mod[] { new OsuModNoFail() }, 0.5],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new OsuModHalfTime { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new OsuModDaycore { SpeedChange = { Value = 0.99 } } }, 0.5],
#endregion
#region Difficulty Increase
[new Mod[] { new OsuModHardRock() }, 1.06],
[new Mod[] { new OsuModSuddenDeath() }, 1],
[new Mod[] { new OsuModPerfect() }, 1],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new OsuModNightcore { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new OsuModHidden() }, 1.06],
[new Mod[] { new OsuModHidden { OnlyFadeApproachCircles = { Value = true } } }, 1],
[new Mod[] { new OsuModTraceable() }, 1],
[new Mod[] { new OsuModFlashlight() }, 1.12],
[new Mod[] { new OsuModFlashlight { ComboBasedSize = { Value = false } } }, 1],
[new Mod[] { new OsuModBlinds() }, 1.12],
[new Mod[] { new OsuModStrictTracking() }, 1],
[new Mod[] { new OsuModAccuracyChallenge() }, 1],
#endregion
#region Conversion
[new Mod[] { new OsuModTargetPractice() }, 0.1],
[new Mod[] { new OsuModDifficultyAdjust() }, 0.5],
[new Mod[] { new OsuModClassic() }, 0.96],
[new Mod[] { new OsuModRandom() }, 1],
[new Mod[] { new OsuModMirror() }, 1],
[new Mod[] { new OsuModAlternate() }, 1],
[new Mod[] { new OsuModSingleTap() }, 1],
#endregion
#region Automation
[new Mod[] { new OsuModAutoplay() }, 1],
[new Mod[] { new OsuModCinema() }, 1],
[new Mod[] { new OsuModRelax() }, 0.1],
[new Mod[] { new OsuModAutopilot() }, 0.1],
[new Mod[] { new OsuModSpunOut() }, 0.9],
#endregion
#region Fun
[new Mod[] { new OsuModTransform() }, 1],
[new Mod[] { new OsuModWiggle() }, 1],
[new Mod[] { new OsuModSpinIn() }, 1],
[new Mod[] { new OsuModGrow() }, 1],
[new Mod[] { new OsuModDeflate() }, 1],
[new Mod[] { new ModWindUp() }, 0.5],
[new Mod[] { new ModWindDown() }, 0.5],
[new Mod[] { new OsuModBarrelRoll() }, 1],
[new Mod[] { new OsuModApproachDifferent() }, 1],
[new Mod[] { new OsuModMuted() }, 1],
[new Mod[] { new OsuModNoScope() }, 1],
[new Mod[] { new OsuModMagnetised() }, 0.5],
[new Mod[] { new OsuModRepel() }, 1],
[new Mod[] { new ModAdaptiveSpeed() }, 0.5],
[new Mod[] { new OsuModFreezeFrame() }, 1],
[new Mod[] { new OsuModBubbles() }, 1],
[new Mod[] { new OsuModSynesthesia() }, 0.8],
[new Mod[] { new OsuModDepth() }, 1],
[new Mod[] { new OsuModBloom() }, 1],
#endregion
#region System
[new Mod[] { new OsuModTouchDevice() }, 1],
[new Mod[] { new ModScoreV2() }, 1],
#endregion
#region Combinations
[new Mod[] { new OsuModHidden(), new OsuModHardRock() }, 1.06 * 1.06],
#endregion
];
[TestCaseSource(nameof(test_cases))]
public void TestMultipliers(Mod[] mods, double expectedMultiplier)
=> TestModCombination(mods, expectedMultiplier);
}
}
@@ -792,7 +792,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
AddStep("export beatmap", () =>
{
var beatmapEncoder = new LegacyBeatmapEncoder(playableBeatmap, null);
var beatmapEncoder = new LegacyBeatmapEncoder(playableBeatmap, null, null);
using (var stream = File.Open(Path.Combine(exportLocation, $"{testCaseName}.osu"), FileMode.Create))
{
@@ -54,6 +54,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
[Resolved]
private EditorClock? editorClock { get; set; }
[Resolved]
private OsuSliderVelocityToolboxGroup? sliderVelocityToolbox { get; set; }
private Bindable<bool> limitedDistanceSnap { get; set; } = null!;
private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 };
@@ -111,9 +114,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
}
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime)
{
var result = composer?.TrySnapToNearbyObjects(screenSpacePosition, fallbackTime);
@@ -129,11 +129,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
case SliderPlacementState.Initial:
BeginPlacement();
double? nearestSliderVelocity = (editorBeatmap
.HitObjects
.LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime) as Slider)?.SliderVelocityMultiplier;
HitObject.SliderVelocityMultiplier = nearestSliderVelocity ?? 1;
HitObject.SliderVelocityMultiplier = sliderVelocityToolbox?.SliderVelocity.Value ?? 1;
HitObject.Position = ToLocalSpace(result.ScreenSpacePosition);
// Replacing the DifficultyControlPoint above doesn't trigger any kind of invalidation.
@@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public partial class FreehandSliderToolboxGroup : EditorToolboxGroup
{
public FreehandSliderToolboxGroup()
: base("slider")
: base("freehand")
{
}
@@ -7,14 +7,13 @@ using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit
{
public class HitCircleCompositionTool : CompositionTool
{
public HitCircleCompositionTool()
: base(nameof(HitCircle))
: base("Hit circle")
{
}
@@ -75,6 +75,9 @@ namespace osu.Game.Rulesets.Osu.Edit
[Cached(typeof(IDistanceSnapProvider))]
public readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider();
[Cached]
private readonly OsuSliderVelocityToolboxGroup sliderVelocityToolboxGroup = new OsuSliderVelocityToolboxGroup();
[Cached]
protected readonly OsuGridToolboxGroup OsuGridToolboxGroup = new OsuGridToolboxGroup();
@@ -111,6 +114,7 @@ namespace osu.Game.Rulesets.Osu.Edit
RightToolbox.AddRange(new Drawable[]
{
sliderVelocityToolboxGroup,
OsuGridToolboxGroup,
new TransformToolboxGroup
{
@@ -0,0 +1,213 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSliderVelocityToolboxGroup : EditorToolboxGroup
{
/// <summary>
/// Whether the last slider's velocity should be used (if available).
/// </summary>
private bool useLastSliderVelocity;
/// <summary>
/// The slider velocity to be used for new object placements.
/// </summary>
public IBindable<double> SliderVelocity => sliderVelocity;
private readonly BindableDouble sliderVelocity = new BindableDouble(1)
{
Precision = 0.01,
MinValue = 0.1,
MaxValue = 10,
};
private ExpandableSlider<double> slider = null!;
private ExpandableButton useLastSliderButton = null!;
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
[Resolved]
private EditorClock editorClock { get; set; } = null!;
private bool syncingBindables;
private double lastClockPosition = double.NegativeInfinity;
private readonly Cached<Slider?> sliderVelocitySourceObject = new Cached<Slider?>();
public OsuSliderVelocityToolboxGroup()
: base("velocity")
{
}
[BackgroundDependencyLoader]
private void load()
{
Spacing = new Vector2(5);
Children = new Drawable[]
{
slider = new ExpandableSlider<double>
{
ExpandedLabelText = "Slider velocity",
Current = new BindableDouble(1)
{
Precision = 0.01,
MinValue = 0.1,
MaxValue = 10,
},
KeyboardStep = 0.1f,
},
useLastSliderButton = new ExpandableButton
{
RelativeSizeAxes = Axes.X,
Action = () =>
{
useLastSliderVelocity = true;
sliderVelocitySourceObject.Invalidate();
},
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
// set unconditionally to true initially.
// if there is no object available to get the slider velocity from, the code in `Update()` will handle that.
useLastSliderVelocity = true;
sliderVelocity.BindValueChanged(_ => updateSliderFromVelocity(), true);
slider.Current.BindValueChanged(_ =>
{
updateVelocityFromSlider();
updateContractedText();
});
updateContractedText();
useLastSliderButton.Expanded.BindValueChanged(_ => sliderVelocitySourceObject.Invalidate());
editorBeatmap.HitObjectAdded += invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectUpdated += invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectRemoved += invalidateSliderVelocitySourceObject;
}
private void updateContractedText()
{
slider.ContractedLabelText = LocalisableString.Interpolate($@"SV: {slider.Current.Value.ToLocalisableString("N2")}x");
}
/// <summary>
/// Updates the displayed value of this toolbox's slider from a change to <see cref="SliderVelocity"/>
/// (which is the source-of-truth used for new object placements).
/// This is only relevant when <see cref="useLastSliderVelocity"/> is true,
/// in which case this code is responsible for propagating the velocity from <see cref="sliderVelocitySourceObject"/> to the slider.
/// </summary>
private void updateSliderFromVelocity()
{
if (syncingBindables)
return;
if (!useLastSliderVelocity)
return;
syncingBindables = true;
slider.Current.Value = sliderVelocity.Value;
syncingBindables = false;
}
/// <summary>
/// Updates the value of <see cref="SliderVelocity"/> from a change to the slider's state.
/// This change is assumed to be user-provoked, and therefore <see cref="useLastSliderVelocity"/> is switched unconditionally off
/// as the presumed intent is to override the velocity from <see cref="sliderVelocitySourceObject"/>.
/// </summary>
private void updateVelocityFromSlider()
{
if (syncingBindables)
return;
syncingBindables = true;
useLastSliderVelocity = false;
sliderVelocity.Value = slider.Current.Value;
syncingBindables = false;
sliderVelocitySourceObject.Invalidate();
}
private void invalidateSliderVelocitySourceObject(HitObject _) => sliderVelocitySourceObject.Invalidate();
protected override void Update()
{
base.Update();
if (editorClock.CurrentTime != lastClockPosition)
{
sliderVelocitySourceObject.Invalidate();
lastClockPosition = editorClock.CurrentTime;
}
// Three possible causes of invalidation:
// - The user seeked the clock, which means a different velocity source object needs to be used.
// - Some change to the beatmap was made, which means the previously-used velocity source object may no longer be the most relevant one.
// - The user is interacting with the toolbox in a way that requires a visual state update
// (hovered to expand it, clicked the button to use last slider's velocity, or dragged the manual velocity slider).
// This is a procedural one, because `sliderVelocitySourceObject` will have been pointing at the correct object already,
// but to decrease unnecessary work being done every frame, the invalidation is explicitly re-triggered to update the toolbox state.
if (!sliderVelocitySourceObject.IsValid)
{
var lastSlider = getLastSlider();
sliderVelocitySourceObject.Value = lastSlider;
if (lastSlider == null)
{
useLastSliderButton.Enabled.Value = false;
useLastSliderButton.ExpandedLabelText = "No sliders to get velocity from";
useLastSliderButton.ContractedLabelText = default;
}
else
{
useLastSliderButton.Enabled.Value = useLastSliderButton.Expanded.Value && !useLastSliderVelocity;
useLastSliderButton.ExpandedLabelText = useLastSliderVelocity
? "Using last slider's velocity"
: LocalisableString.Interpolate($@"Use last slider's velocity ({lastSlider.SliderVelocityMultiplier.ToLocalisableString("N2")}x)");
useLastSliderButton.ContractedLabelText = $@"current {lastSlider.SliderVelocityMultiplier.ToLocalisableString("N2")}x";
if (useLastSliderVelocity)
sliderVelocity.Value = lastSlider.SliderVelocityMultiplier;
}
}
}
private Slider? getLastSlider()
{
return editorBeatmap
.HitObjects
.OfType<Slider>()
.LastOrDefault(h => h.StartTime <= editorClock.CurrentTime);
}
protected override void Dispose(bool isDisposing)
{
if (editorBeatmap.IsNotNull())
{
editorBeatmap.HitObjectAdded -= invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectUpdated -= invalidateSliderVelocitySourceObject;
editorBeatmap.HitObjectRemoved -= invalidateSliderVelocitySourceObject;
}
base.Dispose(isDisposing);
}
}
}
+2
View File
@@ -234,6 +234,8 @@ namespace osu.Game.Rulesets.Osu
}
}
public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator(ScoreMultiplierContext context) => new OsuScoreMultiplierCalculator(context);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetOsu };
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(RulesetInfo, beatmap);
@@ -0,0 +1,101 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Scoring
{
public class OsuScoreMultiplierCalculator : ScoreMultiplierCalculator
{
public OsuScoreMultiplierCalculator(ScoreMultiplierContext context)
: base(context)
{
#region Difficulty Reduction
Single<OsuModEasy>(hasMultiplier: 0.5);
Single<OsuModNoFail>(hasMultiplier: 0.5);
Single<OsuModHalfTime>(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value));
Single<OsuModDaycore>(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value));
#endregion
#region Difficulty Increase
Single<OsuModHardRock>(hasMultiplier: hardRock => hardRock.UsesDefaultConfiguration ? 1.06 : 1);
// Sudden Death
// Perfect
Single<OsuModDoubleTime>(hasMultiplier: doubleTime => rateAdjustMultiplier(doubleTime.SpeedChange.Value));
Single<OsuModNightcore>(hasMultiplier: nightcore => rateAdjustMultiplier(nightcore.SpeedChange.Value));
Single<OsuModHidden>(hasMultiplier: hidden => hidden.UsesDefaultConfiguration ? 1.06 : 1);
// Traceable
Single<OsuModFlashlight>(hasMultiplier: flashlight => flashlight.UsesDefaultConfiguration ? 1.12 : 1);
Single<OsuModBlinds>(hasMultiplier: blinds => blinds.UsesDefaultConfiguration ? 1.12 : 1);
// Strict Tracking
// Accuracy Challenge
#endregion
#region Conversion
Single<OsuModTargetPractice>(hasMultiplier: 0.1);
Single<OsuModDifficultyAdjust>(hasMultiplier: 0.5);
Single<OsuModClassic>(hasMultiplier: 0.96);
// Random
// Mirror
// Alternate
// Single Tap
#endregion
#region Automation
// Autoplay
// Cinema
Single<OsuModRelax>(hasMultiplier: 0.1);
Single<OsuModAutopilot>(hasMultiplier: 0.1);
Single<OsuModSpunOut>(hasMultiplier: 0.9);
#endregion
#region Fun
// Transform
// Wiggle
// Spin In
// Grow
// Deflate
Single<ModWindUp>(hasMultiplier: 0.5);
Single<ModWindDown>(hasMultiplier: 0.5);
// Barrel Roll
// Approach Different
// Muted
// No Scope
Single<OsuModMagnetised>(hasMultiplier: 0.5);
// Repel
Single<ModAdaptiveSpeed>(hasMultiplier: 0.5);
// Freeze Frame
// Bubbles
Single<OsuModSynesthesia>(hasMultiplier: 0.8);
// Depth
// Bloom
#endregion
}
private static double rateAdjustMultiplier(double speedChange)
{
// Round to the nearest multiple of 0.1.
double value = (int)(speedChange * 10) / 10.0;
// Offset back to 0.
value -= 1;
if (speedChange >= 1)
return 1 + value / 5;
else
return 0.6 + value;
}
}
}
@@ -0,0 +1,157 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Tests.Rulesets;
namespace osu.Game.Rulesets.Taiko.Tests
{
public class TaikoScoreMultiplierTest : RulesetScoreMultiplierTest
{
public TaikoScoreMultiplierTest()
: base(new TaikoRuleset())
{
}
private static readonly object[][] test_cases =
[
#region Difficulty Reduction
[new Mod[] { new TaikoModEasy() }, 0.5],
[new Mod[] { new TaikoModNoFail() }, 0.5],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new TaikoModHalfTime { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.50 } } }, 0.1],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.55 } } }, 0.1],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.60 } } }, 0.2],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.65 } } }, 0.2],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.70 } } }, 0.3],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.75 } } }, 0.3],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.80 } } }, 0.4],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.85 } } }, 0.4],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.90 } } }, 0.5],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.95 } } }, 0.5],
[new Mod[] { new TaikoModDaycore { SpeedChange = { Value = 0.99 } } }, 0.5],
[new Mod[] { new TaikoModSimplifiedRhythm() }, 0.6],
#endregion
#region Difficulty Increase
[new Mod[] { new TaikoModHardRock() }, 1.06],
[new Mod[] { new TaikoModSuddenDeath() }, 1],
[new Mod[] { new TaikoModPerfect() }, 1],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new TaikoModDoubleTime { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.01 } } }, 1.00],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.05 } } }, 1.00],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.10 } } }, 1.02],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.15 } } }, 1.02],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.20 } } }, 1.04],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.25 } } }, 1.04],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.30 } } }, 1.06],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.35 } } }, 1.06],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.40 } } }, 1.08],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.45 } } }, 1.08],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.50 } } }, 1.10],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.55 } } }, 1.10],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.60 } } }, 1.12],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.65 } } }, 1.12],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.70 } } }, 1.14],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.75 } } }, 1.14],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.80 } } }, 1.16],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.85 } } }, 1.16],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.90 } } }, 1.18],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 1.95 } } }, 1.18],
[new Mod[] { new TaikoModNightcore { SpeedChange = { Value = 2.00 } } }, 1.20],
[new Mod[] { new TaikoModHidden() }, 1.06],
[new Mod[] { new TaikoModFlashlight() }, 1.12],
[new Mod[] { new TaikoModFlashlight { ComboBasedSize = { Value = false } } }, 1],
[new Mod[] { new ModAccuracyChallenge() }, 1],
#endregion
#region Conversion
[new Mod[] { new TaikoModRandom() }, 1],
[new Mod[] { new TaikoModDifficultyAdjust() }, 0.5],
[new Mod[] { new TaikoModClassic() }, 0.96],
[new Mod[] { new TaikoModSwap() }, 1],
[new Mod[] { new TaikoModSingleTap() }, 1],
[new Mod[] { new TaikoModConstantSpeed() }, 0.9],
#endregion
#region Automation
[new Mod[] { new TaikoModAutoplay() }, 1],
[new Mod[] { new TaikoModCinema() }, 1],
[new Mod[] { new TaikoModRelax() }, 0.1],
#endregion
#region Fun
[new Mod[] { new ModWindUp() }, 0.5],
[new Mod[] { new ModWindDown() }, 0.5],
[new Mod[] { new TaikoModMuted() }, 1],
[new Mod[] { new ModAdaptiveSpeed() }, 0.5],
#endregion
#region System
[new Mod[] { new ModScoreV2() }, 1],
#endregion
#region Combinations
[new Mod[] { new TaikoModHidden(), new TaikoModHardRock() }, 1.06 * 1.06]
#endregion
];
[TestCaseSource(nameof(test_cases))]
public void TestMultipliers(Mod[] mods, double expectedMultiplier)
=> TestModCombination(mods, expectedMultiplier);
}
}
@@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
currentStoryboard = new Storyboard();
for (int i = 0; i < 10; i++)
currentStoryboard.GetLayer("Foreground").Add(new StoryboardSprite($"test{i}", Anchor.Centre, Vector2.Zero));
currentStoryboard.GetLayer("Foreground").Add(new StoryboardSprite(StoryboardElementSource.Beatmap, $"test{i}", Anchor.Centre, Vector2.Zero));
});
CreateTest();
@@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
currentStoryboard = new Storyboard();
for (int i = 0; i < 10; i++)
currentStoryboard.GetLayer("Overlay").Add(new StoryboardSprite($"test{i}", Anchor.Centre, Vector2.Zero));
currentStoryboard.GetLayer("Overlay").Add(new StoryboardSprite(StoryboardElementSource.Beatmap, $"test{i}", Anchor.Centre, Vector2.Zero));
});
CreateTest();
@@ -2,22 +2,22 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Edit
{
public class DrumRollCompositionTool : CompositionTool
{
public DrumRollCompositionTool()
: base(nameof(DrumRoll))
: base("Drum roll")
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorDrumRoll };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint();
}
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
@@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorHit };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint();
}
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
@@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorSwell };
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint();
}
@@ -3,10 +3,15 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
@@ -21,6 +26,29 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
protected override Drawable CreateNewComboButton() => new NewComboTernaryButton
{
Current = NewCombo,
CreateIcon = () => new Container
{
Children = new Drawable[]
{
new SpriteIcon
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Icon = OsuIcon.EditorHit,
Size = new Vector2(15),
},
new SpriteIcon
{
Icon = OsuIcon.EditorNewComboSparkles,
Size = new Vector2(20),
}
},
},
};
protected override SelectionHandler<HitObject> CreateSelectionHandler() => new TaikoSelectionHandler();
public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) =>
@@ -0,0 +1,87 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Mods;
namespace osu.Game.Rulesets.Taiko.Scoring
{
public class TaikoScoreMultiplierCalculator : ScoreMultiplierCalculator
{
public TaikoScoreMultiplierCalculator(ScoreMultiplierContext context)
: base(context)
{
#region Difficulty Reduction
Single<TaikoModEasy>(hasMultiplier: 0.5);
Single<TaikoModNoFail>(hasMultiplier: 0.5);
Single<TaikoModHalfTime>(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value));
Single<TaikoModDaycore>(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value));
Single<TaikoModSimplifiedRhythm>(hasMultiplier: 0.6);
#endregion
#region Difficulty Increase
Single<TaikoModHardRock>(hasMultiplier: hardRock => hardRock.UsesDefaultConfiguration ? 1.06 : 1);
// Sudden Death
// Perfect
Single<TaikoModDoubleTime>(hasMultiplier: doubleTime => rateAdjustMultiplier(doubleTime.SpeedChange.Value));
Single<TaikoModNightcore>(hasMultiplier: nightcore => rateAdjustMultiplier(nightcore.SpeedChange.Value));
Single<TaikoModHidden>(hasMultiplier: hidden => hidden.UsesDefaultConfiguration ? 1.06 : 1);
Single<TaikoModFlashlight>(hasMultiplier: flashlight => flashlight.UsesDefaultConfiguration ? 1.12 : 1);
// Accuracy Challenge
#endregion
#region Conversion
// Random
Single<TaikoModDifficultyAdjust>(hasMultiplier: 0.5);
Single<TaikoModClassic>(hasMultiplier: 0.96);
// Swap
// Single Tap
Single<TaikoModConstantSpeed>(hasMultiplier: 0.9);
#endregion
#region Automation
// Autoplay
// Cinema
Single<TaikoModRelax>(hasMultiplier: 0.1);
#endregion
#region Fun
Single<ModWindUp>(hasMultiplier: 0.5);
Single<ModWindDown>(hasMultiplier: 0.5);
// Muted
Single<ModAdaptiveSpeed>(hasMultiplier: 0.5);
#endregion
#region System
// Score V2
#endregion
}
private static double rateAdjustMultiplier(double speedChange)
{
// Round to the nearest multiple of 0.1.
double value = (int)(speedChange * 10) / 10.0;
// Offset back to 0.
value -= 1;
if (speedChange >= 1)
return 1 + value / 5;
else
return 0.6 + value;
}
}
}
+2
View File
@@ -188,6 +188,8 @@ namespace osu.Game.Rulesets.Taiko
}
}
public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator(ScoreMultiplierContext context) => new TaikoScoreMultiplierCalculator(context);
public override string Description => "osu!taiko";
public override string ShortName => SHORT_NAME;
@@ -41,14 +41,14 @@ namespace osu.Game.Tests.Beatmaps.Formats
private static IEnumerable<string> allBeatmaps = beatmaps_resource_store.GetAvailableResources().Where(res => res.EndsWith(".osu", StringComparison.Ordinal));
public record BeatmapComponents(IBeatmap Beatmap, LegacySkin Skin, Storyboard Storyboard);
[Test]
public void TestUnsupportedStoryboardEvents()
public void TestStoryboardEvents()
{
const string name = "Resources/storyboard_only_video.osu";
var decoded = DecodeFromLegacy(beatmaps_resource_store.GetStream(name), beatmaps_resource_store, name);
Assert.That(decoded.beatmap.UnhandledEventLines.Count, Is.EqualTo(1));
Assert.That(decoded.beatmap.UnhandledEventLines.Single(), Is.EqualTo("Video,0,\"video.avi\""));
var memoryStream = EncodeToLegacy(decoded);
@@ -63,8 +63,8 @@ namespace osu.Game.Tests.Beatmaps.Formats
var decoded = DecodeFromLegacy(beatmaps_resource_store.GetStream(name), beatmaps_resource_store, name);
var decodedAfterEncode = DecodeFromLegacy(EncodeToLegacy(decoded), beatmaps_resource_store, name);
Sort(decoded.beatmap);
Sort(decodedAfterEncode.beatmap);
Sort(decoded.Beatmap);
Sort(decodedAfterEncode.Beatmap);
CompareBeatmaps(decoded, decodedAfterEncode);
}
@@ -76,10 +76,10 @@ namespace osu.Game.Tests.Beatmaps.Formats
var decodedAfterEncode = DecodeFromLegacy(EncodeToLegacy(decoded), beatmaps_resource_store, name);
// run an extra convert. this is expected to be stable.
decodedAfterEncode.beatmap = convert(decodedAfterEncode.beatmap);
decodedAfterEncode = decodedAfterEncode with { Beatmap = convert(decodedAfterEncode.Beatmap) };
Sort(decoded.beatmap);
Sort(decodedAfterEncode.beatmap);
Sort(decoded.Beatmap);
Sort(decodedAfterEncode.Beatmap);
CompareBeatmaps(decoded, decodedAfterEncode);
}
@@ -91,7 +91,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
// we are testing that the transfer of relevant data to hitobjects (from legacy control points) sticks through encode/decode.
// before the encode step, the legacy information is removed here.
decoded.beatmap.ControlPointInfo = removeLegacyControlPointTypes(decoded.beatmap.ControlPointInfo);
decoded.Beatmap.ControlPointInfo = removeLegacyControlPointTypes(decoded.Beatmap.ControlPointInfo);
var decodedAfterEncode = DecodeFromLegacy(EncodeToLegacy(decoded), beatmaps_resource_store, name);
@@ -120,17 +120,21 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
public static void CompareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual)
public static void CompareBeatmaps(BeatmapComponents expected, BeatmapComponents actual)
{
// Check all control points that are still considered to be at a global level.
Assert.That(actual.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.TimingPoints.Serialize()));
Assert.That(actual.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.EffectPoints.Serialize()));
Assert.That(actual.Beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(expected.Beatmap.ControlPointInfo.TimingPoints.Serialize()));
Assert.That(actual.Beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(expected.Beatmap.ControlPointInfo.EffectPoints.Serialize()));
// Check all hitobjects.
Assert.That(actual.beatmap.HitObjects.Serialize(), Is.EqualTo(expected.beatmap.HitObjects.Serialize()));
Assert.That(actual.Beatmap.HitObjects.Serialize(), Is.EqualTo(expected.Beatmap.HitObjects.Serialize()));
// Check skin.
ClassicAssert.True(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration));
ClassicAssert.True(areComboColoursEqual(expected.Skin.Configuration, actual.Skin.Configuration));
// Do a rough pass on storyboard layers.
foreach (string layer in actual.Storyboard.Layers.Concat(expected.Storyboard.Layers).Select(l => l.Name).Distinct())
Assert.That(actual.Storyboard.GetLayer(layer).Elements.Count, Is.EqualTo(expected.Storyboard.GetLayer(layer).Elements.Count));
}
[Test]
@@ -153,9 +157,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
};
var encoded = EncodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty)));
var encoded = EncodeToLegacy(new BeatmapComponents(beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty), new Storyboard()));
var decodedAfterEncode = DecodeFromLegacy(encoded, beatmaps_resource_store, string.Empty);
var decodedSlider = (Slider)decodedAfterEncode.beatmap.HitObjects[0];
var decodedSlider = (Slider)decodedAfterEncode.Beatmap.HitObjects[0];
Assert.That(decodedSlider.Path.ControlPoints.Count, Is.EqualTo(4));
Assert.That(decodedSlider.Path.ControlPoints[0].Type, Is.EqualTo(PathType.BSpline(3)));
Assert.That(decodedSlider.Path.ControlPoints[2].Type, Is.EqualTo(PathType.BSpline(3)));
@@ -183,9 +187,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
};
var encoded = EncodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty)));
var encoded = EncodeToLegacy(new BeatmapComponents(beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty), new Storyboard()));
var decodedAfterEncode = DecodeFromLegacy(encoded, beatmaps_resource_store, string.Empty);
var decodedSlider = (Slider)decodedAfterEncode.beatmap.HitObjects[0];
var decodedSlider = (Slider)decodedAfterEncode.Beatmap.HitObjects[0];
Assert.That(decodedSlider.Path.ControlPoints.Count, Is.EqualTo(5));
}
@@ -211,9 +215,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
};
var encoded = EncodeToLegacy((new Beatmap(), beatmapSkin));
var encoded = EncodeToLegacy(new BeatmapComponents(new Beatmap(), beatmapSkin, new Storyboard()));
var decodedAfterEncode = DecodeFromLegacy(encoded, beatmaps_resource_store, string.Empty);
Assert.That(decodedAfterEncode.skin.Configuration.CustomComboColours, Has.Count.EqualTo(8));
Assert.That(decodedAfterEncode.Skin.Configuration.CustomComboColours, Has.Count.EqualTo(8));
}
[Test]
@@ -234,9 +238,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
HitObjects = { originalSlider }
};
var encoded = EncodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty)));
var encoded = EncodeToLegacy(new BeatmapComponents(beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty), new Storyboard()));
var decodedAfterEncode = DecodeFromLegacy(encoded, beatmaps_resource_store, string.Empty, version: LegacyBeatmapEncoder.FIRST_LAZER_VERSION);
var decodedSlider = (Slider)decodedAfterEncode.beatmap.HitObjects[0];
var decodedSlider = (Slider)decodedAfterEncode.Beatmap.HitObjects[0];
Assert.That(decodedSlider.Path.ControlPoints.Select(p => p.Position),
Is.EquivalentTo(originalSlider.Path.ControlPoints.Select(p => p.Position)));
}
@@ -254,17 +258,17 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
};
var encoded = EncodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty)));
var encoded = EncodeToLegacy(new BeatmapComponents(beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty), new Storyboard()));
var decodedAfterEncode = DecodeFromLegacy(encoded, beatmaps_resource_store, string.Empty);
Assert.That(decodedAfterEncode.beatmap.HitObjects[0].Samples[0].Suffix, Is.Null);
Assert.That(decodedAfterEncode.beatmap.HitObjects[0].Samples[0].UseBeatmapSamples, Is.False);
Assert.That(decodedAfterEncode.Beatmap.HitObjects[0].Samples[0].Suffix, Is.Null);
Assert.That(decodedAfterEncode.Beatmap.HitObjects[0].Samples[0].UseBeatmapSamples, Is.False);
Assert.That(decodedAfterEncode.beatmap.HitObjects[1].Samples[0].Suffix, Is.Null);
Assert.That(decodedAfterEncode.beatmap.HitObjects[1].Samples[0].UseBeatmapSamples, Is.True);
Assert.That(decodedAfterEncode.Beatmap.HitObjects[1].Samples[0].Suffix, Is.Null);
Assert.That(decodedAfterEncode.Beatmap.HitObjects[1].Samples[0].UseBeatmapSamples, Is.True);
Assert.That(decodedAfterEncode.beatmap.HitObjects[2].Samples[0].Suffix, Is.EqualTo("3"));
Assert.That(decodedAfterEncode.beatmap.HitObjects[2].Samples[0].UseBeatmapSamples, Is.True);
Assert.That(decodedAfterEncode.Beatmap.HitObjects[2].Samples[0].Suffix, Is.EqualTo("3"));
Assert.That(decodedAfterEncode.Beatmap.HitObjects[2].Samples[0].UseBeatmapSamples, Is.True);
}
private static bool areComboColoursEqual(IHasComboColours a, IHasComboColours b)
@@ -289,7 +293,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
public static (IBeatmap beatmap, TestLegacySkin skin) DecodeFromLegacy(Stream stream, IResourceStore<byte[]> beatmapsResourceStore, string name, int version = LegacyDecoder<Beatmap>.LATEST_VERSION)
public static BeatmapComponents DecodeFromLegacy(Stream stream, IResourceStore<byte[]> beatmapsResourceStore, string name, int version = LegacyDecoder<Beatmap>.LATEST_VERSION)
{
using (var reader = new LineBufferedReader(stream))
{
@@ -297,7 +301,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
var beatmapSkin = new TestLegacySkin(beatmapsResourceStore, name);
stream.Seek(0, SeekOrigin.Begin);
beatmapSkin.Configuration = new LegacySkinDecoder().Decode(reader);
return (convert(beatmap), beatmapSkin);
stream.Seek(0, SeekOrigin.Begin);
var storyboard = new LegacyStoryboardDecoder().Decode(reader);
return new BeatmapComponents(convert(beatmap), beatmapSkin, storyboard);
}
}
@@ -309,13 +315,13 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
public static MemoryStream EncodeToLegacy((IBeatmap beatmap, ISkin skin) fullBeatmap)
public static MemoryStream EncodeToLegacy(BeatmapComponents fullBeatmap)
{
var (beatmap, beatmapSkin) = fullBeatmap;
var (beatmap, beatmapSkin, storyboard) = fullBeatmap;
var stream = new MemoryStream();
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(beatmap, beatmapSkin).Encode(writer);
new LegacyBeatmapEncoder(beatmap, beatmapSkin, storyboard).Encode(writer);
stream.Position = 0;
@@ -0,0 +1,400 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO;
using osu.Game.Storyboards;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Tests.Beatmaps.Formats
{
[TestFixture]
public class LegacyStoryboardEncoderTest
{
[Test]
public void TestBackground()
{
var initial = createComponents();
initial.Beatmap.BeatmapInfo.Metadata.BackgroundFile = "bg.jpg";
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
Assert.That(decodedAfterEncode.Beatmap.BeatmapInfo.Metadata.BackgroundFile, Is.EqualTo("bg.jpg"));
}
[Test]
public void TestBackgroundOffset()
{
var initial = createComponents();
initial.Beatmap.BeatmapInfo.Metadata.BackgroundFile = "bg_offset.jpg";
initial.Storyboard.BackgroundOffset = new Vector2(0, 45);
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
Assert.Multiple(() =>
{
Assert.That(decodedAfterEncode.Beatmap.BeatmapInfo.Metadata.BackgroundFile, Is.EqualTo("bg_offset.jpg"));
Assert.That(decodedAfterEncode.Storyboard.BackgroundOffset, Is.EqualTo(new Vector2(0, 45)));
});
}
[Test]
public void TestVideos()
{
var initial = createComponents();
initial.Storyboard.GetLayer("Video").Add(new StoryboardVideo(StoryboardElementSource.Beatmap, "video1.avi", 0));
initial.Storyboard.GetLayer("Video").Add(new StoryboardVideo(StoryboardElementSource.Shared, "video2.mp4", 1234));
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
Assert.Multiple(() =>
{
var videoLayer = decodedAfterEncode.Storyboard.GetLayer("Video");
Assert.That(videoLayer.Elements, Has.Count.EqualTo(2));
Assert.That(videoLayer.Elements[0].Source, Is.EqualTo(StoryboardElementSource.Beatmap));
Assert.That(videoLayer.Elements[0].Path, Is.EqualTo("video1.avi"));
Assert.That(videoLayer.Elements[0].StartTime, Is.EqualTo(0));
Assert.That(videoLayer.Elements[1].Source, Is.EqualTo(StoryboardElementSource.Shared));
Assert.That(videoLayer.Elements[1].Path, Is.EqualTo("video2.mp4"));
Assert.That(videoLayer.Elements[1].StartTime, Is.EqualTo(1234));
});
}
[Test]
public void TestVideoWithCommands()
{
var initial = createComponents();
var video = new StoryboardVideo(StoryboardElementSource.Beatmap, "video1.avi", 0);
video.Commands.AddScale(Easing.None, 0, 0, 0.7f, 0.7f);
initial.Storyboard.GetLayer("Video").Add(video);
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
Assert.Multiple(() =>
{
var decodedVideo = (StoryboardVideo)decodedAfterEncode.Storyboard.GetLayer("Video").Elements.Single();
Assert.That(decodedVideo.Source, Is.EqualTo(StoryboardElementSource.Beatmap));
Assert.That(decodedVideo.Path, Is.EqualTo("video1.avi"));
Assert.That(decodedVideo.StartTime, Is.EqualTo(0));
Assert.That(decodedVideo.Commands.Scale, Has.Count.EqualTo(1));
var scaleCommand = (decodedVideo.Commands.Scale.Single());
Assert.That(scaleCommand.Easing, Is.EqualTo(Easing.None));
Assert.That(scaleCommand.StartTime, Is.EqualTo(0));
Assert.That(scaleCommand.EndTime, Is.EqualTo(0));
Assert.That(scaleCommand.StartValue, Is.EqualTo(0.7f));
Assert.That(scaleCommand.EndValue, Is.EqualTo(0.7f));
});
}
[Test]
public void TestSpritesAndAnimations()
{
var initial = createComponents();
initial.Storyboard.GetLayer("Background").Add(new StoryboardSprite(StoryboardElementSource.Beatmap, "1.png", Anchor.TopLeft, new Vector2()));
initial.Storyboard.GetLayer("Fail").Add(new StoryboardSprite(StoryboardElementSource.Shared, "2.png", Anchor.Centre, new Vector2(-3)));
initial.Storyboard.GetLayer("Pass").Add(new StoryboardSprite(StoryboardElementSource.Shared, "3.png", Anchor.BottomRight, new Vector2(30, -30)));
initial.Storyboard.GetLayer("Foreground").Add(new StoryboardAnimation(StoryboardElementSource.Beatmap, "anim1", Anchor.CentreLeft, new Vector2(30), frameCount: 10, frameDelay: 30, AnimationLoopType.LoopForever));
initial.Storyboard.GetLayer("Overlay").Add(new StoryboardAnimation(StoryboardElementSource.Shared, "anim2", Anchor.CentreRight, new Vector2(30), frameCount: 4, frameDelay: 100, AnimationLoopType.LoopOnce));
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
var sb = decodedAfterEncode.Storyboard;
Assert.Multiple(() =>
{
var backgroundSprite = (StoryboardSprite)sb.GetLayer("Background").Elements.Single();
Assert.That(backgroundSprite.Source, Is.EqualTo(StoryboardElementSource.Beatmap));
Assert.That(backgroundSprite.Path, Is.EqualTo("1.png"));
Assert.That(backgroundSprite.Origin, Is.EqualTo(Anchor.TopLeft));
Assert.That(backgroundSprite.InitialPosition, Is.EqualTo(new Vector2()));
var failSprite = (StoryboardSprite)sb.GetLayer("Fail").Elements.Single();
Assert.That(failSprite.Source, Is.EqualTo(StoryboardElementSource.Shared));
Assert.That(failSprite.Path, Is.EqualTo("2.png"));
Assert.That(failSprite.Origin, Is.EqualTo(Anchor.Centre));
Assert.That(failSprite.InitialPosition, Is.EqualTo(new Vector2(-3)));
var passSprite = (StoryboardSprite)sb.GetLayer("Pass").Elements.Single();
Assert.That(passSprite.Source, Is.EqualTo(StoryboardElementSource.Shared));
Assert.That(passSprite.Path, Is.EqualTo("3.png"));
Assert.That(passSprite.Origin, Is.EqualTo(Anchor.BottomRight));
Assert.That(passSprite.InitialPosition, Is.EqualTo(new Vector2(30, -30)));
var foregroundAnimation = (StoryboardAnimation)sb.GetLayer("Foreground").Elements.Single();
Assert.That(foregroundAnimation.Source, Is.EqualTo(StoryboardElementSource.Beatmap));
Assert.That(foregroundAnimation.Path, Is.EqualTo("anim1"));
Assert.That(foregroundAnimation.Origin, Is.EqualTo(Anchor.CentreLeft));
Assert.That(foregroundAnimation.InitialPosition, Is.EqualTo(new Vector2(30)));
Assert.That(foregroundAnimation.FrameCount, Is.EqualTo(10));
Assert.That(foregroundAnimation.FrameDelay, Is.EqualTo(30));
Assert.That(foregroundAnimation.LoopType, Is.EqualTo(AnimationLoopType.LoopForever));
var overlayAnimation = (StoryboardAnimation)sb.GetLayer("Overlay").Elements.Single();
Assert.That(overlayAnimation.Source, Is.EqualTo(StoryboardElementSource.Shared));
Assert.That(overlayAnimation.Path, Is.EqualTo("anim2"));
Assert.That(overlayAnimation.Origin, Is.EqualTo(Anchor.CentreRight));
Assert.That(overlayAnimation.InitialPosition, Is.EqualTo(new Vector2(30)));
Assert.That(overlayAnimation.FrameCount, Is.EqualTo(4));
Assert.That(overlayAnimation.FrameDelay, Is.EqualTo(100));
Assert.That(overlayAnimation.LoopType, Is.EqualTo(AnimationLoopType.LoopOnce));
});
}
[Test]
public void TestCommands()
{
var initial = createComponents();
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "test.jpg", Anchor.Centre, new Vector2(300));
sprite.Commands.AddAlpha(Easing.InBack, 100, 200, 0, 1);
sprite.Commands.AddBlendingParameters(Easing.None, 300, 300, BlendingParameters.Additive, BlendingParameters.Additive);
sprite.Commands.AddColour(Easing.InCubic, 400, 500, Color4.White, Color4.Aquamarine);
sprite.Commands.AddFlipH(Easing.InOutQuad, 600, 600, true, true);
sprite.Commands.AddFlipV(Easing.InOutQuad, 800, 900, true, false);
sprite.Commands.AddRotation(Easing.OutSine, 1000, 1100, 0, 720);
sprite.Commands.AddScale(Easing.OutQuint, 1200, 1300, 1, 4);
sprite.Commands.AddVectorScale(Easing.InCirc, 1400, 1500, new Vector2(4), new Vector2(3, 1));
sprite.Commands.AddX(Easing.InOutQuad, 1600, 1700, 300, 500);
sprite.Commands.AddY(Easing.OutBounce, 1800, 1800, 300, 100);
initial.Storyboard.GetLayer("Background").Add(sprite);
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
var decodedSprite = (StoryboardSprite)decodedAfterEncode.Storyboard.GetLayer("Background").Elements.Single();
Assert.Multiple(() =>
{
var alphaCommand = decodedSprite.Commands.Alpha.Single();
Assert.That(alphaCommand.Easing, Is.EqualTo(Easing.InBack));
Assert.That(alphaCommand.StartTime, Is.EqualTo(100));
Assert.That(alphaCommand.EndTime, Is.EqualTo(200));
Assert.That(alphaCommand.StartValue, Is.EqualTo(0));
Assert.That(alphaCommand.EndValue, Is.EqualTo(1));
var blendingCommand = decodedSprite.Commands.BlendingParameters.Single();
Assert.That(blendingCommand.Easing, Is.EqualTo(Easing.None));
Assert.That(blendingCommand.StartTime, Is.EqualTo(300));
Assert.That(blendingCommand.EndTime, Is.EqualTo(300));
Assert.That(blendingCommand.StartValue, Is.EqualTo(BlendingParameters.Additive));
Assert.That(blendingCommand.EndValue, Is.EqualTo(BlendingParameters.Additive));
var colourCommand = decodedSprite.Commands.Colour.Single();
Assert.That(colourCommand.Easing, Is.EqualTo(Easing.InCubic));
Assert.That(colourCommand.StartTime, Is.EqualTo(400));
Assert.That(colourCommand.EndTime, Is.EqualTo(500));
Assert.That(colourCommand.StartValue, Is.EqualTo(Color4.White));
Assert.That(colourCommand.EndValue, Is.EqualTo(Color4.Aquamarine));
var flipHCommand = decodedSprite.Commands.FlipH.Single();
Assert.That(flipHCommand.Easing, Is.EqualTo(Easing.InOutQuad));
Assert.That(flipHCommand.StartTime, Is.EqualTo(600));
Assert.That(flipHCommand.EndTime, Is.EqualTo(600));
Assert.That(flipHCommand.StartValue, Is.EqualTo(true));
Assert.That(flipHCommand.EndValue, Is.EqualTo(true));
var flipVCommand = decodedSprite.Commands.FlipV.Single();
Assert.That(flipVCommand.Easing, Is.EqualTo(Easing.InOutQuad));
Assert.That(flipVCommand.StartTime, Is.EqualTo(800));
Assert.That(flipVCommand.EndTime, Is.EqualTo(900));
Assert.That(flipVCommand.StartValue, Is.EqualTo(true));
Assert.That(flipVCommand.EndValue, Is.EqualTo(false));
var rotationCommand = decodedSprite.Commands.Rotation.Single();
Assert.That(rotationCommand.Easing, Is.EqualTo(Easing.OutSine));
Assert.That(rotationCommand.StartTime, Is.EqualTo(1000));
Assert.That(rotationCommand.EndTime, Is.EqualTo(1100));
Assert.That(rotationCommand.StartValue, Is.EqualTo(0));
Assert.That(rotationCommand.EndValue, Is.EqualTo(720));
var scaleCommand = decodedSprite.Commands.Scale.Single();
Assert.That(scaleCommand.Easing, Is.EqualTo(Easing.OutQuint));
Assert.That(scaleCommand.StartTime, Is.EqualTo(1200));
Assert.That(scaleCommand.EndTime, Is.EqualTo(1300));
Assert.That(scaleCommand.StartValue, Is.EqualTo(1));
Assert.That(scaleCommand.EndValue, Is.EqualTo(4));
var vectorScaleCommand = decodedSprite.Commands.VectorScale.Single();
Assert.That(vectorScaleCommand.Easing, Is.EqualTo(Easing.InCirc));
Assert.That(vectorScaleCommand.StartTime, Is.EqualTo(1400));
Assert.That(vectorScaleCommand.EndTime, Is.EqualTo(1500));
Assert.That(vectorScaleCommand.StartValue, Is.EqualTo(new Vector2(4)));
Assert.That(vectorScaleCommand.EndValue, Is.EqualTo(new Vector2(3, 1)));
var xCommand = decodedSprite.Commands.X.Single();
Assert.That(xCommand.Easing, Is.EqualTo(Easing.InOutQuad));
Assert.That(xCommand.StartTime, Is.EqualTo(1600));
Assert.That(xCommand.EndTime, Is.EqualTo(1700));
Assert.That(xCommand.StartValue, Is.EqualTo(300));
Assert.That(xCommand.EndValue, Is.EqualTo(500));
var yCommand = decodedSprite.Commands.Y.Single();
Assert.That(yCommand.Easing, Is.EqualTo(Easing.OutBounce));
Assert.That(yCommand.StartTime, Is.EqualTo(1800));
Assert.That(yCommand.EndTime, Is.EqualTo(1800));
Assert.That(yCommand.StartValue, Is.EqualTo(300));
Assert.That(yCommand.EndValue, Is.EqualTo(100));
});
}
[Test]
public void TestLoopingGroup()
{
var initial = createComponents();
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "test.jpg", Anchor.Centre, new Vector2(300));
var loopingGroup = sprite.AddLoopingGroup(1000, 44);
loopingGroup.AddAlpha(Easing.OutQuint, 1000, 1500, 0, 1);
initial.Storyboard.GetLayer("Background").Add(sprite);
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
var decodedSprite = (StoryboardSprite)decodedAfterEncode.Storyboard.GetLayer("Background").Elements.Single();
Assert.Multiple(() =>
{
Assert.That(decodedSprite.LoopingGroups, Has.Count.EqualTo(1));
var decodedLoopingGroup = decodedSprite.LoopingGroups.Single();
Assert.That(decodedLoopingGroup.StartTime, Is.EqualTo(1000));
Assert.That(decodedLoopingGroup.TotalIterations, Is.EqualTo(45));
var alphaCommand = decodedLoopingGroup.Alpha.Single();
Assert.That(alphaCommand.Easing, Is.EqualTo(Easing.OutQuint));
Assert.That(alphaCommand.StartTime, Is.EqualTo(1000));
Assert.That(alphaCommand.EndTime, Is.EqualTo(1500));
Assert.That(alphaCommand.StartValue, Is.EqualTo(0));
Assert.That(alphaCommand.EndValue, Is.EqualTo(1));
});
}
[Test]
public void TestTriggerGroup()
{
var initial = createComponents();
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "test.jpg", Anchor.Centre, new Vector2(300));
var triggerGroup = sprite.AddTriggerGroup("Passing", 0, 100000, 33);
triggerGroup.AddAlpha(Easing.OutQuint, 0, 500, 0, 1);
initial.Storyboard.GetLayer("Background").Add(sprite);
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
var decodedSprite = (StoryboardSprite)decodedAfterEncode.Storyboard.GetLayer("Background").Elements.Single();
Assert.Multiple(() =>
{
Assert.That(decodedSprite.TriggerGroups, Has.Count.EqualTo(1));
var decodedTriggerGroup = decodedSprite.TriggerGroups.Single();
Assert.That(decodedTriggerGroup.TriggerName, Is.EqualTo("Passing"));
Assert.That(decodedTriggerGroup.TriggerStartTime, Is.EqualTo(0));
Assert.That(decodedTriggerGroup.TriggerEndTime, Is.EqualTo(100000));
Assert.That(decodedTriggerGroup.GroupNumber, Is.EqualTo(33));
var alphaCommand = decodedTriggerGroup.Alpha.Single();
Assert.That(alphaCommand.Easing, Is.EqualTo(Easing.OutQuint));
Assert.That(alphaCommand.StartTime, Is.EqualTo(0));
Assert.That(alphaCommand.EndTime, Is.EqualTo(500));
Assert.That(alphaCommand.StartValue, Is.EqualTo(0));
Assert.That(alphaCommand.EndValue, Is.EqualTo(1));
});
}
[Test]
public void TestStoryboardSamples()
{
var initial = createComponents();
initial.Storyboard.GetLayer("Pass").Add(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, "pass.wav", 4000, 85));
initial.Storyboard.GetLayer("Fail").Add(new StoryboardSampleInfo(StoryboardElementSource.Shared, "fail.wav", 4000, 100));
var encoded = encode(initial);
var decodedAfterEncode = decode(encoded);
Assert.Multiple(() =>
{
var passingSample = (StoryboardSampleInfo)decodedAfterEncode.Storyboard.GetLayer("Pass").Elements.Single();
Assert.That(passingSample.Source, Is.EqualTo(StoryboardElementSource.Beatmap));
Assert.That(passingSample.Path, Is.EqualTo("pass.wav"));
Assert.That(passingSample.StartTime, Is.EqualTo(4000));
Assert.That(passingSample.Volume, Is.EqualTo(85));
var failingSample = (StoryboardSampleInfo)decodedAfterEncode.Storyboard.GetLayer("Fail").Elements.Single();
Assert.That(failingSample.Source, Is.EqualTo(StoryboardElementSource.Shared));
Assert.That(failingSample.Path, Is.EqualTo("fail.wav"));
Assert.That(failingSample.StartTime, Is.EqualTo(4000));
Assert.That(failingSample.Volume, Is.EqualTo(100));
});
}
private record DecodedBeatmapComponents(IBeatmap Beatmap, Storyboard Storyboard);
private record EncodedBeatmapComponents(MemoryStream Beatmap, MemoryStream Storyboard);
private DecodedBeatmapComponents createComponents()
{
var beatmapInfo = new BeatmapInfo();
var beatmap = new Beatmap
{
BeatmapInfo = beatmapInfo
};
var storyboard = new Storyboard
{
Beatmap = beatmap,
BeatmapInfo = beatmapInfo
};
return new DecodedBeatmapComponents(beatmap, storyboard);
}
private EncodedBeatmapComponents encode(DecodedBeatmapComponents decoded)
{
var beatmapStream = new MemoryStream();
using (var beatmapWriter = new StreamWriter(beatmapStream, Encoding.UTF8, 1024, leaveOpen: true))
new LegacyBeatmapEncoder(decoded.Beatmap, null, decoded.Storyboard).Encode(beatmapWriter);
beatmapStream.Position = 0;
var storyboardStream = new MemoryStream();
using (var storyboardWriter = new StreamWriter(storyboardStream, Encoding.UTF8, 1024, leaveOpen: true))
new LegacyStoryboardEncoder(decoded.Storyboard).EncodeStandaloneStoryboard(storyboardWriter);
storyboardStream.Position = 0;
return new EncodedBeatmapComponents(beatmapStream, storyboardStream);
}
private DecodedBeatmapComponents decode(EncodedBeatmapComponents encoded)
{
using var beatmapReader = new LineBufferedReader(encoded.Beatmap, leaveOpen: true);
var beatmap = new LegacyBeatmapDecoder().Decode(beatmapReader);
encoded.Beatmap.Position = 0;
using var storyboardReader = new LineBufferedReader(encoded.Storyboard, leaveOpen: true);
var storyboard = new LegacyStoryboardDecoder().Decode(beatmapReader, storyboardReader);
encoded.Beatmap.Position = 0;
encoded.Storyboard.Position = 0;
return new DecodedBeatmapComponents(beatmap, storyboard);
}
}
}
@@ -122,6 +122,29 @@ namespace osu.Game.Tests.Beatmaps.IO
() => Is.EqualTo(384).Within(0.00001));
}
[Test]
public void TestBackgroundSpecificationPreserved()
{
IWorkingBeatmap beatmap = null!;
MemoryStream outStream = null!;
// Ensure importer encoding is correct
AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"241526 Soleily - Renatus.osz"));
AddAssert("beatmap background is correct", () => beatmap.BeatmapInfo.Metadata.BackgroundFile, () => Is.EqualTo("machinetop_background.jpg"));
// Ensure exporter legacy conversion is correct
AddStep("export", () =>
{
outStream = new MemoryStream();
new LegacyBeatmapExporter(LocalStorage)
.ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
});
AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream));
AddAssert("beatmap background is still correct", () => beatmap.BeatmapInfo.Metadata.BackgroundFile, () => Is.EqualTo("machinetop_background.jpg"));
}
[Test]
public void TestExportStability()
{
@@ -87,7 +87,7 @@ namespace osu.Game.Tests.Editing.Checks
{
var storyboard = new Storyboard();
var layer = storyboard.GetLayer("Video");
layer.Add(new StoryboardVideo("abc123.mp4", 0));
layer.Add(new StoryboardVideo(StoryboardElementSource.Beatmap, "abc123.mp4", 0));
var mockWorkingBeatmap = new Mock<TestWorkingBeatmap>(beatmap, null!, null!);
mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny<string>())).Returns(resourceStream);
@@ -260,7 +260,7 @@ namespace osu.Game.Tests.Editing.Checks
if (hasStoryboard)
{
storyboard = new Storyboard();
storyboard.GetLayer("Background").Add(new StoryboardSprite("test.png", Anchor.Centre, Vector2.Zero));
storyboard.GetLayer("Background").Add(new StoryboardSprite(StoryboardElementSource.Beatmap, "test.png", Anchor.Centre, Vector2.Zero));
}
var verifiedCurrentBeatmap = new BeatmapVerifierContext.VerifiedBeatmap(new TestWorkingBeatmap(currentBeatmap, storyboard), currentBeatmap);
@@ -80,7 +80,7 @@ namespace osu.Game.Tests.Editing.Checks
{
var storyboard = new Storyboard();
var video = new StoryboardVideo("abc123.mp4", 0);
var video = new StoryboardVideo(StoryboardElementSource.Beatmap, "abc123.mp4", 0);
storyboard.GetLayer("Video").Add(video);
@@ -98,7 +98,7 @@ namespace osu.Game.Tests.Editing.Checks
{
var storyboard = new Storyboard();
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "unknown", Anchor.TopLeft, Vector2.Zero);
storyboard.GetLayer("Background").Add(sprite);
@@ -77,7 +77,7 @@ namespace osu.Game.Tests.Editing.Checks
{
var storyboard = new Storyboard();
var layer = storyboard.GetLayer("Video");
layer.Add(new StoryboardVideo("abc123.mp4", 0));
layer.Add(new StoryboardVideo(StoryboardElementSource.Beatmap, "abc123.mp4", 0));
var mockWorkingBeatmap = new Mock<TestWorkingBeatmap>(beatmap, null!, null!);
mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny<string>())).Returns(resourceStream);
@@ -143,7 +143,7 @@ namespace osu.Game.Tests.Editing.Checks
};
var storyboard = new Storyboard();
storyboard.GetLayer("Video").Add(new StoryboardVideo(path, startTime));
storyboard.GetLayer("Video").Add(new StoryboardVideo(StoryboardElementSource.Beatmap, path, startTime));
var working = new TestWorkingBeatmap(beatmap, storyboard);
return new BeatmapVerifierContext.VerifiedBeatmap(working, beatmap);
@@ -362,7 +362,7 @@ namespace osu.Game.Tests.Editing
using (var encoded = new MemoryStream())
{
using (var sw = new StreamWriter(encoded))
new LegacyBeatmapEncoder(beatmap, null).Encode(sw);
new LegacyBeatmapEncoder(beatmap, null, null).Encode(sw);
return encoded.ToArray();
}
@@ -81,7 +81,7 @@ namespace osu.Game.Tests.Gameplay
AccuracyJudgementCount = 1,
ComboPortion = 0,
BonusPortion = 0
}, DateTimeOffset.Now)
}, DateTimeOffset.Now, [], 0, [])
});
Assert.That(scoreProcessor.TotalScore.Value, Is.Zero);
@@ -99,7 +99,7 @@ namespace osu.Game.Tests.Gameplay
AccuracyJudgementCount = 0,
ComboPortion = 0,
BonusPortion = 0
}, DateTimeOffset.Now)
}, DateTimeOffset.Now, [], 0, [])
});
Assert.That(scoreProcessor.TotalScore.Value, Is.Zero);
@@ -79,7 +79,7 @@ namespace osu.Game.Tests.Gameplay
{
Child = new FrameStabilityContainer
{
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1))
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, string.Empty, 0, 1))
}
});
});
@@ -109,7 +109,7 @@ namespace osu.Game.Tests.Gameplay
{
Child = new FrameStabilityContainer
{
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1))
Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, string.Empty, 0, 1))
}
});
@@ -141,7 +141,7 @@ namespace osu.Game.Tests.Gameplay
Child = beatmapSkinSourceContainer
});
beatmapSkinSourceContainer.Add(sample = new DrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1))
beatmapSkinSourceContainer.Add(sample = new DrawableStoryboardSample(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, "test-sample", 1, 1))
{
Clock = gameplayContainer
});
@@ -126,7 +126,7 @@ namespace osu.Game.Tests.NonVisual
Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation));
osu.Migrate(customPath);
osu.MigrateUserData(customPath);
Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath));
@@ -183,16 +183,16 @@ namespace osu.Game.Tests.NonVisual
{
var osu = LoadOsuIntoHost(host);
Assert.DoesNotThrow(() => osu.Migrate(customPath));
Assert.DoesNotThrow(() => osu.MigrateUserData(customPath));
Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME)));
Assert.DoesNotThrow(() => osu.Migrate(customPath2));
Assert.DoesNotThrow(() => osu.MigrateUserData(customPath2));
Assert.That(File.Exists(Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME)));
// some files may have been left behind for whatever reason, but that's not what we're testing here.
cleanupPath(customPath);
Assert.DoesNotThrow(() => osu.Migrate(customPath));
Assert.DoesNotThrow(() => osu.MigrateUserData(customPath));
Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME)));
}
finally
@@ -212,8 +212,8 @@ namespace osu.Game.Tests.NonVisual
{
var osu = LoadOsuIntoHost(host);
Assert.DoesNotThrow(() => osu.Migrate(customPath));
Assert.Throws<ArgumentException>(() => osu.Migrate(customPath));
Assert.DoesNotThrow(() => osu.MigrateUserData(customPath));
Assert.Throws<ArgumentException>(() => osu.MigrateUserData(customPath));
}
finally
{
@@ -238,14 +238,14 @@ namespace osu.Game.Tests.NonVisual
string originalDirectory = storage.GetFullPath(".");
Assert.DoesNotThrow(() => osu.Migrate(customPath));
Assert.DoesNotThrow(() => osu.MigrateUserData(customPath));
Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME)));
Directory.CreateDirectory(customPath2);
File.WriteAllText(Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME), "I am a text");
// Fails because file already exists.
Assert.Throws<ArgumentException>(() => osu.Migrate(customPath2));
Assert.Throws<ArgumentException>(() => osu.MigrateUserData(customPath2));
osuStorage?.ChangeDataPath(customPath2);
@@ -269,7 +269,7 @@ namespace osu.Game.Tests.NonVisual
{
var osu = LoadOsuIntoHost(host);
Assert.DoesNotThrow(() => osu.Migrate(customPath));
Assert.DoesNotThrow(() => osu.MigrateUserData(customPath));
string subFolder = Path.Combine(customPath, "sub");
@@ -278,7 +278,7 @@ namespace osu.Game.Tests.NonVisual
Directory.CreateDirectory(subFolder);
Assert.Throws<ArgumentException>(() => osu.Migrate(subFolder));
Assert.Throws<ArgumentException>(() => osu.MigrateUserData(subFolder));
}
finally
{
@@ -297,7 +297,7 @@ namespace osu.Game.Tests.NonVisual
{
var osu = LoadOsuIntoHost(host);
Assert.DoesNotThrow(() => osu.Migrate(customPath));
Assert.DoesNotThrow(() => osu.MigrateUserData(customPath));
string seeminglySubFolder = customPath + "sub";
@@ -306,7 +306,7 @@ namespace osu.Game.Tests.NonVisual
Directory.CreateDirectory(seeminglySubFolder);
osu.Migrate(seeminglySubFolder);
osu.MigrateUserData(seeminglySubFolder);
}
finally
{
@@ -0,0 +1,84 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Tests.Rulesets.Scoring
{
public class ScoreMultiplierCalculatorTest
{
[Test]
public void TestFlatMultiplier()
{
var calculator = new TestScoreMultiplierCalculator(new ScoreMultiplierContext());
double multiplier = calculator.CalculateFor([new OsuModEasy()]);
Assert.That(multiplier, Is.EqualTo(0.15));
}
[Test]
public void TestSettingDependentMultiplier()
{
var calculator = new TestScoreMultiplierCalculator(new ScoreMultiplierContext());
double multiplier = calculator.CalculateFor([new OsuModDaycore { SpeedChange = { Value = 0.6 } }]);
Assert.That(multiplier, Is.EqualTo(0.4));
}
[Test]
public void TestContextDependentMultiplier()
{
TestScoreMultiplierCalculator calculator;
double multiplier;
Assert.Multiple(() =>
{
calculator = new TestScoreMultiplierCalculator(new ScoreMultiplierContext());
multiplier = calculator.CalculateFor([new OsuModHardRock()]);
Assert.That(multiplier, Is.EqualTo(1.4));
calculator = new TestScoreMultiplierCalculator(new ScoreMultiplierContext(new ScoreInfo { ClientVersion = "2024.123.0" }));
multiplier = calculator.CalculateFor([new OsuModHardRock()]);
Assert.That(multiplier, Is.EqualTo(1.2));
});
}
[Test]
public void TestCombinationMultiplier()
{
var calculator = new TestScoreMultiplierCalculator(new ScoreMultiplierContext());
double multiplier = calculator.CalculateFor([new OsuModEasy(), new OsuModDaycore()]);
Assert.That(multiplier, Is.EqualTo(0.003));
}
[Test]
public void TestCombinationAndFlatMultipliers()
{
var calculator = new TestScoreMultiplierCalculator(new ScoreMultiplierContext());
double multiplier = calculator.CalculateFor([new OsuModDaycore(), new OsuModHardRock(), new OsuModEasy()]);
Assert.That(multiplier, Is.EqualTo(0.003 * 1.4));
}
private class TestScoreMultiplierCalculator : ScoreMultiplierCalculator
{
public TestScoreMultiplierCalculator(ScoreMultiplierContext context)
: base(context)
{
Single<OsuModEasy>(hasMultiplier: 0.15);
Single<OsuModDaycore>(hasMultiplier: daycore => (1 + daycore.SpeedChange.Value) / 4);
Single<OsuModHardRock>(hasMultiplier: _ => context.Score?.ClientVersion == "2024.123.0" ? 1.2 : 1.4);
Combination<OsuModEasy, OsuModDaycore>(hasMultiplier: (_, _) => 0.003);
}
}
}
}
@@ -355,6 +355,7 @@ namespace osu.Game.Tests.Visual.Background
public double StartTime => double.MinValue;
public double EndTime => double.MaxValue;
public double EndTimeForDisplay => double.MaxValue;
public StoryboardElementSource Source => StoryboardElementSource.Beatmap;
public Drawable CreateDrawable() => new DrawableTestStoryboardElement();
}
@@ -93,7 +93,7 @@ namespace osu.Game.Tests.Visual.Editing
});
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("select circle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddStep("select circle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "Hit circle").TriggerClick());
AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType<HitObjectContainer>().Single()));
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1);
@@ -128,10 +128,10 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("clear all control points", () => editorBeatmap.ControlPointInfo.Clear());
AddAssert("Tool is selection", () => hitObjectComposer.ChildrenOfType<ComposeBlueprintContainer>().First().CurrentTool is SelectTool);
AddAssert("Hitcircle button not clickable", () => !hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").Enabled.Value);
AddAssert("Hitcircle button not clickable", () => !hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "Hit circle").Enabled.Value);
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddAssert("Hitcircle button is clickable", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").Enabled.Value);
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddAssert("Hitcircle button is clickable", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "Hit circle").Enabled.Value);
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "Hit circle").TriggerClick());
AddAssert("Tool changed", () => hitObjectComposer.ChildrenOfType<ComposeBlueprintContainer>().First().CurrentTool is HitCircleCompositionTool);
}
@@ -146,7 +146,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "Hit circle").TriggerClick());
ExpandingToolboxContainer toolboxContainer = null!;
@@ -186,7 +186,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "Hit circle").TriggerClick());
AddStep("move mouse to scroll area", () =>
{
@@ -66,7 +66,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
var layer = storyboard.GetLayer("Background");
var sprite = new StoryboardSprite(lookup_name, Anchor.TopLeft, new Vector2(256, 192));
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, lookup_name, Anchor.TopLeft, new Vector2(256, 192));
sprite.Commands.AddAlpha(Easing.None, 0, 2000, 0, 2);
layer.Elements.Clear();
@@ -121,7 +121,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
var layer = storyboard.GetLayer("Video");
var sprite = new StoryboardVideo("Videos/test-video.mp4", Time.Current);
var sprite = new StoryboardVideo(StoryboardElementSource.Beatmap, "Videos/test-video.mp4", Time.Current);
if (scaleTransformProvided)
{
@@ -250,7 +250,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
var layer = storyboard.GetLayer("Background");
var sprite = new StoryboardSprite(lookupName, origin, initialPosition);
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, lookupName, origin, initialPosition);
var loop = sprite.AddLoopingGroup(Time.Current, 100);
loop.AddAlpha(Easing.None, 0, 10000, 1, 1);
@@ -47,7 +47,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
var storyboard = new Storyboard();
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "unknown", Anchor.TopLeft, Vector2.Zero);
sprite.Commands.AddAlpha(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1);
@@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.Gameplay
var storyboard = new Storyboard();
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "unknown", Anchor.TopLeft, Vector2.Zero);
// these should be ignored as we have an alpha visibility blocker proceeding this command.
sprite.Commands.AddScale(Easing.None, loop_start_time, -18000, 0, 1);
@@ -201,7 +201,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
var layer = storyboard.GetLayer("Background");
var sprite = new StoryboardSprite(lookup_name, Anchor.Centre, new Vector2(320, 240));
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, lookup_name, Anchor.Centre, new Vector2(320, 240));
sprite.Commands.AddScale(Easing.None, 0, clock_limit, 0.5f, 0.5f);
sprite.Commands.AddAlpha(Easing.None, 0, clock_limit, 1, 1);
addCommands?.Invoke(sprite);
@@ -38,10 +38,10 @@ namespace osu.Game.Tests.Visual.Gameplay
storyboard = new Storyboard();
var backgroundLayer = storyboard.GetLayer("Background");
backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: -7000, volume: 20));
backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: -5000, volume: 20));
backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: 0, volume: 20));
backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: 2000, volume: 20));
backgroundLayer.Add(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, "Intro/welcome.mp3", time: -7000, volume: 20));
backgroundLayer.Add(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, "Intro/welcome.mp3", time: -5000, volume: 20));
backgroundLayer.Add(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, "Intro/welcome.mp3", time: 0, volume: 20));
backgroundLayer.Add(new StoryboardSampleInfo(StoryboardElementSource.Beatmap, "Intro/welcome.mp3", time: 2000, volume: 20));
}
[SetUp]
@@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private Storyboard createStoryboard(double startTime)
{
var storyboard = new Storyboard();
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "unknown", Anchor.TopLeft, Vector2.Zero);
sprite.Commands.AddAlpha(Easing.None, startTime, 0, 0, 1);
storyboard.GetLayer("Background").Add(sprite);
return storyboard;
@@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private Storyboard createStoryboard(double duration)
{
var storyboard = new Storyboard();
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "unknown", Anchor.TopLeft, Vector2.Zero);
sprite.Commands.AddAlpha(Easing.None, 0, duration, 1, 0);
storyboard.GetLayer("Background").Add(sprite);
return storyboard;
@@ -221,7 +221,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[HitResult.Miss] = 0,
[HitResult.Meh] = 0,
[HitResult.Great] = 0
}, new ScoreProcessorStatistics(), DateTimeOffset.Now);
}, new ScoreProcessorStatistics(), DateTimeOffset.Now, [], 0, []);
}
switch (RNG.Next(0, 3))
@@ -178,7 +178,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
LoadComponent(Overlay = new FreeModSelectOverlay
{
SelectedMods = { BindTarget = FreeMods }
SelectedMods = { BindTarget = FreeMods },
Ruleset = { BindTarget = Ruleset }
});
}
@@ -493,7 +493,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestIntroStoryboardElement() => testLeadIn(b =>
{
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
var sprite = new StoryboardSprite(StoryboardElementSource.Beatmap, "unknown", Anchor.TopLeft, Vector2.Zero);
sprite.Commands.AddAlpha(Easing.None, -2000, 0, 0, 1);
b.Storyboard.GetLayer("Background").Add(sprite);
});
@@ -1206,6 +1206,27 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("style selection screen closed", () => this.ChildrenOfType<MultiplayerMatchFreestyleSelect>().SingleOrDefault()?.IsCurrentScreen() != true);
}
[Test]
public void TestMaxParticipantsAndSlots()
{
createRoom(() => new Room
{
Name = "Test Room",
Password = "password",
Playlist =
[
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
],
MaxParticipants = 10
});
AddStep("turn max participants off", () => multiplayerClient.ChangeSettings(maxParticipants: null));
AddStep("turn max participants back on", () => multiplayerClient.ChangeSettings(maxParticipants: 8));
}
private void enterGameplay()
{
pressReadyButton();
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
@@ -55,6 +56,68 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
}
[Test]
public void TestSlots()
{
setUpList();
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
AddStep("add user", () => MultiplayerClient.AddUser(new APIUser
{
Id = 3,
Username = "Second",
CoverUrl = TestResources.COVER_IMAGE_3,
}));
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
AddStep("introduce slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
{
Slots = [null, 3, null, null, 1001, null, null]
}).WaitSafely());
AddStep("click first slot", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<ParticipantPanel>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots,
() => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null }));
AddStep("click second slot", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<ParticipantPanel>().ElementAt(1));
InputManager.Click(MouseButton.Left);
});
AddUntilStep("slots not changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots,
() => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null }));
AddStep("click last slot", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<ParticipantPanel>().Last());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots,
() => Is.EquivalentTo(new int?[] { null, 3, null, null, null, null, 1001 }));
AddStep("shuffle slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
{
Slots = [null, null, 1001, null, null, null, 3]
}).WaitSafely());
AddStep("remove slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
{
Slots = [null, 3, null, 1001]
}).WaitSafely());
AddStep("add slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
{
Slots = [null, null, 3, null, 1001, null]
}).WaitSafely());
AddStep("turn off slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState
{
Slots = null
}).WaitSafely());
}
[Test]
public void TestAddReferee()
{
@@ -86,7 +149,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
AddStep("kick null user", () => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User == null)
AddStep("kick null user", () => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.User == null)
.ChildrenOfType<ParticipantPanel.KickButton>().Single().TriggerClick());
AddUntilStep("null user kicked", () => MultiplayerClient.ClientRoom.AsNonNull().Users.Count == 1);
@@ -111,7 +174,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("remove host", () => MultiplayerClient.RemoveUser(API.LocalUser.Value));
AddAssert("single panel is for second user", () => this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.UserID == secondUser?.Id);
AddAssert("single panel is for second user", () => this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.User?.UserID == secondUser?.Id);
}
[Test]
@@ -150,7 +213,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddRepeatStep("increment progress", () =>
{
float progress = this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.BeatmapAvailability.DownloadProgress ?? 0;
float progress = this.ChildrenOfType<ParticipantPanel>().Single().Current.Value.User?.BeatmapAvailability.DownloadProgress ?? 0;
MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(progress + RNG.NextSingle(0.1f)));
}, 25);
@@ -195,16 +258,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
}));
AddUntilStep("first user crown visible",
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
AddUntilStep("second user crown hidden",
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
AddStep("make second user host", () => MultiplayerClient.TransferHost(3));
AddUntilStep("first user crown visible",
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType<SpriteIcon>().First().Alpha == 0);
AddUntilStep("second user crown hidden",
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
() => this.ChildrenOfType<ParticipantPanel>().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType<SpriteIcon>().First().Alpha == 1);
}
[Test]
@@ -221,8 +284,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("make second user host", () => MultiplayerClient.TransferHost(3));
AddAssert("second user above first", () =>
{
var first = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.UserID == 1001);
var second = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.UserID == 3);
var first = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.User?.UserID == 1001);
var second = this.ChildrenOfType<ParticipantPanel>().Single(u => u.Current.Value.User?.UserID == 3);
return second.ScreenSpaceDrawQuad.TopLeft.Y < first.ScreenSpaceDrawQuad.TopLeft.Y;
});
}
@@ -178,6 +178,23 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, panel.ChildrenOfType<RoomPanel.CornerIcon>().First().Alpha));
}
[Test]
public void TestSetAndUnsetMaxParticipants()
{
RoomPanel panel = null!;
Room room = null!;
AddStep("create room", () => Child = panel = createLoungeRoom(room = new Room
{
Name = "A room",
Type = MatchType.HeadToHead,
}));
AddUntilStep("wait for panel load", () => panel.ChildrenOfType<DrawableRoomParticipantsList>().Any());
AddStep("set max participants", () => room.MaxParticipants = 5);
AddStep("unset max participants", () => room.MaxParticipants = null);
}
[Test]
public void TestMultiplayerRooms()
{
@@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.RankedPlay
AddStep("set discard phase", () => MultiplayerClient.RankedPlayChangeStage(RankedPlayStage.CardDiscard).WaitSafely());
AddWaitStep("wait", 3);
AddUntilStep("wait until cards are present", () => this.ChildrenOfType<PlayerHandOfCards.PlayerHandCard>().Count() == 5);
for (int i = 0; i < 3; i++)
{
@@ -11,8 +11,11 @@ using osu.Framework.Testing;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Select;
using osu.Game.Utils;
@@ -63,37 +66,45 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test]
public void TestIncrementMultiplier()
{
var ruleset = new OsuRuleset();
var hiddenMod = new Mod[] { new OsuModHidden() };
AddStep("Set ruleset", () => footerButtonMods.Ruleset.Value = ruleset.RulesetInfo);
AddStep(@"Add Hidden", () => changeMods(hiddenMod));
assertModsMultiplier(hiddenMod);
assertModsMultiplier(ruleset, hiddenMod);
var hardRockMod = new Mod[] { new OsuModHardRock() };
AddStep(@"Add HardRock", () => changeMods(hardRockMod));
assertModsMultiplier(hardRockMod);
assertModsMultiplier(ruleset, hardRockMod);
var doubleTimeMod = new Mod[] { new OsuModDoubleTime() };
AddStep(@"Add DoubleTime", () => changeMods(doubleTimeMod));
assertModsMultiplier(doubleTimeMod);
assertModsMultiplier(ruleset, doubleTimeMod);
var multipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() };
AddStep(@"Add multiple Mods", () => changeMods(multipleIncrementMods));
assertModsMultiplier(multipleIncrementMods);
assertModsMultiplier(ruleset, multipleIncrementMods);
}
[Test]
public void TestDecrementMultiplier()
{
var ruleset = new OsuRuleset();
var easyMod = new Mod[] { new OsuModEasy() };
AddStep("Set ruleset", () => footerButtonMods.Ruleset.Value = ruleset.RulesetInfo);
AddStep(@"Add Easy", () => changeMods(easyMod));
assertModsMultiplier(easyMod);
assertModsMultiplier(ruleset, easyMod);
var noFailMod = new Mod[] { new OsuModNoFail() };
AddStep(@"Add NoFail", () => changeMods(noFailMod));
assertModsMultiplier(noFailMod);
assertModsMultiplier(ruleset, noFailMod);
var multipleDecrementMods = new Mod[] { new OsuModEasy(), new OsuModNoFail() };
AddStep(@"Add Multiple Mods", () => changeMods(multipleDecrementMods));
assertModsMultiplier(multipleDecrementMods);
assertModsMultiplier(ruleset, multipleDecrementMods);
}
[Test]
@@ -105,11 +116,12 @@ namespace osu.Game.Tests.Visual.SongSelect
AddUntilStep("Unranked badge not shown", () => footerButtonMods.ChildrenOfType<FooterButtonMods.UnrankedBadge>().Single().Alpha == 0);
}
private void changeMods(IReadOnlyList<Mod> mods) => footerButtonMods.Current.Value = mods;
private void changeMods(IReadOnlyList<Mod> mods) => footerButtonMods.Mods.Value = mods;
private void assertModsMultiplier(IEnumerable<Mod> mods)
private void assertModsMultiplier(Ruleset ruleset, IEnumerable<Mod> mods)
{
double multiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
var scoreMultiplierCalculator = ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext());
double multiplier = scoreMultiplierCalculator.CalculateFor(mods);
string expectedValue = ModUtils.FormatScoreMultiplier(multiplier).ToString();
AddAssert($"Displayed multiplier is {expectedValue}", () => footerButtonMods.ChildrenOfType<OsuSpriteText>().First(t => t.Text.ToString().Contains('x')).Text.ToString(), () => Is.EqualTo(expectedValue));
@@ -7,6 +7,7 @@ using System;
using System.Threading;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
@@ -16,10 +17,12 @@ using osu.Game.Overlays.Dialog;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public partial class TestSceneDialogOverlay : OsuTestScene
public partial class TestSceneDialogOverlay : OsuTestScene, IOverlayManager
{
private DialogOverlay overlay;
private readonly Bindable<OverlayActivation> overlayActivationMode = new Bindable<OverlayActivation>(OverlayActivation.All);
[SetUpSteps]
public void SetUpSteps()
{
@@ -99,7 +102,8 @@ namespace osu.Game.Tests.Visual.UserInterface
{
Icon = FontAwesome.Regular.TrashAlt,
HeaderText = @"Confirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion of",
BodyText = @"Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver. ",
BodyText =
@"Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver. ",
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
@@ -116,6 +120,36 @@ namespace osu.Game.Tests.Visual.UserInterface
}));
}
[Test]
public void TestPushWhileOverlayActivationDisabled()
{
PopupDialog dialog = null;
AddStep("set activation mode disabled", () => overlayActivationMode.Value = OverlayActivation.Disabled);
AddStep("push dialog", () =>
{
overlay.Push(dialog = new TestPopupDialog
{
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton { Text = @"OK" },
},
});
});
AddUntilStep("overlay not visible", () => overlay.State.Value, () => Is.EqualTo(Visibility.Hidden));
AddStep("set activation mode enabled", () => overlayActivationMode.Value = OverlayActivation.All);
AddUntilStep("overlay visible", () => overlay.State.Value, () => Is.EqualTo(Visibility.Visible));
AddUntilStep("dialog displayed", () => dialog.State.Value, () => Is.EqualTo(Visibility.Visible));
AddStep("set activation mode disabled", () => overlayActivationMode.Value = OverlayActivation.Disabled);
AddUntilStep("dialog hidden", () => dialog.State.Value, () => Is.EqualTo(Visibility.Hidden));
AddAssert("dialog dismissed", () => overlay.CurrentDialog, () => Is.Null);
}
[Test]
public void TestPushBeforeLoad()
{
@@ -194,5 +228,17 @@ namespace osu.Game.Tests.Visual.UserInterface
private partial class TestPopupDialog : PopupDialog
{
}
public IBindable<OverlayActivation> OverlayActivationMode => overlayActivationMode;
public IDisposable RegisterBlockingOverlay(OverlayContainer overlayContainer) => throw new NotImplementedException();
public void ShowBlockingOverlay(OverlayContainer overlay)
{
}
public void HideBlockingOverlay(OverlayContainer overlay)
{
}
}
}
@@ -188,6 +188,12 @@ namespace osu.Game.Tests.Visual.UserInterface
Caption = "File selector",
PlaceholderText = "Select a file",
},
new FormFileSelector
{
Caption = "File selector with deselection",
PlaceholderText = "Select a file",
AllowClear = true,
},
new FormBeatmapFileSelector(true)
{
Caption = "File selector with intermediate choice dialog",
@@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Overlays;
namespace osu.Game.Tests.Visual.UserInterface
{
public partial class TestSceneMigrateAudioDialog : OsuManualInputManagerTestScene
{
private DialogOverlay overlay = null!;
[SetUpSteps]
public void SetUpSteps()
{
AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay());
}
[Test]
public void TestWasUsing()
{
AddStep("create dialog", () =>
{
overlay.Push(new MigrateNewAudioDialog(true));
});
}
[Test]
public void TestNotUsing()
{
AddStep("create dialog", () =>
{
overlay.Push(new MigrateNewAudioDialog(false));
});
}
}
}
@@ -25,6 +25,8 @@ using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Screens;
using osu.Game.Screens.Footer;
@@ -122,7 +124,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AddUntilStep("two panels active", () => modSelectOverlay.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
AddAssert("mod multiplier correct", () =>
{
double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier);
double multiplier = new OsuScoreMultiplierCalculator(new ScoreMultiplierContext()).CalculateFor(SelectedMods.Value);
return Precision.AlmostEquals(multiplier, this.ChildrenOfType<RankingInformationDisplay>().Single().ModMultiplier.Value);
});
assertCustomisationToggleState(disabled: false, active: false);
@@ -137,7 +139,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AddUntilStep("two panels active", () => modSelectOverlay.ChildrenOfType<ModPanel>().Count(panel => panel.Active.Value) == 2);
AddAssert("mod multiplier correct", () =>
{
double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier);
double multiplier = new OsuScoreMultiplierCalculator(new ScoreMultiplierContext()).CalculateFor(SelectedMods.Value);
return Precision.AlmostEquals(multiplier, this.ChildrenOfType<RankingInformationDisplay>().Single().ModMultiplier.Value);
});
assertCustomisationToggleState(disabled: false, active: false);
@@ -1087,6 +1089,7 @@ namespace osu.Game.Tests.Visual.UserInterface
State = { Value = Visibility.Visible },
Beatmap = { Value = Beatmap.Value },
SelectedMods = { BindTarget = SelectedMods },
Ruleset = { BindTarget = Ruleset },
ShowPresets = true,
});
}
-2
View File
@@ -65,8 +65,6 @@ namespace osu.Game.Beatmaps
public SortedList<BreakPeriod> Breaks { get; set; } = new SortedList<BreakPeriod>(Comparer<BreakPeriod>.Default);
public List<string> UnhandledEventLines { get; set; } = new List<string>();
[JsonIgnore]
public double TotalBreakTime => Breaks.Sum(b => b.Duration);
-1
View File
@@ -72,7 +72,6 @@ namespace osu.Game.Beatmaps
beatmap.ControlPointInfo = original.ControlPointInfo;
beatmap.HitObjects = convertHitObjects(original.HitObjects, original, cancellationToken).OrderBy(s => s.StartTime).ToList();
beatmap.Breaks = original.Breaks;
beatmap.UnhandledEventLines = original.UnhandledEventLines;
beatmap.AudioLeadIn = original.AudioLeadIn;
beatmap.StackLeniency = original.StackLeniency;
beatmap.SpecialStyle = original.SpecialStyle;
+7 -5
View File
@@ -27,6 +27,7 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Skinning;
using osu.Game.Storyboards;
using osu.Game.Utils;
using Realms;
@@ -216,7 +217,7 @@ namespace osu.Game.Beatmaps
targetBeatmapSet.Beatmaps.Add(newBeatmap.BeatmapInfo);
newBeatmap.BeatmapInfo.BeatmapSet = targetBeatmapSet;
save(newBeatmap.BeatmapInfo, newBeatmap, beatmapSkin, transferCollections: false);
save(newBeatmap.BeatmapInfo, newBeatmap, beatmapSkin, new Storyboard(), transferCollections: false);
workingBeatmapCache.Invalidate(targetBeatmapSet);
return GetWorkingBeatmap(newBeatmap.BeatmapInfo);
@@ -359,8 +360,9 @@ namespace osu.Game.Beatmaps
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to save the content against. The file referenced by <see cref="BeatmapInfo.Path"/> will be replaced.</param>
/// <param name="beatmapContent">The <see cref="IBeatmap"/> content to write.</param>
/// <param name="beatmapSkin">The beatmap <see cref="ISkin"/> content to write, null if to be omitted.</param>
public virtual void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin = null) =>
save(beatmapInfo, beatmapContent, beatmapSkin, transferCollections: true);
/// <param name="storyboard">The storyboard content to write, null if to be omitted.</param>
public virtual void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin = null, Storyboard? storyboard = null) =>
save(beatmapInfo, beatmapContent, beatmapSkin, storyboard, transferCollections: true);
public void DeleteAllVideos()
{
@@ -502,7 +504,7 @@ namespace osu.Game.Beatmaps
setInfo.Status = BeatmapOnlineStatus.LocallyModified;
}
private void save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin, bool transferCollections)
private void save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin, Storyboard? storyboard, bool transferCollections)
{
var setInfo = beatmapInfo.BeatmapSet;
Debug.Assert(setInfo != null);
@@ -526,7 +528,7 @@ namespace osu.Game.Beatmaps
{
using var stream = new MemoryStream();
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw);
new LegacyBeatmapEncoder(beatmapContent, beatmapSkin, storyboard).Encode(sw);
stream.Seek(0, SeekOrigin.Begin);
@@ -244,10 +244,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards
});
if (BeatmapSet.HasVideo)
leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) });
leftIconArea.Add(new VideoIconPill());
if (BeatmapSet.HasStoryboard)
leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) });
leftIconArea.Add(new StoryboardIconPill());
if (BeatmapSet.FeaturedInSpotlight)
{
@@ -226,10 +226,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards
});
if (BeatmapSet.HasVideo)
leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) });
leftIconArea.Add(new VideoIconPill());
if (BeatmapSet.HasStoryboard)
leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) });
leftIconArea.Add(new StoryboardIconPill());
if (BeatmapSet.FeaturedInSpotlight)
{
+2 -2
View File
@@ -19,11 +19,11 @@ namespace osu.Game.Beatmaps.Formats
{
var output = CreateTemplateObject();
foreach (LineBufferedReader stream in otherStreams.Prepend(primaryStream))
ParseStreamInto(stream, output);
ParseStreamInto(stream, stream == primaryStream, output);
return output;
}
protected abstract void ParseStreamInto(LineBufferedReader stream, TOutput output);
protected abstract void ParseStreamInto(LineBufferedReader stream, bool isPrimaryStream, TOutput output);
}
public abstract class Decoder
@@ -13,7 +13,7 @@ namespace osu.Game.Beatmaps.Formats
AddDecoder<Beatmap>("{", _ => new JsonBeatmapDecoder());
}
protected override void ParseStreamInto(LineBufferedReader stream, Beatmap output)
protected override void ParseStreamInto(LineBufferedReader stream, bool isPrimaryStream, Beatmap output)
{
stream.ReadToEnd().DeserializeInto(output);
@@ -81,7 +81,7 @@ namespace osu.Game.Beatmaps.Formats
return templateBeatmap;
}
protected override void ParseStreamInto(LineBufferedReader stream, Beatmap beatmap)
protected override void ParseStreamInto(LineBufferedReader stream, bool isPrimaryStream, Beatmap beatmap)
{
this.beatmap = beatmap;
this.beatmap.BeatmapVersion = FormatVersion;
@@ -89,7 +89,7 @@ namespace osu.Game.Beatmaps.Formats
ApplyLegacyDefaults(this.beatmap);
base.ParseStreamInto(stream, beatmap);
base.ParseStreamInto(stream, isPrimaryStream, beatmap);
applyDifficultyRestrictions(beatmap.Difficulty, beatmap);
@@ -204,7 +204,7 @@ namespace osu.Game.Beatmaps.Formats
beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(0) ?? beatmap.BeatmapInfo.Ruleset;
}
protected override void ParseLine(Beatmap beatmap, Section section, string line)
protected override void ParseLine(Beatmap beatmap, Section section, string line, bool isPrimaryStream)
{
switch (section)
{
@@ -237,7 +237,7 @@ namespace osu.Game.Beatmaps.Formats
return;
}
base.ParseLine(beatmap, section, line);
base.ParseLine(beatmap, section, line, isPrimaryStream);
}
private void handleGeneral(string line)
@@ -430,10 +430,6 @@ namespace osu.Game.Beatmaps.Formats
{
string[] split = line.Split(',');
// Until we have full storyboard encoder coverage, let's track any lines which aren't handled
// and store them to a temporary location such that they aren't lost on editor save / export.
bool lineSupportedByEncoder = false;
if (Enum.TryParse(split[0], out LegacyEventType type))
{
switch (type)
@@ -445,7 +441,6 @@ namespace osu.Game.Beatmaps.Formats
if (string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile))
{
beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[3]);
lineSupportedByEncoder = true;
}
break;
@@ -459,14 +454,12 @@ namespace osu.Game.Beatmaps.Formats
if (!SupportedExtensions.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant()))
{
beatmap.BeatmapInfo.Metadata.BackgroundFile = filename;
lineSupportedByEncoder = true;
}
break;
case LegacyEventType.Background:
beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]);
lineSupportedByEncoder = true;
break;
case LegacyEventType.Break:
@@ -474,13 +467,9 @@ namespace osu.Game.Beatmaps.Formats
double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2])));
beatmap.Breaks.Add(new BreakPeriod(start, end));
lineSupportedByEncoder = true;
break;
}
}
if (!lineSupportedByEncoder)
beatmap.UnhandledEventLines.Add(line);
}
private void handleTimingPoint(string line)
@@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning;
using osu.Game.Storyboards;
using osuTK;
using osuTK.Graphics;
@@ -24,8 +25,8 @@ namespace osu.Game.Beatmaps.Formats
public const int FIRST_LAZER_VERSION = 128;
private readonly IBeatmap beatmap;
private readonly ISkin? skin;
private readonly LegacyStoryboardEncoder? storyboardEncoder;
private readonly int onlineRulesetID;
@@ -34,11 +35,18 @@ namespace osu.Game.Beatmaps.Formats
/// </summary>
/// <param name="beatmap">The beatmap to encode.</param>
/// <param name="skin">The beatmap's skin, used for encoding combo colours.</param>
public LegacyBeatmapEncoder(IBeatmap beatmap, ISkin? skin)
/// <param name="storyboard">
/// The combined storyboard, loaded from both the <c>.osu</c> and the <c>.osz</c>.
/// Only elements from the <c>.osu</c> (marked via <see cref="StoryboardElementSource.Beatmap"/>) will be encoded to the beatmap.
/// </param>
public LegacyBeatmapEncoder(IBeatmap beatmap, ISkin? skin, Storyboard? storyboard)
{
this.beatmap = beatmap;
this.skin = skin;
if (storyboard != null)
storyboardEncoder = new LegacyStoryboardEncoder(storyboard);
onlineRulesetID = beatmap.BeatmapInfo.Ruleset.OnlineID;
if (onlineRulesetID < 0 || onlineRulesetID > 3)
@@ -101,7 +109,12 @@ namespace osu.Game.Beatmaps.Formats
writer.WriteLine(FormattableString.Invariant($@"CountdownOffset: {beatmap.CountdownOffset}"));
if (onlineRulesetID == 3)
writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.SpecialStyle ? '1' : '0')}"));
writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.WidescreenStoryboard ? '1' : '0')}"));
if (storyboardEncoder != null)
storyboardEncoder.EncodeGeneralToBeatmap(writer);
else
writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.WidescreenStoryboard ? '1' : '0')}"));
if (beatmap.SamplesMatchPlaybackRate)
writer.WriteLine(@"SamplesMatchPlaybackRate: 1");
}
@@ -151,14 +164,19 @@ namespace osu.Game.Beatmaps.Formats
{
writer.WriteLine("[Events]");
if (!string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile))
writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Background},0,\"{beatmap.BeatmapInfo.Metadata.BackgroundFile}\",0,0"));
if (storyboardEncoder != null)
{
storyboardEncoder.EncodeEventsToBeatmap(writer);
}
else
{
if (!string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile))
writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Background},0,\"{beatmap.BeatmapInfo.Metadata.BackgroundFile}\",0,0"));
}
writer.WriteLine("// Break Periods");
foreach (var b in beatmap.Breaks)
writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Break},{b.StartTime},{b.EndTime}"));
foreach (string l in beatmap.UnhandledEventLines)
writer.WriteLine(l);
}
private void handleControlPoints(TextWriter writer)
+3 -3
View File
@@ -35,7 +35,7 @@ namespace osu.Game.Beatmaps.Formats
FormatVersion = version;
}
protected override void ParseStreamInto(LineBufferedReader stream, T output)
protected override void ParseStreamInto(LineBufferedReader stream, bool isPrimaryStream, T output)
{
Section section = Section.General;
@@ -65,7 +65,7 @@ namespace osu.Game.Beatmaps.Formats
try
{
ParseLine(output, section, line);
ParseLine(output, section, line, isPrimaryStream);
}
catch (Exception e)
{
@@ -84,7 +84,7 @@ namespace osu.Game.Beatmaps.Formats
{
}
protected virtual void ParseLine(T output, Section section, string line)
protected virtual void ParseLine(T output, Section section, string line, bool isPrimaryStream)
{
switch (section)
{
@@ -49,13 +49,13 @@ namespace osu.Game.Beatmaps.Formats
return sb;
}
protected override void ParseStreamInto(LineBufferedReader stream, Storyboard storyboard)
protected override void ParseStreamInto(LineBufferedReader stream, bool isPrimaryStream, Storyboard storyboard)
{
this.storyboard = storyboard;
base.ParseStreamInto(stream, storyboard);
base.ParseStreamInto(stream, isPrimaryStream, storyboard);
}
protected override void ParseLine(Storyboard storyboard, Section section, string line)
protected override void ParseLine(Storyboard storyboard, Section section, string line, bool isPrimaryStream)
{
switch (section)
{
@@ -64,7 +64,7 @@ namespace osu.Game.Beatmaps.Formats
return;
case Section.Events:
handleEvents(line);
handleEvents(line, isPrimaryStream);
return;
case Section.Variables:
@@ -72,7 +72,7 @@ namespace osu.Game.Beatmaps.Formats
return;
}
base.ParseLine(storyboard, section, line);
base.ParseLine(storyboard, section, line, isPrimaryStream);
}
private void handleGeneral(Storyboard storyboard, string line)
@@ -91,7 +91,7 @@ namespace osu.Game.Beatmaps.Formats
}
}
private void handleEvents(string line)
private void handleEvents(string line, bool isPrimaryStream)
{
decodeVariables(ref line);
@@ -116,8 +116,24 @@ namespace osu.Game.Beatmaps.Formats
if (!Enum.TryParse(split[0], out LegacyEventType type))
throw new InvalidDataException($@"Unknown event type: {split[0]}");
var source = isPrimaryStream ? StoryboardElementSource.Beatmap : StoryboardElementSource.Shared;
switch (type)
{
case LegacyEventType.Background:
{
// the actual filename is handled in `LegacyBeatmapDecoder`.
// this only handles the background offset, because it does not logically belong in `Beatmap` or related classes.
if (split.Length > 4)
{
float x = Parsing.ParseFloat(split[3]);
float y = Parsing.ParseFloat(split[4]);
storyboard.BackgroundOffset = new Vector2(x, y);
}
break;
}
case LegacyEventType.Video:
{
int offset = Parsing.ParseInt(split[1]);
@@ -131,7 +147,7 @@ namespace osu.Game.Beatmaps.Formats
if (!SupportedExtensions.VIDEO_EXTENSIONS.Contains(Path.GetExtension(path).ToLowerInvariant()))
break;
storyboard.GetLayer("Video").Add(storyboardSprite = new StoryboardVideo(path, offset));
storyboard.GetLayer("Video").Add(storyboardSprite = new StoryboardVideo(source, path, offset));
break;
}
@@ -142,7 +158,7 @@ namespace osu.Game.Beatmaps.Formats
string path = CleanFilename(split[3]);
float x = Parsing.ParseFloat(split[4], Parsing.MAX_COORDINATE_VALUE);
float y = Parsing.ParseFloat(split[5], Parsing.MAX_COORDINATE_VALUE);
storyboardSprite = new StoryboardSprite(path, origin, new Vector2(x, y));
storyboardSprite = new StoryboardSprite(source, path, origin, new Vector2(x, y));
storyboard.GetLayer(layer).Add(storyboardSprite);
break;
}
@@ -162,7 +178,7 @@ namespace osu.Game.Beatmaps.Formats
frameDelay = Math.Round(0.015 * frameDelay) * 1.186 * (1000 / 60f);
var loopType = split.Length > 8 ? parseAnimationLoopType(split[8]) : AnimationLoopType.LoopForever;
storyboardSprite = new StoryboardAnimation(path, origin, new Vector2(x, y), frameCount, frameDelay, loopType);
storyboardSprite = new StoryboardAnimation(source, path, origin, new Vector2(x, y), frameCount, frameDelay, loopType);
storyboard.GetLayer(layer).Add(storyboardSprite);
break;
}
@@ -173,7 +189,7 @@ namespace osu.Game.Beatmaps.Formats
string layer = parseLayer(split[2]);
string path = CleanFilename(split[3]);
float volume = split.Length > 4 ? Parsing.ParseFloat(split[4]) : 100;
storyboard.GetLayer(layer).Add(new StoryboardSampleInfo(path, time, (int)volume));
storyboard.GetLayer(layer).Add(new StoryboardSampleInfo(source, path, time, (int)volume));
break;
}
}
@@ -192,7 +208,8 @@ namespace osu.Game.Beatmaps.Formats
string triggerName = split[1];
double startTime = split.Length > 2 ? Parsing.ParseDouble(split[2]) : double.MinValue;
double endTime = split.Length > 3 ? Parsing.ParseDouble(split[3]) : double.MaxValue;
int groupNumber = split.Length > 4 ? Parsing.ParseInt(split[4]) : 0;
// negation as per https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L736
int groupNumber = split.Length > 4 ? -Parsing.ParseInt(split[4]) : 0;
currentCommandsGroup = storyboardSprite?.AddTriggerGroup(triggerName, startTime, endTime, groupNumber);
break;
}
@@ -0,0 +1,349 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Storyboards;
using osu.Game.Storyboards.Commands;
namespace osu.Game.Beatmaps.Formats
{
public class LegacyStoryboardEncoder
{
private readonly Storyboard storyboard;
public LegacyStoryboardEncoder(Storyboard storyboard)
{
this.storyboard = storyboard;
}
#region Storyboards embedded in beatmaps
public void EncodeGeneralToBeatmap(TextWriter writer)
{
writer.WriteLine(FormattableString.Invariant($@"UseSkinSprites: {(storyboard.UseSkinSprites ? '1' : '0')}"));
writer.WriteLine(FormattableString.Invariant($@"WidescreenStoryboard: {(storyboard.Beatmap.WidescreenStoryboard ? '1' : '0')}"));
}
public void EncodeEventsToBeatmap(TextWriter writer)
=> encodeEvents(writer, StoryboardElementSource.Beatmap);
#endregion
#region Standalone storyboards
public void EncodeStandaloneStoryboard(TextWriter writer)
{
writer.WriteLine(@"[Events]");
encodeEvents(writer, StoryboardElementSource.Shared);
}
#endregion
private void encodeEvents(TextWriter writer, StoryboardElementSource target)
{
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameModes/Edit/Modes/EditorModeDesign.cs#L189
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/Events/EventManager.cs#L368
writer.WriteLine(@"// Background and Video events");
if (target == StoryboardElementSource.Beatmap)
{
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1499
writer.WriteLine(string.Format(CultureInfo.InvariantCulture,
@"{0},{1},""{2}"",{3},{4}",
(int)LegacyEventType.Background, 0, storyboard.BeatmapInfo.Metadata.BackgroundFile, storyboard.BackgroundOffset.X, storyboard.BackgroundOffset.Y));
}
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1496
foreach (var video in storyboard.GetLayer(@"Video").Elements.OfType<StoryboardVideo>().Where(v => v.Source == target))
{
writer.WriteLine(string.Format(CultureInfo.InvariantCulture,
@"{0},{1},""{2}""",
nameof(LegacyEventType.Video), video.StartTime, video.Path));
encodeCommands(writer, video);
}
foreach (var legacyLayer in Enum.GetValues<LegacyStoryLayer>().Except(LegacyStoryLayer.Video.Yield()))
{
writer.WriteLine(string.Format(CultureInfo.InvariantCulture,
@"// Storyboard Layer {0} ({1})",
(int)legacyLayer,
legacyLayer));
string layerName = legacyLayer.ToString();
var layer = storyboard.GetLayer(layerName);
encodeSpritesFromLayer(writer, layer, target);
}
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1478-L1481
writer.WriteLine(@"// Storyboard Sound Samples");
foreach (var legacyLayer in Enum.GetValues<LegacyStoryLayer>().Except(LegacyStoryLayer.Video.Yield()))
{
string layerName = legacyLayer.ToString();
var layer = storyboard.GetLayer(layerName);
foreach (var sample in layer.Elements.OfType<StoryboardSampleInfo>().Where(s => s.Source == target))
{
writer.WriteLine(string.Format(CultureInfo.InvariantCulture,
@"{0},{1},{2},""{3}"",{4}",
nameof(LegacyEventType.Sample),
sample.StartTime,
(int)legacyLayer,
sample.Path,
sample.Volume));
}
}
}
private void encodeSpritesFromLayer(TextWriter writer, StoryboardLayer layer, StoryboardElementSource target)
{
foreach (var element in layer.Elements.Where(elem => elem.Source == target))
{
LegacyOrigins origin;
switch (element)
{
case StoryboardAnimation animation:
{
origin = convertOrigin(animation.Origin);
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1505-L1507
writer.WriteLine(string.Format(
CultureInfo.InvariantCulture,
@"{0},{1},{2},""{3}"",{4},{5},{6},{7},{8}",
nameof(LegacyEventType.Animation),
layer.Name,
origin,
animation.Path,
animation.InitialPosition.X,
animation.InitialPosition.Y,
animation.FrameCount,
animation.FrameDelay,
animation.LoopType));
encodeCommands(writer, animation);
break;
}
case StoryboardSprite sprite:
{
origin = convertOrigin(sprite.Origin);
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1502
writer.WriteLine(string.Format(
CultureInfo.InvariantCulture,
@"{0},{1},{2},""{3}"",{4},{5}",
nameof(LegacyEventType.Sprite),
layer.Name,
origin,
sprite.Path,
sprite.InitialPosition.X,
sprite.InitialPosition.Y));
encodeCommands(writer, sprite);
break;
}
}
}
}
private void encodeCommands(TextWriter writer, StoryboardSprite sprite)
{
foreach (var loopingGroup in sprite.LoopingGroups)
{
writer.WriteLine(string.Format(
CultureInfo.InvariantCulture,
@" L,{0},{1}",
loopingGroup.StartTime, loopingGroup.TotalIterations));
foreach (var command in loopingGroup.AllCommands)
// see `StoryboardLoopingCommand` ctor for why `relativeToTime` is passed
encodeCommand(writer, command, 2, relativeToTime: loopingGroup.StartTime);
}
foreach (var command in sprite.Commands.AllCommands)
encodeCommand(writer, command, 1);
foreach (var triggerGroup in sprite.TriggerGroups)
{
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1564-L1572
writer.Write(string.Format(
CultureInfo.InvariantCulture,
@" T,{0}",
triggerGroup.TriggerName));
if (triggerGroup.TriggerEndTime != 0)
{
writer.Write(string.Format(
CultureInfo.InvariantCulture,
@",{0},{1}",
triggerGroup.TriggerStartTime, triggerGroup.TriggerEndTime));
}
if (triggerGroup.GroupNumber != 0)
{
writer.Write(string.Format(CultureInfo.InvariantCulture, @",{0}", -triggerGroup.GroupNumber));
}
writer.WriteLine();
foreach (var command in triggerGroup.AllCommands)
encodeCommand(writer, command, 2);
}
}
private void encodeCommand(TextWriter writer, IStoryboardCommand command, int depth, double relativeToTime = 0)
{
for (int i = 0; i < depth; ++i)
writer.Write(' ');
string typeAcronym;
string details;
if (command is IStoryboardLoopingCommand loopingCommand)
command = loopingCommand.OriginalCommand;
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1546-L1550
// https://github.com/peppy/osu-stable-reference/blob/c34a74fb61c17c5667486a12548485d1f03baa2e/osu!/GameplayElements/HitObjectManager_LoadSave.cs#L1690-L1730
switch (command)
{
case StoryboardVectorScaleCommand vectorScale:
typeAcronym = @"V";
details = vectorScale.StartValue == vectorScale.EndValue
? string.Format(CultureInfo.InvariantCulture, @"{0},{1}", vectorScale.StartValue.X, vectorScale.StartValue.Y)
: string.Format(CultureInfo.InvariantCulture,
@"{0},{1},{2},{3}",
vectorScale.StartValue.X, vectorScale.StartValue.Y, vectorScale.EndValue.X, vectorScale.EndValue.Y);
break;
case StoryboardAlphaCommand fade:
typeAcronym = @"F";
details = fade.StartValue == fade.EndValue
? fade.StartValue.ToString(CultureInfo.InvariantCulture)
: string.Format(CultureInfo.InvariantCulture,
@"{0},{1}",
fade.StartValue,
fade.EndValue);
break;
case StoryboardRotationCommand rotation:
typeAcronym = @"R";
details = rotation.StartValue == rotation.EndValue
? rotation.StartValue.ToString(CultureInfo.InvariantCulture)
: string.Format(CultureInfo.InvariantCulture,
@"{0},{1}",
float.DegreesToRadians(rotation.StartValue),
float.DegreesToRadians(rotation.EndValue));
break;
case StoryboardScaleCommand scale:
typeAcronym = @"S";
details = scale.StartValue == scale.EndValue
? scale.StartValue.ToString(CultureInfo.InvariantCulture)
: string.Format(CultureInfo.InvariantCulture,
@"{0},{1}",
scale.StartValue,
scale.EndValue);
break;
// stable has M commands that combine X and Y movement, but we decompose those into X/Y with no way to undo
case StoryboardXCommand movementX:
typeAcronym = @"MX";
details = movementX.StartValue == movementX.EndValue
? movementX.StartValue.ToString(CultureInfo.InvariantCulture)
: string.Format(CultureInfo.InvariantCulture,
@"{0},{1}",
movementX.StartValue,
movementX.EndValue);
break;
case StoryboardYCommand movementY:
typeAcronym = @"MY";
details = movementY.StartValue == movementY.EndValue
? movementY.StartValue.ToString(CultureInfo.InvariantCulture)
: string.Format(CultureInfo.InvariantCulture,
@"{0},{1}",
movementY.StartValue,
movementY.EndValue);
break;
case StoryboardColourCommand colour:
typeAcronym = @"C";
details = colour.StartValue == colour.EndValue
? string.Format(CultureInfo.InvariantCulture,
@"{0},{1},{2}",
(int)(colour.StartValue.R * 255), (int)(colour.StartValue.G * 255), (int)(colour.StartValue.B * 255))
: string.Format(CultureInfo.InvariantCulture,
@"{0},{1},{2},{3},{4},{5}",
(int)(colour.StartValue.R * 255), (int)(colour.StartValue.G * 255), (int)(colour.StartValue.B * 255),
(int)(colour.EndValue.R * 255), (int)(colour.EndValue.G * 255), (int)(colour.EndValue.B * 255));
break;
case StoryboardFlipHCommand:
typeAcronym = @"P";
details = @"H";
break;
case StoryboardFlipVCommand:
typeAcronym = @"P";
details = @"V";
break;
case StoryboardBlendingParametersCommand:
typeAcronym = @"P";
details = @"A";
break;
default:
return;
}
writer.WriteLine(string.Format(CultureInfo.InvariantCulture,
@"{0},{1},{2},{3},{4}",
typeAcronym,
(int)command.Easing,
command.StartTime - relativeToTime,
command.StartTime == command.EndTime ? null : command.EndTime - relativeToTime,
details));
}
private LegacyOrigins convertOrigin(Anchor anchor)
{
switch (anchor)
{
case Anchor.TopLeft:
return LegacyOrigins.TopLeft;
case Anchor.TopCentre:
return LegacyOrigins.TopCentre;
case Anchor.TopRight:
return LegacyOrigins.TopRight;
case Anchor.CentreLeft:
return LegacyOrigins.CentreLeft;
case Anchor.Centre:
return LegacyOrigins.Centre;
case Anchor.CentreRight:
return LegacyOrigins.CentreRight;
case Anchor.BottomLeft:
return LegacyOrigins.BottomLeft;
case Anchor.BottomCentre:
return LegacyOrigins.BottomCentre;
case Anchor.BottomRight:
return LegacyOrigins.BottomRight;
default:
return LegacyOrigins.TopLeft;
}
}
}
}
+43 -3
View File
@@ -5,6 +5,7 @@ using System;
using System.Diagnostics;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Timing;
@@ -49,6 +50,11 @@ namespace osu.Game.Beatmaps
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
[Resolved]
private AudioManager audioManager { get; set; } = null!;
private Bindable<bool> experimentalAudio = null!;
public bool IsRewinding { get; private set; }
public FramedBeatmapClock(bool applyOffsets, bool requireDecoupling, IClock? source = null)
@@ -66,9 +72,7 @@ namespace osu.Game.Beatmaps
if (applyOffsets)
{
// Audio timings in general with newer BASS versions don't match stable.
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
platformOffsetClock = new OffsetCorrectionClock(interpolatedTrack) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
platformOffsetClock = new OffsetCorrectionClock(interpolatedTrack);
// User global offset (set in settings) should also be applied.
userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock);
@@ -94,6 +98,9 @@ namespace osu.Game.Beatmaps
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
experimentalAudio = audioManager.UseExperimentalWasapi.GetBoundCopy();
experimentalAudio.BindValueChanged(_ => updatePlatformOffset());
// TODO: this doesn't update when using ChangeSource() to change beatmap.
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
r => r.Find<BeatmapInfo>(beatmap.Value.BeatmapInfo.ID)?.UserSettings,
@@ -105,6 +112,39 @@ namespace osu.Game.Beatmaps
}
}
/// <summary>
/// Audio timings in general with newer BASS versions don't match stable.
/// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
/// </summary>
public const double WINDOWS_BASE_AUDIO_OFFSET = 15;
/// <summary>
/// An additional offset applied to account for experimental mode being much better.
/// </summary>
public const double WINDOWS_EXPERIMENTAL_AUDIO_OFFSET = -25;
private void updatePlatformOffset()
{
if (!applyOffsets)
return;
Debug.Assert(platformOffsetClock != null);
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
platformOffsetClock.Offset = WINDOWS_BASE_AUDIO_OFFSET;
if (audioManager.UseExperimentalWasapi.Value)
platformOffsetClock.Offset += WINDOWS_EXPERIMENTAL_AUDIO_OFFSET;
return;
default:
platformOffsetClock.Offset = 0;
break;
}
}
protected override void Update()
{
base.Update();
-6
View File
@@ -44,12 +44,6 @@ namespace osu.Game.Beatmaps
/// </summary>
SortedList<BreakPeriod> Breaks { get; set; }
/// <summary>
/// All lines from the [Events] section which aren't handled in the encoding process yet.
/// These lines should be written out to the beatmap file on save or export.
/// </summary>
List<string> UnhandledEventLines { get; }
/// <summary>
/// Total amount of break time in the beatmap.
/// </summary>
@@ -9,8 +9,8 @@ namespace osu.Game.Configuration
{
protected override string Filename => base.Filename.Replace(".ini", ".dev.ini");
public DevelopmentOsuConfigManager(Storage storage, GameHost? host = null)
: base(storage, host)
public DevelopmentOsuConfigManager(Storage storage)
: base(storage)
{
}
}
@@ -0,0 +1,69 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osu.Game.Overlays.Settings.Sections.Audio;
namespace osu.Game.Configuration
{
public partial class MigrateNewAudioDialog : PopupDialog
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
public MigrateNewAudioDialog(bool wasAlreadyUsing)
{
Icon = FontAwesome.Regular.Bell;
if (wasAlreadyUsing)
{
HeaderText = @"New audio engine is now default!";
BodyText =
$"""
We recently added a new "Experimental Audio" backend for Windows users to reduce hitsound latency. Due to overwhelmingly positive feedback, this is now the default mode.
As you were already using this engine, your audio offset has been adjusted to account for an internal offset change (no intervention required).
If you have any issues, you can switch back to the legacy engine from settings via the "{AudioSettingsStrings.LegacyAudioLabel}" checkbox.
""";
}
else
{
HeaderText = @"New audio engine has been enabled";
BodyText =
$"""
We recently added a new "Experimental Audio" backend for Windows users to reduce hitsound latency. Due to overwhelmingly positive feedback, this is now the default mode.
If you have any issues, you can switch back to the legacy engine below, or at any time in settings via the "{AudioSettingsStrings.LegacyAudioLabel}" checkbox.
""";
MainContent.Add(new Container
{
Margin = new MarginPadding { Top = 20 },
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 400,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
new LegacyAudioCheckbox(),
}
});
}
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
{
Text = BeatmapOverlayStrings.UserContentConfirmButtonText,
},
};
}
}
}
+1 -45
View File
@@ -2,15 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Configuration.Tracking;
using osu.Framework.Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Input.Handlers.Mouse;
using osu.Framework.Input.Handlers.Pen;
using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Game.Beatmaps.Drawables.Cards;
@@ -33,14 +30,9 @@ namespace osu.Game.Configuration
{
public class OsuConfigManager : IniConfigManager<OsuSetting>, IGameplaySettings
{
private readonly GameHost? host;
public OsuConfigManager(Storage storage, GameHost? host = null)
public OsuConfigManager(Storage storage)
: base(storage)
{
this.host = host;
Migrate();
}
protected override void InitialiseDefaults()
@@ -258,42 +250,6 @@ namespace osu.Game.Configuration
return false;
}
public void Migrate()
{
// arrives as 2020.123.0-lazer
string rawVersion = Get<string>(OsuSetting.Version);
if (rawVersion.Length < 6)
return;
string[] pieces = rawVersion.Split('.');
// on a fresh install or when coming from a non-release build, execution will end here.
// we don't want to run migrations in such cases.
if (!int.TryParse(pieces[0], out int year)) return;
if (!int.TryParse(pieces[1], out int monthDay)) return;
int combined = year * 10000 + monthDay;
if (combined < 20250214)
{
// UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before.
if (RuntimeInfo.IsMobile)
GetBindable<float>(OsuSetting.UIScale).SetDefault();
}
if (combined < 20250428)
{
// Pen tablet sensitivity is now separated from cursor sensitivity.
// Most users will want the default to be what they already had set on cursor sensitivity so let's transfer it.
var mouseHandler = host?.AvailableInputHandlers.OfType<MouseHandler>().SingleOrDefault();
var penHandler = host?.AvailableInputHandlers.OfType<PenHandler>().SingleOrDefault();
if (penHandler != null && mouseHandler != null && penHandler.Sensitivity.IsDefault)
penHandler.Sensitivity.Value = mouseHandler.Sensitivity.Value;
}
}
public override TrackedSettings CreateTrackedSettings()
{
return new TrackedSettings
+11 -1
View File
@@ -67,6 +67,16 @@ namespace osu.Game.Database
Configuration = new LegacySkinDecoder().Decode(skinStreamReader)
};
using var storyboardStream = base.GetFileContents(model, file);
if (storyboardStream == null)
return null;
using var storyboardStreamReader = new LineBufferedReader(storyboardStream);
var beatmapStoryboard = new LegacyStoryboardDecoder().Decode(storyboardStreamReader);
beatmapStoryboard.Beatmap = beatmapContent;
beatmapStoryboard.BeatmapInfo = beatmapInfo;
MutateBeatmap(model, playableBeatmap);
// Encode to legacy format
@@ -78,7 +88,7 @@ namespace osu.Game.Database
// If we don't do that, uploads to BSS may show changes where there are none.
sw.NewLine = "\r\n";
new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw);
new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin, beatmapStoryboard).Encode(sw);
}
stream.Seek(0, SeekOrigin.Begin);
@@ -187,10 +187,9 @@ namespace osu.Game.Database
break;
}
double modMultiplier = 1;
foreach (var mod in score.Mods)
modMultiplier *= mod.ScoreMultiplier;
var ruleset = score.Ruleset.CreateInstance();
var scoreMultiplierCalculator = ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext(score));
double modMultiplier = scoreMultiplierCalculator.CalculateFor(score.Mods);
return (long)Math.Round((1000000 * (accuracyPortion * accuracyScore + (1 - accuracyPortion) * comboScore) + bonusScore) * modMultiplier);
@@ -352,7 +351,8 @@ namespace osu.Game.Database
long maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore;
double bonusProportion = Math.Max(0, ((long)score.LegacyTotalScore - maximumLegacyBaseScore) * maximumLegacyBonusRatio);
double modMultiplier = score.Mods.Select(m => m.ScoreMultiplier).Aggregate(1.0, (c, n) => c * n);
var modMultiplierCalculator = ruleset.CreateScoreMultiplierCalculator(new ScoreMultiplierContext());
double modMultiplier = modMultiplierCalculator.CalculateFor(score.Mods);
long convertedTotalScoreWithoutMods;
+35 -3
View File
@@ -93,13 +93,21 @@ namespace osu.Game.Graphics
public static IconUsage EditorHitCircle => get(OsuIconMapping.EditorHitCircle);
public static IconUsage EditorSlider => get(OsuIconMapping.EditorSlider);
public static IconUsage EditorSpinner => get(OsuIconMapping.EditorSpinner);
public static IconUsage EditorHit => get(OsuIconMapping.EditorHit);
public static IconUsage EditorDrumRoll => get(OsuIconMapping.EditorDrumRoll);
public static IconUsage EditorSwell => get(OsuIconMapping.EditorSwell);
public static IconUsage EditorFruit => get(OsuIconMapping.EditorFruit);
public static IconUsage EditorJuiceStream => get(OsuIconMapping.EditorJuiceStream);
public static IconUsage EditorNote => get(OsuIconMapping.EditorNote);
public static IconUsage EditorHoldNote => get(OsuIconMapping.EditorHoldNote);
public static IconUsage EditorBananaShower => get(OsuIconMapping.EditorBananaShower);
public static IconUsage EditorGrid => get(OsuIconMapping.EditorGrid);
public static IconUsage EditorAddControlPoint => get(OsuIconMapping.EditorAddControlPoint);
public static IconUsage EditorConvertToStream => get(OsuIconMapping.EditorConvertToStream);
public static IconUsage EditorDistanceSnap => get(OsuIconMapping.EditorDistanceSnap);
public static IconUsage EditorFinish => get(OsuIconMapping.EditorFinish);
public static IconUsage EditorGridSnap => get(OsuIconMapping.EditorGridSnap);
public static IconUsage EditorNewCombo => get(OsuIconMapping.EditorNewCombo);
public static IconUsage EditorNewComboSparkles => get(OsuIconMapping.EditorNewComboSparkles);
public static IconUsage EditorSelect => get(OsuIconMapping.EditorSelect);
public static IconUsage EditorSound => get(OsuIconMapping.EditorSound);
public static IconUsage EditorWhistle => get(OsuIconMapping.EditorWhistle);
@@ -409,6 +417,30 @@ namespace osu.Game.Graphics
[Description(@"Editor/spinner")]
EditorSpinner,
[Description(@"Editor/hit")]
EditorHit,
[Description(@"Editor/drum-roll")]
EditorDrumRoll,
[Description(@"Editor/swell")]
EditorSwell,
[Description(@"Editor/fruit")]
EditorFruit,
[Description(@"Editor/juice-stream")]
EditorJuiceStream,
[Description(@"Editor/banana-shower")]
EditorBananaShower,
[Description(@"Editor/note")]
EditorNote,
[Description(@"Editor/hold-note")]
EditorHoldNote,
[Description(@"Editor/grid")]
EditorGrid,
@@ -427,8 +459,8 @@ namespace osu.Game.Graphics
[Description(@"Editor/grid-snap")]
EditorGridSnap,
[Description(@"Editor/new-combo")]
EditorNewCombo,
[Description(@"Editor/new-combo-sparkles")]
EditorNewComboSparkles,
[Description(@"Editor/select")]
EditorSelect,
@@ -21,9 +21,11 @@ using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK;
using osuTK.Graphics;
using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Graphics.UserInterfaceV2
{
@@ -67,6 +69,12 @@ namespace osu.Game.Graphics.UserInterfaceV2
/// </summary>
public LocalisableString PlaceholderText { get; init; }
/// <summary>
/// If set to <see langword="true"/>, the selector will display a button,
/// which when clicked, will change <see cref="Current"/>'s value to <see langword="null"/>.
/// </summary>
public bool AllowClear { get; init; }
public Container PreviewContainer { get; private set; } = null!;
private FormControlBackground background = null!;
@@ -180,7 +188,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
private void onFileSelected()
{
if (Current.Value != null)
if (Current.Value != null || AllowClear)
this.HidePopover();
initialChooserPath = Current.Value?.DirectoryName;
@@ -238,12 +246,12 @@ namespace osu.Game.Graphics.UserInterfaceV2
Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException();
protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath) =>
new FileChooserPopover(handledExtensions, current, chooserPath);
protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath, bool allowClear) =>
new FileChooserPopover(handledExtensions, current, chooserPath, allowClear);
public Popover GetPopover()
{
var popover = CreatePopover(handledExtensions, Current, initialChooserPath);
var popover = CreatePopover(handledExtensions, Current, initialChooserPath, AllowClear);
popoverState.UnbindBindings();
popoverState.BindTo(popover.State);
return popover;
@@ -258,7 +266,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
protected OsuFileSelector FileSelector;
public FileChooserPopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath)
public FileChooserPopover(string[] handledExtensions, Bindable<FileInfo?> current, string? chooserPath, bool allowClear)
: base(false)
{
Child = new Container
@@ -267,9 +275,37 @@ namespace osu.Game.Graphics.UserInterfaceV2
// simplest solution to avoid underlying text to bleed through the bottom border
// https://github.com/ppy/osu/pull/30005#issuecomment-2378884430
Padding = new MarginPadding { Bottom = 1 },
Child = FileSelector = new OsuFileSelector(chooserPath, handledExtensions)
Children = new[]
{
RelativeSizeAxes = Axes.Both,
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Bottom = allowClear ? 50 : 0 },
Child = FileSelector = new OsuFileSelector(chooserPath, handledExtensions)
{
RelativeSizeAxes = Axes.Both,
},
},
allowClear
? new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Padding = new MarginPadding(5),
Child = new DangerousRoundedButton
{
Text = CommonStrings.ButtonsClear,
Action = () => OnFileSelected(null),
Enabled = { Value = current.Value != null },
Padding = new MarginPadding(5),
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Width = 60,
}
}
: Empty()
},
};
@@ -308,7 +344,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
};
}
protected virtual void OnFileSelected(FileInfo file) => current.Value = file;
protected virtual void OnFileSelected(FileInfo? file) => current.Value = file;
}
}
}
@@ -155,6 +155,8 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.EditorSeekToPreviousBookmark),
new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.EditorSeekToNextBookmark),
new KeyBinding(new[] { InputKey.Control, InputKey.L }, GlobalAction.EditorDiscardUnsavedChanges),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.U }, GlobalAction.EditorSubmitBeatmap),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.O }, GlobalAction.EditorEditExternally),
};
private static IEnumerable<KeyBinding> editorTestPlayKeyBindings => new[]
@@ -528,6 +530,12 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.NextSkin))]
NextSkin,
[LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.SubmitBeatmap))]
EditorSubmitBeatmap,
[LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.EditExternally))]
EditorEditExternally
}
public enum GlobalActionCategory
@@ -100,19 +100,14 @@ namespace osu.Game.Localisation
public static LocalisableString AdjustBeatmapOffsetAutomaticallyTooltip => new TranslatableString(getKey(@"adjust_beatmap_offset_automatically_tooltip"), @"If enabled, the offset suggested from last play on a beatmap is automatically applied.");
/// <summary>
/// "Use experimental audio mode"
/// "Use legacy audio mode"
/// </summary>
public static LocalisableString WasapiLabel => new TranslatableString(getKey(@"wasapi_label"), @"Use experimental audio mode");
public static LocalisableString LegacyAudioLabel => new TranslatableString(getKey(@"legacy_audio_label"), @"Use legacy audio mode");
/// <summary>
/// "This will attempt to initialise the audio engine in a lower latency mode."
/// "Use this if you are experiencing audio issues. Note that audio latency will be higher when this is toggled on."
/// </summary>
public static LocalisableString WasapiTooltip => new TranslatableString(getKey(@"wasapi_tooltip"), @"This will attempt to initialise the audio engine in a lower latency mode.");
/// <summary>
/// "Due to reduced latency, your audio offset will need to be adjusted when enabling this setting. Generally expect to subtract 20 - 60 ms from your known value."
/// </summary>
public static LocalisableString WasapiNotice => new TranslatableString(getKey(@"wasapi_notice"), @"Due to reduced latency, your audio offset will need to be adjusted when enabling this setting. Generally expect to subtract 20 - 60 ms from your known value.");
public static LocalisableString LegacyAudioTooltip => new TranslatableString(getKey(@"legacy_audio_tooltip"), @"Use this if you are experiencing audio issues. Note that audio latency will be higher when this is toggled on.");
private static string getKey(string key) => $@"{prefix}:{key}";
}
+31 -22
View File
@@ -42,8 +42,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "If enabled, an &quot;Are you ready? 3, 2, 1, GO!&quot; countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so."
/// </summary>
public static LocalisableString CountdownDescription => new TranslatableString(getKey(@"countdown_description"),
@"If enabled, an ""Are you ready? 3, 2, 1, GO!"" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so.");
public static LocalisableString CountdownDescription => new TranslatableString(getKey(@"countdown_description"), @"If enabled, an ""Are you ready? 3, 2, 1, GO!"" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so.");
/// <summary>
/// "Countdown speed"
@@ -53,8 +52,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "If the countdown sounds off-time, use this to make it appear one or more beats early."
/// </summary>
public static LocalisableString CountdownOffsetDescription =>
new TranslatableString(getKey(@"countdown_offset_description"), @"If the countdown sounds off-time, use this to make it appear one or more beats early.");
public static LocalisableString CountdownOffsetDescription => new TranslatableString(getKey(@"countdown_offset_description"), @"If the countdown sounds off-time, use this to make it appear one or more beats early.");
/// <summary>
/// "Countdown offset"
@@ -69,8 +67,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "Allows storyboards to use the full screen space, rather than be confined to a 4:3 area."
/// </summary>
public static LocalisableString WidescreenSupportDescription =>
new TranslatableString(getKey(@"widescreen_support_description"), @"Allows storyboards to use the full screen space, rather than be confined to a 4:3 area.");
public static LocalisableString WidescreenSupportDescription => new TranslatableString(getKey(@"widescreen_support_description"), @"Allows storyboards to use the full screen space, rather than be confined to a 4:3 area.");
/// <summary>
/// "Epilepsy warning"
@@ -80,8 +77,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "Recommended if the storyboard or video contain scenes with rapidly flashing colours."
/// </summary>
public static LocalisableString EpilepsyWarningDescription =>
new TranslatableString(getKey(@"epilepsy_warning_description"), @"Recommended if the storyboard or video contain scenes with rapidly flashing colours.");
public static LocalisableString EpilepsyWarningDescription => new TranslatableString(getKey(@"epilepsy_warning_description"), @"Recommended if the storyboard or video contain scenes with rapidly flashing colours.");
/// <summary>
/// "Letterbox during breaks"
@@ -91,8 +87,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "Adds horizontal letterboxing to give a cinematic look during breaks."
/// </summary>
public static LocalisableString LetterboxDuringBreaksDescription =>
new TranslatableString(getKey(@"letterbox_during_breaks_description"), @"Adds horizontal letterboxing to give a cinematic look during breaks.");
public static LocalisableString LetterboxDuringBreaksDescription => new TranslatableString(getKey(@"letterbox_during_breaks_description"), @"Adds horizontal letterboxing to give a cinematic look during breaks.");
/// <summary>
/// "Samples match playback rate"
@@ -102,8 +97,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "When enabled, all samples will speed up or slow down when rate-changing mods are enabled."
/// </summary>
public static LocalisableString SamplesMatchPlaybackRateDescription => new TranslatableString(getKey(@"samples_match_playback_rate_description"),
@"When enabled, all samples will speed up or slow down when rate-changing mods are enabled.");
public static LocalisableString SamplesMatchPlaybackRateDescription => new TranslatableString(getKey(@"samples_match_playback_rate_description"), @"When enabled, all samples will speed up or slow down when rate-changing mods are enabled.");
/// <summary>
/// "The size of all hit objects"
@@ -123,8 +117,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "The harshness of hit windows and difficulty of special objects (ie. spinners)"
/// </summary>
public static LocalisableString OverallDifficultyDescription =>
new TranslatableString(getKey(@"overall_difficulty_description"), @"The harshness of hit windows and difficulty of special objects (ie. spinners)");
public static LocalisableString OverallDifficultyDescription => new TranslatableString(getKey(@"overall_difficulty_description"), @"The harshness of hit windows and difficulty of special objects (ie. spinners)");
/// <summary>
/// "Tick Rate"
@@ -134,8 +127,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "Determines how many &quot;ticks&quot; are generated within long hit objects. A tick rate of 1 will generate ticks on each beat, 2 would be twice per beat, etc."
/// </summary>
public static LocalisableString TickRateDescription => new TranslatableString(getKey(@"tick_rate_description"),
@"Determines how many ""ticks"" are generated within long hit objects. A tick rate of 1 will generate ticks on each beat, 2 would be twice per beat, etc.");
public static LocalisableString TickRateDescription => new TranslatableString(getKey(@"tick_rate_description"), @"Determines how many ""ticks"" are generated within long hit objects. A tick rate of 1 will generate ticks on each beat, 2 would be twice per beat, etc.");
/// <summary>
/// "Base Velocity"
@@ -145,8 +137,7 @@ namespace osu.Game.Localisation
/// <summary>
/// "The base velocity of the beatmap, affecting things like slider velocity and scroll speed in some rulesets."
/// </summary>
public static LocalisableString BaseVelocityDescription => new TranslatableString(getKey(@"base_velocity_description"),
@"The base velocity of the beatmap, affecting things like slider velocity and scroll speed in some rulesets.");
public static LocalisableString BaseVelocityDescription => new TranslatableString(getKey(@"base_velocity_description"), @"The base velocity of the beatmap, affecting things like slider velocity and scroll speed in some rulesets.");
/// <summary>
/// "Metadata"
@@ -188,6 +179,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString AudioTrack => new TranslatableString(getKey(@"audio_track"), @"Audio Track");
/// <summary>
/// "Video"
/// </summary>
public static LocalisableString Video => new TranslatableString(getKey(@"video"), @"Video");
/// <summary>
/// "The video will be used instead of the static background, if present. Beatmap downloads are offered both with and without video, so if adding a video, a matching background should also be provided."
/// </summary>
public static LocalisableString VideoHint => new TranslatableString(getKey(@"video_hint"), @"The video will be used instead of the static background, if present. Beatmap downloads are offered both with and without video, so if adding a video, a matching background should also be provided.");
/// <summary>
/// "Custom sample sets"
/// </summary>
@@ -198,6 +199,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ClickToSelectTrack => new TranslatableString(getKey(@"click_to_select_track"), @"Click to select a track");
/// <summary>
/// "Click to select a video"
/// </summary>
public static LocalisableString ClickToSelectVideo => new TranslatableString(getKey(@"click_to_select_video"), @"Click to select a video");
/// <summary>
/// "Click to select a background image"
/// </summary>
@@ -221,14 +227,12 @@ namespace osu.Game.Localisation
/// <summary>
/// "Sync metadata with all difficulties"
/// </summary>
public static LocalisableString SyncMetadataWithAllDifficulties =>
new TranslatableString(getKey(@"sync_metadata_with_all_difficulties"), @"Sync metadata with all difficulties");
public static LocalisableString SyncMetadataWithAllDifficulties => new TranslatableString(getKey(@"sync_metadata_with_all_difficulties"), @"Sync metadata with all difficulties");
/// <summary>
/// "Copies artist, title, source, and tags to all difficulties."
/// </summary>
public static LocalisableString SyncMetadataWithAllDifficultiesTooltip => new TranslatableString(getKey(@"sync_metadata_with_all_difficulties_tooltip"),
@"Copies artist, title, source, and tags to all difficulties.");
public static LocalisableString SyncMetadataWithAllDifficultiesTooltip => new TranslatableString(getKey(@"sync_metadata_with_all_difficulties_tooltip"), @"Copies artist, title, source, and tags to all difficulties.");
/// <summary>
/// "Ruleset ({0})"
@@ -260,6 +264,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString DragToSetBackground => new TranslatableString(getKey(@"drag_to_set_background"), @"Drag image here to set beatmap background!");
/// <summary>
/// "Drag video here to set beatmap video!"
/// </summary>
public static LocalisableString DragToSetVideo => new TranslatableString(getKey(@"drag_to_set_video"), @"Drag video here to set beatmap video!");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -0,0 +1,20 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using MessagePack;
namespace osu.Game.Online.Multiplayer
{
/// <summary>
/// User requests to change their slot in the room.
/// </summary>
[MessagePackObject]
public class ChangeSlotRequest : MatchUserRequest
{
/// <summary>
/// The zero-based ID of the desired slot.
/// </summary>
[Key(0)]
public byte SlotID { get; set; }
}
}
@@ -18,6 +18,7 @@ namespace osu.Game.Online.Multiplayer
[Union(0, typeof(TeamVersusRoomState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types.
[Union(1, typeof(MatchmakingRoomState))]
[Union(2, typeof(RankedPlayRoomState))]
[Union(3, typeof(StandardMatchRoomState))]
public abstract class MatchRoomState
{
}
@@ -7,22 +7,20 @@ using MessagePack;
namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus
{
[MessagePackObject]
public class TeamVersusRoomState : MatchRoomState
public class TeamVersusRoomState : StandardMatchRoomState
{
[Key(0)]
public List<MultiplayerTeam> Teams { get; set; } = new List<MultiplayerTeam>();
[Key(1)]
public bool Locked { get; set; }
public static TeamVersusRoomState CreateDefault() =>
public static TeamVersusRoomState CreateDefault(byte? maxParticipants = null) =>
new TeamVersusRoomState
{
Teams =
{
new MultiplayerTeam { ID = 0, Name = "Team Red" },
new MultiplayerTeam { ID = 1, Name = "Team Blue" },
}
},
Slots = maxParticipants == null ? null : new int?[maxParticipants.Value]
};
}
}
@@ -23,6 +23,7 @@ namespace osu.Game.Online.Multiplayer
[Union(4, typeof(RankedPlayCardHandReplayRequest))]
[Union(5, typeof(SetLockStateRequest))]
[Union(6, typeof(RollRequest))]
[Union(7, typeof(ChangeSlotRequest))]
public abstract class MatchUserRequest
{
}
@@ -403,8 +403,9 @@ namespace osu.Game.Online.Multiplayer
/// <param name="queueMode">The new queue mode, if any.</param>
/// <param name="autoStartDuration">The new auto-start countdown duration, if any.</param>
/// <param name="autoSkip">The new auto-skip setting.</param>
/// <param name="maxParticipants">The new participant count limit, if any.</param>
public Task ChangeSettings(Optional<string> name = default, Optional<string> password = default, Optional<MatchType> matchType = default, Optional<QueueMode> queueMode = default,
Optional<TimeSpan> autoStartDuration = default, Optional<bool> autoSkip = default)
Optional<TimeSpan> autoStartDuration = default, Optional<bool> autoSkip = default, Optional<byte?> maxParticipants = default)
{
if (Room == null)
throw new InvalidOperationException("Must be joined to a match to change settings.");
@@ -416,7 +417,8 @@ namespace osu.Game.Online.Multiplayer
MatchType = matchType.GetOr(Room.Settings.MatchType),
QueueMode = queueMode.GetOr(Room.Settings.QueueMode),
AutoStartDuration = autoStartDuration.GetOr(Room.Settings.AutoStartDuration),
AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip)
AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip),
MaxParticipants = maxParticipants.GetOr(Room.Settings.MaxParticipants),
});
}
@@ -1009,6 +1011,7 @@ namespace osu.Game.Online.Multiplayer
APIRoom.AutoStartDuration = Room.Settings.AutoStartDuration;
APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId);
APIRoom.AutoSkip = Room.Settings.AutoSkip;
APIRoom.MaxParticipants = Room.Settings.MaxParticipants;
SettingsChanged?.Invoke(settings);
RoomUpdated?.Invoke();
@@ -32,6 +32,9 @@ namespace osu.Game.Online.Multiplayer
[Key(6)]
public bool AutoSkip { get; set; }
[Key(7)]
public byte? MaxParticipants { get; set; }
[IgnoreMember]
public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero;
@@ -47,6 +50,7 @@ namespace osu.Game.Online.Multiplayer
QueueMode = room.QueueMode;
AutoStartDuration = room.AutoStartDuration;
AutoSkip = room.AutoSkip;
MaxParticipants = room.MaxParticipants;
}
public bool Equals(MultiplayerRoomSettings? other)
@@ -60,7 +64,8 @@ namespace osu.Game.Online.Multiplayer
&& MatchType == other.MatchType
&& QueueMode == other.QueueMode
&& AutoStartDuration == other.AutoStartDuration
&& AutoSkip == other.AutoSkip;
&& AutoSkip == other.AutoSkip
&& MaxParticipants == other.MaxParticipants;
}
public override string ToString() => $"Name:{Name}"
@@ -69,6 +74,7 @@ namespace osu.Game.Online.Multiplayer
+ $" Item:{PlaylistItemId}"
+ $" Queue:{QueueMode}"
+ $" Start:{AutoStartDuration}"
+ $" AutoSkip:{AutoSkip}";
+ $" AutoSkip:{AutoSkip}"
+ $" MaxParticipants:{MaxParticipants?.ToString() ?? "no limit"}";
}
}

Some files were not shown because too many files have changed in this diff Show More