1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-02 23:41:00 +08:00

Compare commits

...

34 Commits

  • Fix edge cases around matchmaking queue notifications (#37942)
    Supersedes / closes https://github.com/ppy/osu/pull/37881
    Fixes https://github.com/ppy/osu/issues/37497
    Fixes https://github.com/ppy/osu/issues/37214
    Fixes https://github.com/ppy/osu/issues/36744
    
    ## Some ranked play matches getting stuck in the database
    
    This is an issue I've been investigating for a while now, and I have
    suspicions that it's related to the following sentry event:
    https://sentry.ppy.sh/organizations/ppy/issues/15519/events/7a0d5a390a2d4894ae382a1fdcfd54cc/
    
    ```
    Unobserved exception occurred via FireAndForget call: Cannot join a multiplayer room while already in one.
    ```
    
    While I haven't repro'd the exact failure case where `ends_at` is
    `NULL`, it does put the game into a weird state:
    
    
    https://github.com/user-attachments/assets/1d97cfbf-ae97-4f78-89bb-6b3f186c7c4e
    
    Should be fixed by 787ef7fe2d
    
    ## Game sometimes deadlocking when accepting queue invitations
    
    I've personally experienced this when waiting for the invitation
    notification to time out.
    
    
    https://github.com/user-attachments/assets/cc191cbd-d72a-4a2e-a300-81f22b3fc993
    
    Should be fixed by b238bf8aea
  • Add beatmap difficulty before mods as context for score multiplier calculations (#37921)
    - Part of https://github.com/ppy/osu/issues/37818
    
    Access to difficulty info is required for the upcoming multiplier
    proposals. All places providing difficulty info intentionally use
    `IBeatmapInfo` as the difficulty info exposed to the calculator should
    _always_ be pre-mods for our usecase.
    
    There's a couple of quirks:
    
    - The usage in `ScoreProcessor` is a bit troubling to me but I can't see
    a way to make it better without refactoring it. Essentially, we don't
    have a beatmap until `ApplyBeatmap` is called, but most usages of
    `ScoreProcessor` are setting `Mods` prior to `ApplyBeatmap` so there is
    a `null` check in the logic for when mods change. Additionally, this
    means a new bindable of the beatmap via `ApplyBeatmap` which also feels
    a bit dirty. Open to suggestions.
    - ~~`BeatmapLeaderboardScore.Tooltip` is using a null-forgiving on the
    `BeatmapInfo`, but there's basically no context available on if this is
    an issue - the only code path which sets the score is `SetContent` which
    has no callers, so it's essentially dead code. Makes sense given it's
    Select V1.~~
    
    ---------
    
    Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
  • Obsolete Mod.ScoreMultiplier and remove all other references to it (#37846)
    - Part of https://github.com/ppy/osu/issues/37818
    - [x] Depends on https://github.com/ppy/osu/pull/37845
    
    Purely mechanical, split away for ease of review / due to size.
    
    ---------
    
    Co-authored-by: Dean Herbert <pe@ppy.sh>
  • 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.
261 changed files with 4560 additions and 860 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.527.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
@@ -0,0 +1,67 @@
// 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.Beatmaps;
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(new BeatmapDifficulty()));
}
[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();
}
@@ -13,8 +13,6 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public partial class CatchModFlashlight : ModFlashlight<CatchHitObject>
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1)
{
MinValue = 0.5f,
@@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Catch.Mods
public override string Name => "Floating Fruits";
public override string Acronym => "FF";
public override LocalisableString Description => "The fruits are... floating?";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModFloatingFruits;
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
@@ -10,8 +10,6 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModHardRock : ModHardRock, IApplicableToBeatmapProcessor
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor)
{
var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor;
@@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Catch.Mods
public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset<CatchHitObject>
{
public override LocalisableString Description => @"Play with fading fruits.";
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
private const double fade_out_offset_multiplier = 0.6;
private const double fade_out_duration_multiplier = 0.44;
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Catch.Mods
public override string Acronym => "MF";
public override LocalisableString Description => "Dashing by default, slow down!";
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModMovingFast;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax) };
@@ -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,224 @@
// 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.Beatmaps;
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 BeatmapDifficulty(), 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(new BeatmapDifficulty())).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;
@@ -14,7 +14,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => Name;
public abstract int KeyCount { get; }
public override ModType Type => ModType.Conversion;
public override double ScoreMultiplier => 0.9;
public override bool Ranked => UsesDefaultConfiguration;
public void ApplyToBeatmapConverter(IBeatmapConverter beatmapConverter)
@@ -18,8 +18,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "CS";
public override double ScoreMultiplier => 0.9;
public override LocalisableString Description => "No more tricky speed changes!";
public override IconUsage? Icon => OsuIcon.ModConstantSpeed;
@@ -20,8 +20,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override LocalisableString Description => @"Decrease the playfield's viewing area.";
public override double ScoreMultiplier => 1;
protected override CoverExpandDirection ExpandDirection => Direction.Value;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
@@ -7,9 +7,5 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModDoubleTime : ModDoubleTime, IManiaRateAdjustmentMod
{
// For now, all rate-increasing mods should be given a 1x multiplier in mania because it doesn't always
// make the map harder and is more of a personal preference.
// In the future, we can consider adjusting this by experimenting with not applying the hitwindow leniency.
public override double ScoreMultiplier => 1;
}
}
@@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override LocalisableString Description => @"Double the stages, double the fun!";
public override IconUsage? Icon => OsuIcon.ModDualStages;
public override ModType Type => ModType.Conversion;
public override double ScoreMultiplier => 1;
private bool isForCurrentRuleset;
@@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "FI";
public override IconUsage? Icon => OsuIcon.ModFadeIn;
public override LocalisableString Description => @"Keys appear out of nowhere!";
public override double ScoreMultiplier => 1;
public override bool ValidForFreestyleAsRequiredMod => false;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
@@ -13,7 +13,6 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public partial class ManiaModFlashlight : ModFlashlight<ManiaHitObject>
{
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModHidden) };
public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1)
@@ -10,7 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModHardRock : ModHardRock, IApplicableToHitObject
{
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public const double HIT_WINDOW_DIFFICULTY_MULTIPLIER = 1.4;
@@ -30,7 +30,6 @@ namespace osu.Game.Rulesets.Mania.Mods
private const float coverage_increase_per_combo = 0.5f;
public override LocalisableString Description => @"Keys fade out before you hit them!";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
@@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "HO";
public override double ScoreMultiplier => 0.9;
public override LocalisableString Description => @"Replaces all hold notes with normal notes.";
public override IconUsage? Icon => OsuIcon.ModHoldOff;
@@ -20,7 +20,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Name => "Invert";
public override string Acronym => "IN";
public override double ScoreMultiplier => 1;
public override LocalisableString Description => "Hold the keys. To the beat.";
@@ -8,9 +8,5 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModNightcore : ModNightcore<ManiaHitObject>, IManiaRateAdjustmentMod
{
// For now, all rate-increasing mods should be given a 1x multiplier in mania because it doesn't always
// make the map any harder and is more of a personal preference.
// In the future, we can consider adjusting this by experimenting with not applying the hitwindow leniency.
public override double ScoreMultiplier => 1;
}
}
@@ -26,8 +26,6 @@ namespace osu.Game.Rulesets.Mania.Mods
public override LocalisableString Description => "No more timing the end of hold notes.";
public override double ScoreMultiplier => 0.9;
public override IconUsage? Icon => OsuIcon.ModNoRelease;
public override ModType Type => ModType.DifficultyReduction;
@@ -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);
}
}
}
@@ -21,7 +21,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public abstract partial class InputBlockingMod : Mod, IApplicableToDrawableRuleset<OsuHitObject>, IUpdatableByPlayfield
{
public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(OsuModCinema) };
public override ModType Type => ModType.Conversion;
@@ -19,7 +19,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Name => "Approach Different";
public override string Acronym => "AD";
public override LocalisableString Description => "Never trust the approach circles...";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModApproachDifferent;
public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModFreezeFrame) };
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModAutopilot;
public override ModType Type => ModType.Automation;
public override LocalisableString Description => @"Automatic cursor movement - just follow the rhythm.";
public override double ScoreMultiplier => 0.1;
public override Type[] IncompatibleMods => new[]
{
@@ -30,7 +30,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModBlinds;
public override ModType Type => ModType.DifficultyIncrease;
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight) };
public override bool Ranked => true;
@@ -26,7 +26,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModBloom;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "The cursor blooms into.. a larger cursor!";
public override double ScoreMultiplier => 1;
protected const float MIN_SIZE = 1;
protected const float TRANSITION_DURATION = 100;
public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight), typeof(OsuModNoScope), typeof(ModTouchDevice) };
@@ -34,8 +34,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override LocalisableString Description => "Don't let their popping distract you!";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModBubbles;
public override ModType Type => ModType.Fun;
@@ -25,7 +25,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModDepth;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "3D. Almost.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModFreezeFrame), typeof(ModWithVisibilityAdjustment) }).ToArray();
private static readonly Vector3 camera_position = new Vector3(OsuPlayfield.BASE_SIZE.X * 0.5f, OsuPlayfield.BASE_SIZE.Y * 0.5f, -200);
@@ -19,7 +19,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public partial class OsuModFlashlight : ModFlashlight<OsuHitObject>, IApplicableToDrawableHitObject
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModBloom), typeof(OsuModBlinds) }).ToArray();
private const double default_follow_delay = 120;
@@ -23,8 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModFreezeFrame;
public override double ScoreMultiplier => 1;
public override LocalisableString Description => "Burn the notes into your memory.";
/// <remarks>
@@ -13,8 +13,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModHardRock : ModHardRock, IApplicableToHitObject
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModMirror)).ToArray();
public void ApplyToHitObject(HitObject hitObject)
@@ -24,7 +24,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public Bindable<bool> OnlyFadeApproachCircles { get; } = new BindableBool();
public override LocalisableString Description => @"Play with no approach circles and fading circles/sliders.";
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn), typeof(OsuModDepth), typeof(OsuModFreezeFrame) };
@@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModMagnetised;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "No need to chase the circles your cursor is a magnet!";
public override double ScoreMultiplier => 0.5;
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel), typeof(OsuModBubbles), typeof(OsuModDepth) };
[SettingSource("Attraction strength", "How strong the pull is.", 0)]
@@ -19,8 +19,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
[SettingSource("Starting Size", "The initial size multiplier applied to all objects.")]
public abstract BindableNumber<float> StartScale { get; }
@@ -28,7 +28,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModRepel;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "Hit objects run away!";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised), typeof(OsuModBubbles), typeof(OsuModDepth) };
[SettingSource("Repulsion strength", "How strong the repulsion is.", 0)]
@@ -21,7 +21,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModSpinIn;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "Circles spin in. No approach circles.";
public override double ScoreMultiplier => 1;
// todo: this mod needs to be incompatible with "hidden" due to forcing the circle to remain opaque,
// further implementation will be required for supporting that.
@@ -20,7 +20,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModSpunOut;
public override ModType Type => ModType.Automation;
public override LocalisableString Description => @"Spinners will be automatically completed.";
public override double ScoreMultiplier => 0.9;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(OsuModAutopilot), typeof(OsuModTargetPractice) };
public override bool Ranked => UsesDefaultConfiguration;
@@ -29,7 +29,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModStrictTracking;
public override ModType Type => ModType.DifficultyIncrease;
public override LocalisableString Description => @"Once you start a slider, follow precisely or get a miss.";
public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModClassic), typeof(OsuModTargetPractice) };
public void ApplyToDrawableHitObject(DrawableHitObject drawable)
@@ -39,7 +39,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override ModType Type => ModType.Conversion;
public override IconUsage? Icon => OsuIcon.ModTargetPractice;
public override LocalisableString Description => @"Practice keeping up with the beat of the song.";
public override double ScoreMultiplier => 0.1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModTraceable;
public override ModType Type => ModType.DifficultyIncrease;
public override LocalisableString Description => "Put your faith in the approach circles...";
public override double ScoreMultiplier => 1;
public override bool Ranked => true;
public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModDepth) };
@@ -22,7 +22,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModTransform;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "Everything rotates. EVERYTHING.";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModFreezeFrame), typeof(OsuModDepth) }).ToArray();
private float theta;
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModWiggle;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "They just won't stay still...";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModDepth) };
private const int wiggle_duration = 100; // (ms) Higher = fewer wiggles
+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) =>
@@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
{
public override string Name => "Constant Speed";
public override string Acronym => "CS";
public override double ScoreMultiplier => 0.9;
public override LocalisableString Description => "No more tricky speed changes!";
public override IconUsage? Icon => OsuIcon.ModConstantSpeed;
public override ModType Type => ModType.Conversion;
@@ -14,8 +14,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
{
public partial class TaikoModFlashlight : ModFlashlight<TaikoHitObject>
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1)
{
MinValue = 0.5f,
@@ -9,8 +9,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
{
public class TaikoModHardRock : ModHardRock
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
/// <summary>
/// Multiplier factor added to the scrolling speed.
/// </summary>
@@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
public class TaikoModHidden : ModHidden, IApplicableToDrawableRuleset<TaikoHitObject>
{
public override LocalisableString Description => @"Beats fade out before you hit them!";
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1;
/// <summary>
/// How far away from the hit target should hitobjects start to fade out.
@@ -21,7 +21,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
{
public override string Name => "Simplified Rhythm";
public override string Acronym => "SR";
public override double ScoreMultiplier => 0.6;
public override LocalisableString Description => "Simplify tricky rhythms!";
public override IconUsage? Icon => OsuIcon.ModSimplifiedRhythm;
public override ModType Type => ModType.DifficultyReduction;
@@ -30,7 +30,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
public override LocalisableString Description => @"One key for dons, one key for kats.";
public override bool Ranked => true;
public override double ScoreMultiplier => 1.0;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(TaikoModCinema) };
public override ModType Type => ModType.Conversion;
@@ -20,7 +20,6 @@ namespace osu.Game.Rulesets.Taiko.Mods
public override LocalisableString Description => @"Dons become kats, kats become dons";
public override IconUsage? Icon => OsuIcon.ModSwap;
public override ModType Type => ModType.Conversion;
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModRandom)).ToArray();
public override bool Ranked => true;
@@ -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
});
-1
View File
@@ -91,7 +91,6 @@ namespace osu.Game.Tests.Mods
{
public override string Name => "Non-matching setting type mod";
public override LocalisableString Description => "Description";
public override double ScoreMultiplier => 1;
public override ModType Type => ModType.Conversion;
[SettingSource("Test setting")]
-3
View File
@@ -421,7 +421,6 @@ namespace osu.Game.Tests.Mods
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
public override string Acronym => string.Empty;
public override double ScoreMultiplier => 1;
public override bool HasImplementation => true;
public override bool ValidForMultiplayer => false;
public override bool ValidForMultiplayerAsFreeMod => false;
@@ -432,7 +431,6 @@ namespace osu.Game.Tests.Mods
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
public override string Acronym => string.Empty;
public override double ScoreMultiplier => 1;
public override bool HasImplementation => true;
public override bool ValidForMultiplayerAsFreeMod => false;
}
@@ -441,7 +439,6 @@ namespace osu.Game.Tests.Mods
{
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 1;
public override string Acronym => string.Empty;
public override bool HasImplementation => true;
public override bool ValidForFreestyleAsRequiredMod => false;
@@ -59,8 +59,6 @@ namespace osu.Game.Tests.Mods
public abstract class TestModCustomisable : Mod, IApplicableMod
{
public override double ScoreMultiplier => 1.0;
public override LocalisableString Description => "This is a customisable test mod.";
public override ModType Type => ModType.Conversion;
@@ -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
{
@@ -161,7 +161,6 @@ namespace osu.Game.Tests.NonVisual
public override string Name => nameof(ModA);
public override string Acronym => nameof(ModA);
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModIncompatibleWithA), typeof(ModIncompatibleWithAAndB) };
}
@@ -171,7 +170,6 @@ namespace osu.Game.Tests.NonVisual
public override string Name => nameof(ModB);
public override LocalisableString Description => string.Empty;
public override string Acronym => nameof(ModB);
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModIncompatibleWithAAndB) };
}
@@ -181,7 +179,6 @@ namespace osu.Game.Tests.NonVisual
public override string Name => nameof(ModC);
public override string Acronym => nameof(ModC);
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 1;
}
private class ModIncompatibleWithA : Mod
@@ -189,7 +186,6 @@ namespace osu.Game.Tests.NonVisual
public override string Name => $"Incompatible With {nameof(ModA)}";
public override string Acronym => $"Incompatible With {nameof(ModA)}";
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModA) };
}
@@ -208,7 +204,6 @@ namespace osu.Game.Tests.NonVisual
public override string Name => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}";
public override string Acronym => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}";
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModA), typeof(ModB) };
}
@@ -185,7 +185,6 @@ namespace osu.Game.Tests.Online
public override string Name => "Test Mod";
public override string Acronym => "TM";
public override LocalisableString Description => "This is a test mod.";
public override double ScoreMultiplier => 1;
[SettingSource("Test")]
public BindableNumber<double> TestSetting { get; } = new BindableDouble
@@ -202,7 +201,6 @@ namespace osu.Game.Tests.Online
public override string Name => "Test Mod";
public override string Acronym => "TMTR";
public override LocalisableString Description => "This is a test mod.";
public override double ScoreMultiplier => 1;
[SettingSource("Initial rate", "The starting speed of the track")]
public override BindableNumber<double> InitialRate { get; } = new BindableDouble(1.5)

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