1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-18 23:10:50 +08:00

Merge remote-tracking branch 'upstream/master' into pr/22237

This commit is contained in:
Joseph Madamba
2023-10-11 09:53:26 -07:00
Unverified
193 changed files with 2892 additions and 1105 deletions
+337 -178
View File
@@ -1,206 +1,365 @@
# Listens for new PR comments containing !pp check [id], and runs a diffcalc comparison against master.
# Usage:
# !pp check 0 | Runs only the osu! ruleset.
# !pp check 0 2 | Runs only the osu! and catch rulesets.
# ## Description
#
# Uses [diffcalc-sheet-generator](https://github.com/smoogipoo/diffcalc-sheet-generator) to run two builds of osu and generate an SR/PP/Score comparison spreadsheet.
#
# ## Requirements
#
# Self-hosted runner with installed:
# - `docker >= 20.10.16`
# - `docker-compose >= 2.5.1`
# - `lbzip2`
# - `jq`
#
# ## Usage
#
# The workflow can be run in two ways:
# 1. Via workflow dispatch.
# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`.
# For pull requests, the workflow will assume the pull request as the target to compare against (i.e. the `OSU_B` variable).
# Any lines in the comment of the form `KEY=VALUE` are treated as variables for the generator.
#
# ## Google Service Account
#
# Spreadsheets are uploaded to a Google Service Account, and exposed with read-only permissions to the wider audience.
#
# 1. Create a project at https://console.cloud.google.com
# 2. Enable the `Google Sheets` and `Google Drive` APIs.
# 3. Create a Service Account
# 4. Generate a key in the JSON format.
# 5. Encode the key as base64 and store as an **actions secret** with name **`DIFFCALC_GOOGLE_CREDENTIALS`**
#
# ## Environment variables
#
# The default environment may be configured via **actions variables**.
#
# Refer to [the sample environment](https://github.com/smoogipoo/diffcalc-sheet-generator/blob/master/.env.sample), and prefix each variable with `DIFFCALC_` (e.g. `DIFFCALC_THREADS`, `DIFFCALC_INNODB_BUFFER_SIZE`, etc...).
name: Run difficulty calculation comparison
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Manual run: {0}', inputs.osu-b) || 'Automatic comment trigger' }}"
name: Difficulty Calculation
on:
issue_comment:
types: [ created ]
workflow_dispatch:
inputs:
osu-b:
description: "The target build of ppy/osu"
type: string
required: true
ruleset:
description: "The ruleset to process"
type: choice
required: true
options:
- osu
- taiko
- catch
- mania
converts:
description: "Include converted beatmaps"
type: boolean
required: false
default: true
ranked-only:
description: "Only ranked beatmaps"
type: boolean
required: false
default: true
generators:
description: "Comma-separated list of generators (available: [sr, pp, score])"
type: string
required: false
default: 'pp,sr'
osu-a:
description: "The source build of ppy/osu"
type: string
required: false
default: 'latest'
difficulty-calculator-a:
description: "The source build of ppy/osu-difficulty-calculator"
type: string
required: false
default: 'latest'
difficulty-calculator-b:
description: "The target build of ppy/osu-difficulty-calculator"
type: string
required: false
default: 'latest'
score-processor-a:
description: "The source build of ppy/osu-queue-score-statistics"
type: string
required: false
default: 'latest'
score-processor-b:
description: "The target build of ppy/osu-queue-score-statistics"
type: string
required: false
default: 'latest'
permissions:
pull-requests: write
env:
CONCURRENCY: 4
ALLOW_DOWNLOAD: 1
SAVE_DOWNLOADED: 1
SKIP_INSERT_ATTRIBUTES: 1
COMMENT_TAG: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
jobs:
metadata:
name: Check for requests
wait-for-queue:
name: "Wait for previous workflows"
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }}
timeout-minutes: 50400 # 35 days, the maximum for jobs.
steps:
- uses: ahmadnassri/action-workflow-queue@v1
with:
timeout: 2147483647 # Around 24 days, maximum supported.
delay: 120000 # Poll every 2 minutes. API seems fairly low on this one.
create-comment:
name: Create PR comment
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER' }}
steps:
- name: Create comment
uses: thollander/actions-comment-pull-request@v2
with:
comment_tag: ${{ env.COMMENT_TAG }}
message: |
Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
*This comment will update on completion*
directory:
name: Prepare directory
needs: wait-for-queue
runs-on: self-hosted
if: github.event.issue.pull_request && contains(github.event.comment.body, '!pp check') && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER')
if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }}
outputs:
matrix: ${{ steps.generate-matrix.outputs.matrix }}
continue: ${{ steps.generate-matrix.outputs.continue }}
GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }}
GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }}
GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }}
steps:
- name: Construct build matrix
id: generate-matrix
- name: Checkout
uses: actions/checkout@v3
- name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v3
with:
path: 'diffcalc-sheet-generator'
repository: 'smoogipoo/diffcalc-sheet-generator'
- name: Set outputs
id: set-outputs
run: |
if [[ "${{ github.event.comment.body }}" =~ "osu" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "osu", "id": 0 },'
fi
if [[ "${{ github.event.comment.body }}" =~ "taiko" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "taiko", "id": 1 },'
fi
if [[ "${{ github.event.comment.body }}" =~ "catch" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "catch", "id": 2 },'
fi
if [[ "${{ github.event.comment.body }}" =~ "mania" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "mania", "id": 3 },'
fi
echo "GENERATOR_DIR=${{ github.workspace }}/diffcalc-sheet-generator" >> "${GITHUB_OUTPUT}"
echo "GENERATOR_ENV=${{ github.workspace }}/diffcalc-sheet-generator/.env" >> "${GITHUB_OUTPUT}"
echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/diffcalc-sheet-generator/google-credentials.json" >> "${GITHUB_OUTPUT}"
if [[ "${MATRIX_PROJECTS_JSON}" != "" ]]; then
MATRIX_JSON="{ \"ruleset\": [ ${MATRIX_PROJECTS_JSON} ] }"
echo "${MATRIX_JSON}"
CONTINUE="yes"
else
CONTINUE="no"
fi
echo "continue=${CONTINUE}" >> $GITHUB_OUTPUT
echo "matrix=${MATRIX_JSON}" >> $GITHUB_OUTPUT
diffcalc:
name: Run
environment:
name: Setup environment
needs: directory
runs-on: self-hosted
timeout-minutes: 1440
if: needs.metadata.outputs.continue == 'yes'
needs: metadata
strategy:
matrix: ${{ fromJson(needs.metadata.outputs.matrix) }}
if: ${{ !cancelled() && needs.directory.result == 'success' }}
env:
VARS_JSON: ${{ toJSON(vars) }}
steps:
- name: Verify MySQL connection from host
- name: Add base environment
run: |
mysql -e "SHOW DATABASES"
# Required by diffcalc-sheet-generator
cp '${{ github.workspace }}/diffcalc-sheet-generator/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Drop previous databases
run: |
for db in osu_master osu_pr
do
mysql -e "DROP DATABASE IF EXISTS $db"
# Add Google credentials
echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}"
# Add repository variables
echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do
opt=$(jq -r '.key' <<< ${line})
val=$(jq -r '.value' <<< ${line})
if [[ "${opt}" =~ ^DIFFCALC_ ]]; then
optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-)
sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
done
- name: Create directory structure
- name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: |
mkdir -p $GITHUB_WORKSPACE/master/
mkdir -p $GITHUB_WORKSPACE/pr/
sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Get upstream branch # https://akaimo.hatenablog.jp/entry/2020/05/16/101251
id: upstreambranch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Add comment environment
if: ${{ github.event_name == 'issue_comment' }}
run: |
echo "branchname=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" >> $GITHUB_OUTPUT
echo "repo=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" >> $GITHUB_OUTPUT
# Checkout osu
- name: Checkout osu (master)
uses: actions/checkout@v3
with:
path: 'master/osu'
- name: Checkout osu (pr)
uses: actions/checkout@v3
with:
path: 'pr/osu'
repository: ${{ steps.upstreambranch.outputs.repo }}
ref: ${{ steps.upstreambranch.outputs.branchname }}
- name: Checkout osu-difficulty-calculator (master)
uses: actions/checkout@v3
with:
repository: ppy/osu-difficulty-calculator
path: 'master/osu-difficulty-calculator'
- name: Checkout osu-difficulty-calculator (pr)
uses: actions/checkout@v3
with:
repository: ppy/osu-difficulty-calculator
path: 'pr/osu-difficulty-calculator'
- name: Install .NET 5.0.x
uses: actions/setup-dotnet@v3
with:
dotnet-version: "5.0.x"
# Sanity checks to make sure diffcalc is not run when incompatible.
- name: Build diffcalc (master)
run: |
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator
./UseLocalOsu.sh
dotnet build
- name: Build diffcalc (pr)
run: |
cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator
./UseLocalOsu.sh
dotnet build
- name: Download + import data
run: |
PERFORMANCE_DATA_NAME=$(curl https://data.ppy.sh/ | grep performance_${{ matrix.ruleset.name }}_top_1000 | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g')
BEATMAPS_DATA_NAME=$(curl https://data.ppy.sh/ | grep osu_files | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g')
# Set env variable for further steps.
echo "BEATMAPS_PATH=$GITHUB_WORKSPACE/$BEATMAPS_DATA_NAME" >> $GITHUB_ENV
cd $GITHUB_WORKSPACE
echo "Downloading database dump $PERFORMANCE_DATA_NAME.."
wget -q -nc https://data.ppy.sh/$PERFORMANCE_DATA_NAME.tar.bz2
echo "Extracting.."
tar -xf $PERFORMANCE_DATA_NAME.tar.bz2
echo "Downloading beatmap dump $BEATMAPS_DATA_NAME.."
wget -q -nc https://data.ppy.sh/$BEATMAPS_DATA_NAME.tar.bz2
echo "Extracting.."
tar -xf $BEATMAPS_DATA_NAME.tar.bz2
cd $PERFORMANCE_DATA_NAME
for db in osu_master osu_pr
do
echo "Setting up database $db.."
mysql -e "CREATE DATABASE $db"
echo "Importing beatmaps.."
cat osu_beatmaps.sql | mysql $db
echo "Importing beatmapsets.."
cat osu_beatmapsets.sql | mysql $db
echo "Creating table structure.."
mysql $db -e 'CREATE TABLE `osu_beatmap_difficulty` (
`beatmap_id` int unsigned NOT NULL,
`mode` tinyint NOT NULL DEFAULT 0,
`mods` int unsigned NOT NULL,
`diff_unified` float NOT NULL,
`last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`beatmap_id`,`mode`,`mods`),
KEY `diff_sort` (`mode`,`mods`,`diff_unified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;'
# Add comment environment
echo '${{ github.event.comment.body }}' | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do
opt=$(echo ${line} | cut -d '=' -f1)
sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
done
- name: Run diffcalc (master)
env:
DB_NAME: osu_master
- name: Add dispatch environment
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator/osu.Server.DifficultyCalculator
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }}
- name: Run diffcalc (pr)
env:
DB_NAME: osu_pr
run: |
cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator/osu.Server.DifficultyCalculator
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }}
sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Print diffs
run: |
mysql -e "
SELECT
m.beatmap_id,
m.mods,
b.filename,
m.diff_unified as 'sr_master',
p.diff_unified as 'sr_pr',
(p.diff_unified - m.diff_unified) as 'diff'
FROM osu_master.osu_beatmap_difficulty m
JOIN osu_pr.osu_beatmap_difficulty p
ON m.beatmap_id = p.beatmap_id
AND m.mode = p.mode
AND m.mods = p.mods
JOIN osu_pr.osu_beatmaps b
ON b.beatmap_id = p.beatmap_id
WHERE abs(m.diff_unified - p.diff_unified) > 0.1
ORDER BY abs(m.diff_unified - p.diff_unified)
DESC
LIMIT 10000;"
if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then
sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
# Todo: Run ppcalc
if [[ '${{ inputs.difficulty-calculator-a }}' != 'latest' ]]; then
sed -i 's;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${{ inputs.difficulty-calculator-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.difficulty-calculator-b }}' != 'latest' ]]; then
sed -i 's;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${{ inputs.difficulty-calculator-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.score-processor-a }}' != 'latest' ]]; then
sed -i 's;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${{ inputs.score-processor-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.score-processor-b }}' != 'latest' ]]; then
sed -i 's;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${{ inputs.score-processor-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.converts }}' == 'true' ]]; then
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
else
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.ranked-only }}' == 'true' ]]; then
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
else
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
scores:
name: Setup scores
needs: [ directory, environment ]
runs-on: self-hosted
if: ${{ !cancelled() && needs.environment.result == 'success' }}
steps:
- name: Query latest data
id: query
run: |
ruleset=$(cat ${{ needs.directory.outputs.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-)
performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@v1
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
beatmaps:
name: Setup beatmaps
needs: directory
runs-on: self-hosted
if: ${{ !cancelled() && needs.directory.result == 'success' }}
steps:
- name: Query latest data
id: query
run: |
beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@v1
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
generator:
name: Run generator
needs: [ directory, environment, scores, beatmaps ]
runs-on: self-hosted
timeout-minutes: 720
if: ${{ !cancelled() && needs.scores.result == 'success' && needs.beatmaps.result == 'success' }}
outputs:
TARGET: ${{ steps.run.outputs.TARGET }}
SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }}
steps:
- name: Run
id: run
run: |
# Add the GitHub token. This needs to be done here because it's unique per-job.
sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker-compose up --build generator
link=$(docker-compose logs generator -n 10 | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
target=$(cat "${{ needs.directory.outputs.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
echo "TARGET=${target}" >> "${GITHUB_OUTPUT}"
echo "SPREADSHEET_LINK=${link}" >> "${GITHUB_OUTPUT}"
- name: Shutdown
if: ${{ always() }}
run: |
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker-compose down
- name: Output info
if: ${{ success() }}
run: |
echo "Target: ${{ steps.run.outputs.TARGET }}"
echo "Spreadsheet: ${{ steps.run.outputs.SPREADSHEET_LINK }}"
update-comment:
name: Update PR comment
needs: [ create-comment, generator ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER' }}
steps:
- name: Update comment on success
if: ${{ needs.generator.result == 'success' }}
uses: thollander/actions-comment-pull-request@v2
with:
comment_tag: ${{ env.COMMENT_TAG }}
mode: upsert
create_if_not_exists: false
message: |
Target: ${{ needs.generator.outputs.TARGET }}
Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}
- name: Update comment on failure
if: ${{ needs.generator.result == 'failure' }}
uses: thollander/actions-comment-pull-request@v2
with:
comment_tag: ${{ env.COMMENT_TAG }}
mode: upsert
create_if_not_exists: false
message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.922.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.1006.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
@@ -41,7 +41,6 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
X = xPositionData?.X ?? 0,
NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0,
LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0,
LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y,
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1
}.Yield();
+1
View File
@@ -25,6 +25,7 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
@@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty
public override int Version => 20220701;
private readonly IWorkingBeatmap workingBeatmap;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@@ -49,15 +46,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
};
if (ComputeLegacyScoringValues)
{
CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -5,30 +5,26 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Catch.Difficulty
{
internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int standardisedBonusScore;
private int combo;
private double scoreMultiplier;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
@@ -70,13 +66,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
scoreMultiplier = difficultyPeppyStars;
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
return attributes;
}
private void simulateHit(HitObject hitObject)
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
@@ -112,31 +114,79 @@ namespace osu.Game.Rulesets.Catch.Difficulty
case JuiceStream:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
return;
case BananaShower:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
return;
}
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
attributes.AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
{
bool scoreV2 = mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in mods)
{
switch (mod)
{
case CatchModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case CatchModEasy:
multiplier *= 0.5;
break;
case CatchModHalfTime:
case CatchModDaycore:
multiplier *= 0.3;
break;
case CatchModHidden:
multiplier *= scoreV2 ? 1.0 : 1.06;
break;
case CatchModHardRock:
multiplier *= 1.12;
break;
case CatchModDoubleTime:
case CatchModNightcore:
multiplier *= 1.06;
break;
case CatchModFlashlight:
multiplier *= 1.12;
break;
case CatchModRelax:
return 0;
}
}
return multiplier;
}
}
}
@@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects
int nodeIndex = 0;
SliderEventDescriptor? lastEvent = null;
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken))
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken))
{
// generate tiny droplets since the last point
if (lastEvent != null)
@@ -104,8 +104,8 @@ namespace osu.Game.Rulesets.Catch.Objects
}
}
// this also includes LegacyLastTick and this is used for TinyDroplet generation above.
// this means that the final segment of TinyDroplets are increasingly mistimed where LegacyLastTickOffset is being applied.
// this also includes LastTick and this is used for TinyDroplet generation above.
// this means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied.
lastEvent = e;
switch (e.Type)
@@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects
public double Distance => Path.Distance;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
public double? LegacyLastTickOffset { get; set; }
}
}
@@ -15,7 +15,11 @@ namespace osu.Game.Rulesets.Catch.Skinning.Argon
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Both;
Anchor = Anchor.TopCentre;
Origin = Anchor.Centre;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChildren = new Drawable[]
{
@@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Catch.UI;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Default
{
@@ -22,6 +23,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
public DefaultCatcher()
{
Anchor = Anchor.TopCentre;
RelativeSizeAxes = Axes.Both;
InternalChild = sprite = new Sprite
{
@@ -32,6 +34,15 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
};
}
protected override void Update()
{
base.Update();
// matches stable's origin position since we're using the same catcher sprite.
// see LegacyCatcher for more information.
OriginPosition = new Vector2(DrawWidth / 2, 16f);
}
[BackgroundDependencyLoader]
private void load(TextureStore store, Bindable<CatcherAnimationState> currentState)
{
@@ -2,17 +2,21 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 banana_max_size = new Vector2(160);
protected override void LoadComplete()
{
base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-bananas");
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay");
Texture? texture = Skin.GetTexture("fruit-bananas")?.WithMaximumSize(banana_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay")?.WithMaximumSize(banana_max_size);
SetTexture(texture, overlayTexture);
}
@@ -0,0 +1,33 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public abstract partial class LegacyCatcher : CompositeDrawable
{
protected LegacyCatcher()
{
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
// in stable, catcher sprites are displayed in their raw size. stable also has catcher sprites displayed with the following scale factors applied:
// 1. 0.5x, affecting all sprites in the playfield, computed here based on lazer's catch playfield dimensions (see WIDTH/HEIGHT constants in CatchPlayfield),
// source: https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/GameplayElements/HitObjectManager.cs#L483-L494
// 2. 0.7x, a constant scale applied to all catcher sprites on construction.
AutoSizeAxes = Axes.Both;
Scale = new Vector2(0.5f * 0.7f);
}
protected override void Update()
{
base.Update();
// stable sets the Y origin position of the catcher to 16px in order for the catching range and OD scaling to align with the top of the catcher's plate in the default skin.
OriginPosition = new Vector2(DrawWidth / 2, 16f);
}
}
}
@@ -7,14 +7,12 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyCatcherNew : CompositeDrawable
public partial class LegacyCatcherNew : LegacyCatcher
{
[Resolved]
private Bindable<CatcherAnimationState> currentState { get; set; } = null!;
@@ -23,25 +21,12 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
private Drawable currentDrawable = null!;
public LegacyCatcherNew()
{
RelativeSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
foreach (var state in Enum.GetValues<CatcherAnimationState>())
{
AddInternal(drawables[state] = getDrawableFor(state).With(d =>
{
d.Anchor = Anchor.TopCentre;
d.Origin = Anchor.TopCentre;
d.RelativeSizeAxes = Axes.Both;
d.Size = Vector2.One;
d.FillMode = FillMode.Fit;
d.Alpha = 0;
}));
AddInternal(drawables[state] = getDrawableFor(state).With(d => d.Alpha = 0));
}
currentDrawable = drawables[CatcherAnimationState.Idle];
@@ -3,30 +3,21 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyCatcherOld : CompositeDrawable
public partial class LegacyCatcherOld : LegacyCatcher
{
public LegacyCatcherOld()
{
RelativeSizeAxes = Axes.Both;
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
InternalChild = (skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty()).With(d =>
{
d.Anchor = Anchor.TopCentre;
d.Origin = Anchor.TopCentre;
d.RelativeSizeAxes = Axes.Both;
d.Size = Vector2.One;
d.FillMode = FillMode.Fit;
});
InternalChild = skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty();
}
}
}
@@ -2,12 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 droplet_max_size = new Vector2(160);
public LegacyDropletPiece()
{
Scale = new Vector2(0.8f);
@@ -17,8 +20,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-drop");
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay");
Texture? texture = Skin.GetTexture("fruit-drop")?.WithMaximumSize(droplet_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay")?.WithMaximumSize(droplet_max_size);
SetTexture(texture, overlayTexture);
}
@@ -2,11 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 fruit_max_size = new Vector2(160);
protected override void LoadComplete()
{
base.LoadComplete();
@@ -22,21 +26,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
switch (visualRepresentation)
{
case FruitVisualRepresentation.Pear:
SetTexture(Skin.GetTexture("fruit-pear"), Skin.GetTexture("fruit-pear-overlay"));
setTextures("pear");
break;
case FruitVisualRepresentation.Grape:
SetTexture(Skin.GetTexture("fruit-grapes"), Skin.GetTexture("fruit-grapes-overlay"));
setTextures("grapes");
break;
case FruitVisualRepresentation.Pineapple:
SetTexture(Skin.GetTexture("fruit-apple"), Skin.GetTexture("fruit-apple-overlay"));
setTextures("apple");
break;
case FruitVisualRepresentation.Raspberry:
SetTexture(Skin.GetTexture("fruit-orange"), Skin.GetTexture("fruit-orange-overlay"));
setTextures("orange");
break;
}
void setTextures(string fruitName) => SetTexture(
Skin.GetTexture($"fruit-{fruitName}")?.WithMaximumSize(fruit_max_size),
Skin.GetTexture($"fruit-{fruitName}-overlay")?.WithMaximumSize(fruit_max_size)
);
}
}
}
@@ -17,12 +17,13 @@ namespace osu.Game.Rulesets.Catch.UI
public CatchPlayfieldAdjustmentContainer()
{
// because we are using centre anchor/origin, we will need to limit visibility in the future
// to ensure tall windows do not get a readability advantage.
// it may be possible to bake the catch-specific offsets (-100..340 mentioned below) into new values
// which are compatible with TopCentre alignment.
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
// playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
// we can match that in lazer by using relative coordinates for Y and considering window height to be 1, and playfield height to be 0.8.
RelativePositionAxes = Axes.Y;
Y = (1 - playfield_size_adjust) / 4 * 3;
Size = new Vector2(playfield_size_adjust);
@@ -42,18 +43,28 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
private partial class ScalingContainer : Container
{
public ScalingContainer()
{
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
}
protected override void Update()
{
base.Update();
// in stable, fruit fall vertically from -100 to 340.
// to emulate this, we want to make our playfield 440 gameplay pixels high.
// we then offset it -100 vertically in the position set below.
const float stable_v_offset_ratio = 440 / 384f;
// in stable, fruit fall vertically from 100 pixels above the playfield top down to the catcher's Y position (i.e. -100 to 340),
// see: https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/GameplayElements/HitObjects/Fruits/HitCircleFruits.cs#L65
// we already have the playfield positioned similar to stable (see CatchPlayfieldAdjustmentContainer constructor),
// so we only need to increase this container's height 100 pixels above the playfield, and offset it to have the bottom at 340 rather than 384.
const float stable_fruit_start_position = -100;
const float stable_catcher_y_position = 340;
const float playfield_v_size_adjustment = (stable_catcher_y_position - stable_fruit_start_position) / CatchPlayfield.HEIGHT;
const float playfield_v_catcher_offset = stable_catcher_y_position - CatchPlayfield.HEIGHT;
Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.WIDTH);
Position = new Vector2(0, -100 * stable_v_offset_ratio + Scale.X);
Size = Vector2.Divide(new Vector2(1, stable_v_offset_ratio), Scale);
Scale = new Vector2(Parent!.ChildSize.X / CatchPlayfield.WIDTH);
Position = new Vector2(0f, playfield_v_catcher_offset * Scale.Y);
Size = Vector2.Divide(new Vector2(1, playfield_v_size_adjustment), Scale);
}
}
}
+7
View File
@@ -29,6 +29,13 @@ namespace osu.Game.Rulesets.Catch.UI
/// <summary>
/// The size of the catcher at 1x scale.
/// </summary>
/// <remarks>
/// This is mainly used to compute catching range, the actual catcher size may differ based on skin implementation and sprite textures.
/// This is also equivalent to the "catcherWidth" property in osu-stable when the game field and beatmap difficulty are set to default values.
/// </remarks>
/// <seealso cref="CatchPlayfield.WIDTH"/>
/// <seealso cref="CatchPlayfield.HEIGHT"/>
/// <seealso cref="IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY"/>
public const float BASE_SIZE = 106.75f;
/// <summary>
@@ -6,7 +6,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.UI
{
@@ -26,8 +25,8 @@ namespace osu.Game.Rulesets.Catch.UI
: base(new CatchSkinComponentLookup(CatchSkinComponents.Catcher), _ => new DefaultCatcher())
{
Anchor = Anchor.TopCentre;
// Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling.
OriginPosition = new Vector2(0.5f, 0.06f) * Catcher.BASE_SIZE;
Origin = Anchor.TopCentre;
CentreComponent = false;
}
}
}
@@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Utils;
using osuTK;
@@ -43,39 +44,41 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
: base(beatmap, ruleset)
{
IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo);
TargetColumns = GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap));
double roundedCircleSize = Math.Round(beatmap.Difficulty.CircleSize);
double roundedOverallDifficulty = Math.Round(beatmap.Difficulty.OverallDifficulty);
if (IsForCurrentRuleset)
if (IsForCurrentRuleset && TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
{
TargetColumns = GetColumnCountForNonConvert(beatmap.BeatmapInfo);
if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
{
TargetColumns /= 2;
Dual = true;
}
}
else
{
float percentSliderOrSpinner = (float)beatmap.HitObjects.Count(h => h is IHasDuration) / beatmap.HitObjects.Count;
if (percentSliderOrSpinner < 0.2)
TargetColumns = 7;
else if (percentSliderOrSpinner < 0.3 || roundedCircleSize >= 5)
TargetColumns = roundedOverallDifficulty > 5 ? 7 : 6;
else if (percentSliderOrSpinner > 0.6)
TargetColumns = roundedOverallDifficulty > 4 ? 5 : 4;
else
TargetColumns = Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7));
TargetColumns /= 2;
Dual = true;
}
originalTargetColumns = TargetColumns;
}
public static int GetColumnCountForNonConvert(BeatmapInfo beatmapInfo)
public static int GetColumnCount(LegacyBeatmapConversionDifficultyInfo difficulty)
{
double roundedCircleSize = Math.Round(beatmapInfo.Difficulty.CircleSize);
if (new ManiaRuleset().RulesetInfo.Equals(difficulty.SourceRuleset))
return GetColumnCountForNonConvert(difficulty);
double roundedCircleSize = Math.Round(difficulty.CircleSize);
double roundedOverallDifficulty = Math.Round(difficulty.OverallDifficulty);
int countSliderOrSpinner = difficulty.TotalObjectCount - difficulty.CircleCount;
float percentSpecialObjects = (float)countSliderOrSpinner / difficulty.TotalObjectCount;
if (percentSpecialObjects < 0.2)
return 7;
if (percentSpecialObjects < 0.3 || roundedCircleSize >= 5)
return roundedOverallDifficulty > 5 ? 7 : 6;
if (percentSpecialObjects > 0.6)
return roundedOverallDifficulty > 4 ? 5 : 4;
return Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7));
}
public static int GetColumnCountForNonConvert(IBeatmapDifficultyInfo difficulty)
{
double roundedCircleSize = Math.Round(difficulty.CircleSize);
return (int)Math.Max(1, roundedCircleSize);
}
@@ -31,13 +31,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
}
@@ -60,15 +56,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
if (ComputeLegacyScoringValues)
{
ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -4,25 +4,63 @@
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Mania.Difficulty
{
internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore => 0;
public int ComboScore { get; private set; }
public double BonusScoreRatio => 0;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn))
.Select(m => m.ScoreMultiplier)
.Aggregate(1.0, (c, n) => c * n);
return new LegacyScoreAttributes { ComboScore = 1000000 };
}
ComboScore = (int)(1000000 * multiplier);
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
{
bool scoreV2 = mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in mods)
{
switch (mod)
{
case ManiaModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case ManiaModEasy:
multiplier *= 0.5;
break;
case ManiaModHalfTime:
case ManiaModDaycore:
multiplier *= 0.5;
break;
}
}
if (new ManiaRuleset().RulesetInfo.Equals(difficulty.SourceRuleset))
return multiplier;
// Apply key mod multipliers.
int originalColumns = ManiaBeatmapConverter.GetColumnCount(difficulty);
int actualColumns = originalColumns;
actualColumns = mods.OfType<ManiaKeyMod>().SingleOrDefault()?.KeyCount ?? actualColumns;
if (mods.Any(m => m is ManiaModDualStages))
actualColumns *= 2;
if (actualColumns > originalColumns)
multiplier *= 0.9;
else if (actualColumns < originalColumns)
multiplier *= 0.9 - 0.04 * (originalColumns - actualColumns);
return multiplier;
}
}
}
@@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Edit
{
public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset, ISupportConstantAlgorithmToggle
{
public BindableBool ShowSpeedChanges { get; set; } = new BindableBool();
public BindableBool ShowSpeedChanges { get; } = new BindableBool();
public new IScrollingInfo ScrollingInfo => base.ScrollingInfo;
@@ -8,6 +8,8 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
@@ -23,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Edit
/// <summary>
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
/// </summary>
public partial class ManiaBeatSnapGrid : Component
public partial class ManiaBeatSnapGrid : CompositeComponent
{
private const double visible_range = 750;
@@ -53,6 +55,8 @@ namespace osu.Game.Rulesets.Mania.Edit
private readonly List<ScrollingHitObjectContainer> grids = new List<ScrollingHitObjectContainer>();
private readonly DrawablePool<DrawableGridLine> linesPool = new DrawablePool<DrawableGridLine>(50);
private readonly Cached lineCache = new Cached();
private (double start, double end)? selectionTimeRange;
@@ -60,6 +64,8 @@ namespace osu.Game.Rulesets.Mania.Edit
[BackgroundDependencyLoader]
private void load(HitObjectComposer composer)
{
AddInternal(linesPool);
foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages)
{
foreach (var column in stage.Columns)
@@ -85,17 +91,10 @@ namespace osu.Game.Rulesets.Mania.Edit
}
}
private readonly Stack<DrawableGridLine> availableLines = new Stack<DrawableGridLine>();
private void createLines()
{
foreach (var grid in grids)
{
foreach (var line in grid.Objects.OfType<DrawableGridLine>())
availableLines.Push(line);
grid.Clear();
}
if (selectionTimeRange == null)
return;
@@ -131,10 +130,13 @@ namespace osu.Game.Rulesets.Mania.Edit
foreach (var grid in grids)
{
if (!availableLines.TryPop(out var line))
line = new DrawableGridLine();
var line = linesPool.Get();
line.Apply(new HitObject
{
StartTime = time
});
line.HitObject.StartTime = time;
line.Colour = colour;
grid.Add(line);
@@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania
public bool Matches(BeatmapInfo beatmapInfo)
{
return !keys.HasFilter || (beatmapInfo.Ruleset.OnlineID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmapInfo)));
return !keys.HasFilter || (beatmapInfo.Ruleset.OnlineID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmapInfo.Difficulty)));
}
public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)
+1
View File
@@ -33,6 +33,7 @@ using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
@@ -41,6 +41,7 @@ namespace osu.Game.Rulesets.Mania
},
new SettingsCheckbox
{
Keywords = new[] { "color" },
LabelText = RulesetSettingsStrings.TimingBasedColouring,
Current = config.GetBindable<bool>(ManiaRulesetSetting.TimingBasedNoteColouring),
}
@@ -4,6 +4,7 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Mania.Objects.Drawables;
@@ -25,33 +26,42 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
{
RelativeSizeAxes = Axes.Both;
// Avoid flickering due to no anti-aliasing of boxes by default.
var edgeSmoothness = new Vector2(0.3f);
AddInternal(mainLine = new Box
{
Name = "Bar line",
EdgeSmoothness = edgeSmoothness,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
});
Vector2 size = new Vector2(22, 6);
const float line_offset = 4;
const float major_extension = 10;
AddInternal(leftAnchor = new Circle
AddInternal(leftAnchor = new Box
{
Name = "Left anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreRight,
Size = size,
X = -line_offset,
Width = major_extension,
RelativeSizeAxes = Axes.Y,
Colour = ColourInfo.GradientHorizontal(Colour4.Transparent, Colour4.White),
});
AddInternal(rightAnchor = new Circle
AddInternal(rightAnchor = new Box
{
Name = "Right anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreLeft,
Size = size,
X = line_offset,
Width = major_extension,
RelativeSizeAxes = Axes.Y,
Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.Transparent),
});
major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy();
@@ -66,7 +76,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
private void updateMajor(ValueChangedEvent<bool> major)
{
mainLine.Alpha = major.NewValue ? 0.5f : 0.2f;
leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? 1 : 0;
leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0;
}
}
}
@@ -163,7 +163,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
slider = new Slider
{
Position = new Vector2(0, 50),
LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point
Path = new SliderPath(new[]
{
new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -43,7 +43,8 @@ namespace osu.Game.Rulesets.Osu.Tests
AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8);
AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8);
PausableSkinnableSound getSpinningSample() => drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
PausableSkinnableSound getSpinningSample() =>
drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
}
[TestCase(false)]
@@ -64,6 +65,39 @@ namespace osu.Game.Rulesets.Osu.Tests
AddUntilStep("Short spinner implicitly completes", () => drawableSpinner.Progress == 1);
}
[TestCase(0, 4, 6)]
[TestCase(5, 7, 10)]
[TestCase(10, 11, 8)]
public void TestSpinnerSpinRequirements(int od, int normalTicks, int bonusTicks)
{
Spinner spinner = null;
AddStep("add spinner", () => SetContents(_ =>
{
spinner = new Spinner
{
StartTime = Time.Current,
EndTime = Time.Current + 3000,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL)
}
};
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od });
return drawableSpinner = new TestDrawableSpinner(spinner, true)
{
Anchor = Anchor.Centre,
Depth = depthIndex++,
Scale = new Vector2(0.75f)
};
}));
AddAssert("number of normal ticks matches", () => spinner.SpinsRequired, () => Is.EqualTo(normalTicks));
AddAssert("number of bonus ticks matches", () => spinner.MaximumBonusSpins, () => Is.EqualTo(bonusTicks));
}
private Drawable testSingle(float circleSize, bool auto = false, double length = 3000)
{
const double delay = 2000;
@@ -44,7 +44,6 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
Position = positionData?.Position ?? Vector2.Zero,
NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0,
LegacyLastTickOffset = (original as IHasLegacyLastTickOffset)?.LegacyLastTickOffset,
// prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
// this results in more (or less) ticks being generated in <v8 maps for the same time duration.
TickDistanceMultiplier = beatmap.BeatmapInfo.BeatmapVersion < 8 ? 1f / ((LegacyControlPointInfo)beatmap.ControlPointInfo).DifficultyPointAt(original.StartTime).SliderVelocity : 1,
@@ -26,12 +26,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@@ -109,15 +106,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpinnerCount = spinnerCount,
};
if (ComputeLegacyScoringValues)
{
OsuLegacyScoreSimulator sv1Simulator = new OsuLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -9,30 +9,23 @@ using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Osu.Difficulty
{
internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int standardisedBonusScore;
private int combo;
private double scoreMultiplier;
private IBeatmap playableBeatmap = null!;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
this.playableBeatmap = playableBeatmap;
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
@@ -73,13 +66,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
scoreMultiplier = difficultyPeppyStars;
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
return attributes;
}
private void simulateHit(HitObject hitObject)
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
@@ -122,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
case Slider:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
scoreIncrease = 300;
increaseCombo = false;
@@ -133,22 +132,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
// We'll redo the calculations to match osu-stable here...
const double maximum_rotations_per_second = 477.0 / 60;
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score.
// As we're primarily concerned with computing the maximum theoretical final score,
// this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1.
const double minimum_rotations_per_second = 3;
double secondsDuration = spinner.Duration / 1000;
// The total amount of half spins possible for the entire spinner.
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second);
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
for (int i = 0; i <= totalHalfSpinsPossible; i++)
{
if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0)
simulateHit(new SpinnerBonusTick());
simulateHit(new SpinnerBonusTick(), ref attributes);
else if (i > 1 && i % 2 == 0)
simulateHit(new SpinnerTick());
simulateHit(new SpinnerTick(), ref attributes);
}
scoreIncrease = 300;
@@ -159,19 +163,72 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
attributes.AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
{
bool scoreV2 = mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in mods)
{
switch (mod)
{
case OsuModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case OsuModEasy:
multiplier *= 0.5;
break;
case OsuModHalfTime:
case OsuModDaycore:
multiplier *= 0.3;
break;
case OsuModHidden:
multiplier *= 1.06;
break;
case OsuModHardRock:
multiplier *= scoreV2 ? 1.10 : 1.06;
break;
case OsuModDoubleTime:
case OsuModNightcore:
multiplier *= scoreV2 ? 1.20 : 1.12;
break;
case OsuModFlashlight:
multiplier *= 1.12;
break;
case OsuModSpunOut:
multiplier *= 0.9;
break;
case OsuModRelax:
case OsuModAutopilot:
return 0;
}
}
return multiplier;
}
}
}
@@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
{
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
InternalChild = content = new Container
{
@@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
{
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
CornerRadius = Size.X / 2;
CornerExponent = 2;
@@ -315,7 +315,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
StartTime = HitObject.StartTime,
Position = HitObject.Position + splitControlPoints[0].Position,
NewCombo = HitObject.NewCombo,
LegacyLastTickOffset = HitObject.LegacyLastTickOffset,
Samples = HitObject.Samples.Select(s => s.With()).ToList(),
RepeatCount = HitObject.RepeatCount,
NodeSamples = HitObject.NodeSamples.Select(n => (IList<HitSampleInfo>)n.Select(s => s.With()).ToList()).ToList(),
+4 -2
View File
@@ -61,10 +61,12 @@ namespace osu.Game.Rulesets.Osu.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{
OsuHitObject firstObject = drawableRuleset.Beatmap.HitObjects.First();
// Multiplying by 2 results in an initial size that is too large, hence 1.90 has been chosen
// Also avoids the HitObject bleeding around the edges of the bubble drawable at minimum size
bubbleSize = (float)(drawableRuleset.Beatmap.HitObjects.OfType<HitCircle>().First().Radius * 1.90f);
bubbleFade = drawableRuleset.Beatmap.HitObjects.OfType<HitCircle>().First().TimePreempt * 2;
bubbleSize = (float)firstObject.Radius * 1.90f;
bubbleFade = firstObject.TimePreempt * 2;
// We want to hide the judgements since they are obscured by the BubbleDrawable (due to layering)
drawableRuleset.Playfield.DisplayJudgements.Value = false;
@@ -96,14 +96,13 @@ namespace osu.Game.Rulesets.Osu.Mods
Position = original.Position;
NewCombo = original.NewCombo;
ComboOffset = original.ComboOffset;
LegacyLastTickOffset = original.LegacyLastTickOffset;
TickDistanceMultiplier = original.TickDistanceMultiplier;
SliderVelocityMultiplier = original.SliderVelocityMultiplier;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken);
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken);
foreach (var e in sliderEvents)
{
@@ -130,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods
});
break;
case SliderEventType.LegacyLastTick:
case SliderEventType.LastTick:
AddNested(TailCircle = new StrictTrackingSliderTailCircle(this)
{
RepeatIndex = e.SpanIndex,
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Mods
d.HitObjectApplied += _ =>
{
// slider tails are a painful edge case, as their start time is offset 36ms back (see `LegacyLastTick`).
// slider tails are a painful edge case, as their start time is offset 36ms back (see `LastTick`).
// to work around this, look up the slider tail's parenting slider's end time instead to ensure proper snap.
double snapTime = d is DrawableSliderTail tail
? tail.Slider.GetEndTime()
@@ -4,6 +4,7 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Allocation;
@@ -34,6 +35,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public HitReceptor HitArea { get; private set; }
public SkinnableDrawable CirclePiece { get; private set; }
protected override IEnumerable<Drawable> DimmablePieces => new[]
{
CirclePiece,
};
Drawable IHasApproachCircle.ApproachCircle => ApproachCircle;
private Container scaleContainer;
@@ -191,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
CirclePiece.FadeInFromZero(HitObject.TimeFadeIn);
ApproachCircle.FadeIn(Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt));
ApproachCircle.FadeTo(0.9f, Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt));
ApproachCircle.ScaleTo(1f, HitObject.TimePreempt);
ApproachCircle.Expire(true);
}
@@ -244,7 +250,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public HitReceptor()
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
@@ -4,6 +4,8 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -71,20 +73,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
ScaleBindable.UnbindFrom(HitObject.ScaleBindable);
}
protected virtual IEnumerable<Drawable> DimmablePieces => Enumerable.Empty<Drawable>();
protected override void UpdateInitialTransforms()
{
base.UpdateInitialTransforms();
// Dim should only be applied at a top level, as it will be implicitly applied to nested objects.
if (ParentHitObject == null)
foreach (var piece in DimmablePieces)
{
// Of note, no one noticed this was missing for years, but it definitely feels like it should still exist.
// For now this is applied across all skins, and matches stable.
// For simplicity, dim colour is applied to the DrawableHitObject itself.
// We may need to make a nested container setup if this even causes a usage conflict (ie. with a mod).
this.FadeColour(new Color4(195, 195, 195, 255));
using (BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
this.FadeColour(Color4.White, 100);
piece.FadeColour(new Color4(195, 195, 195, 255));
using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
piece.FadeColour(Color4.White, 100);
}
}
@@ -4,6 +4,7 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
@@ -35,6 +36,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private ShakeContainer shakeContainer;
protected override IEnumerable<Drawable> DimmablePieces => new Drawable[]
{
HeadCircle,
TailCircle,
Body,
};
/// <summary>
/// A target container which can be used to add top level elements to the slider's display.
/// Intended to be used for proxy purposes only.
@@ -288,7 +296,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public override void PlaySamples()
{
// rather than doing it this way, we should probably attach the sample to the tail circle.
// this can only be done after we stop using LegacyLastTick.
// this can only be done if we stop using LastTick.
if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit)
base.PlaySamples();
}
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Children = new[]
{
@@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public SkinnableDrawable CirclePiece { get; private set; }
public ReverseArrowPiece Arrow { get; private set; }
public SkinnableDrawable Arrow { get; private set; }
private Drawable scaleContainer;
@@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private void load()
{
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
AddInternal(scaleContainer = new Container
{
@@ -65,7 +65,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
Arrow = new ReverseArrowPiece(),
Arrow = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new DefaultReverseArrow())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
}
});
@@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private void load()
{
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
AddRangeInternal(new Drawable[]
{
@@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
[BackgroundDependencyLoader]
private void load()
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Origin = Anchor.Centre;
AddInternal(scaleContainer = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer
@@ -21,6 +21,11 @@ namespace osu.Game.Rulesets.Osu.Objects
/// </summary>
public const float OBJECT_RADIUS = 64;
/// <summary>
/// The width and height any element participating in display of a hitcircle (or similarly sized object) should be.
/// </summary>
public static readonly Vector2 OBJECT_DIMENSIONS = new Vector2(OBJECT_RADIUS * 2);
/// <summary>
/// Scoring distance with a speed-adjusted beat length of 1 second (ie. the speed slider balls move through their track).
/// </summary>
+9 -8
View File
@@ -71,8 +71,6 @@ namespace osu.Game.Rulesets.Osu.Objects
}
}
public double? LegacyLastTickOffset { get; set; }
/// <summary>
/// The position of the cursor at the point of completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
@@ -179,7 +177,7 @@ namespace osu.Game.Rulesets.Osu.Objects
{
base.CreateNestedHitObjects(cancellationToken);
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken);
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken);
foreach (var e in sliderEvents)
{
@@ -206,10 +204,11 @@ namespace osu.Game.Rulesets.Osu.Objects
});
break;
case SliderEventType.LegacyLastTick:
// we need to use the LegacyLastTick here for compatibility reasons (difficulty).
// it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
// if this is to change, we should revisit this.
case SliderEventType.LastTick:
// Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle.
// It is required as difficulty calculation and gameplay relies on reading this value.
// (although it is displayed in classic skins, which may be a concern).
// If this is to change, we should revisit this.
AddNested(TailCircle = new SliderTailCircle(this)
{
RepeatIndex = e.SpanIndex,
@@ -264,7 +263,9 @@ namespace osu.Game.Rulesets.Osu.Objects
if (HeadCircle != null)
HeadCircle.Samples = this.GetNodeSamples(0);
// The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to.
// The samples should be attached to the slider tail, however this can only be done if LastTick is removed otherwise they would play earlier than they're intended to.
// (see mapping logic in `CreateNestedHitObjects` above)
//
// For now, the samples are played by the slider itself at the correct end time.
TailSamples = this.GetNodeSamples(repeatCount + 1);
}
@@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Objects
{
/// <summary>
/// Note that this should not be used for timing correctness.
/// See <see cref="SliderEventType.LegacyLastTick"/> usage in <see cref="Slider"/> for more information.
/// See <see cref="SliderEventType.LastTick"/> usage in <see cref="Slider"/> for more information.
/// </summary>
public class SliderTailCircle : SliderEndCircle
{
+20 -4
View File
@@ -18,6 +18,16 @@ namespace osu.Game.Rulesets.Osu.Objects
{
public class Spinner : OsuHitObject, IHasDuration
{
/// <summary>
/// The RPM required to clear the spinner at ODs [ 0, 5, 10 ].
/// </summary>
private static readonly (int min, int mid, int max) clear_rpm_range = (90, 150, 225);
/// <summary>
/// The RPM required to complete the spinner and receive full score at ODs [ 0, 5, 10 ].
/// </summary>
private static readonly (int min, int mid, int max) complete_rpm_range = (250, 380, 430);
public double EndTime
{
get => StartTime + Duration;
@@ -52,13 +62,19 @@ namespace osu.Game.Rulesets.Osu.Objects
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
const double maximum_rotations_per_second = 477f / 60f;
// The average RPS required over the length of the spinner to clear the spinner.
double minRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, clear_rpm_range) / 60;
// The RPS required over the length of the spinner to receive full score (all normal + bonus ticks).
double maxRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, complete_rpm_range) / 60;
double secondsDuration = Duration / 1000;
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, 1.5, 2.5, 3.75);
SpinsRequired = (int)(secondsDuration * minimumRotationsPerSecond);
MaximumBonusSpins = (int)((maximum_rotations_per_second - minimumRotationsPerSecond) * secondsDuration) - bonus_spins_gap;
// Allow a 0.1ms floating point precision error in the calculation of the duration.
const double duration_error = 0.0001;
SpinsRequired = (int)(minRps * secondsDuration + duration_error);
MaximumBonusSpins = Math.Max(0, (int)(maxRps * secondsDuration + duration_error) - SpinsRequired - bonus_spins_gap);
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
+1
View File
@@ -33,6 +33,7 @@ using osu.Game.Rulesets.Osu.Statistics;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
@@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
private Bindable<bool> configHitLighting = null!;
private static readonly Vector2 circle_size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
private static readonly Vector2 circle_size = OsuHitObject.OBJECT_DIMENSIONS;
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
@@ -4,10 +4,12 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
@@ -17,38 +19,92 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
public partial class ArgonReverseArrow : CompositeDrawable
{
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
private Bindable<Color4> accentColour = null!;
private SpriteIcon icon = null!;
private Container main = null!;
private Sprite side = null!;
[BackgroundDependencyLoader]
private void load(DrawableHitObject hitObject)
private void load(TextureStore textures)
{
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
InternalChildren = new Drawable[]
{
new Circle
main = new Container
{
Size = new Vector2(40, 20),
Colour = Color4.White,
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new Drawable[]
{
new Circle
{
Size = new Vector2(40, 20),
Colour = Color4.White,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
icon = new SpriteIcon
{
Icon = FontAwesome.Solid.AngleDoubleRight,
Size = new Vector2(16),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
}
},
icon = new SpriteIcon
side = new Sprite
{
Icon = FontAwesome.Solid.AngleDoubleRight,
Size = new Vector2(16),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
Texture = textures.Get("Gameplay/osu/repeat-edge-piece"),
Size = new Vector2(ArgonMainCirclePiece.OUTER_GRADIENT_SIZE),
}
};
accentColour = hitObject.AccentColour.GetBoundCopy();
accentColour = drawableObject.AccentColour.GetBoundCopy();
accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true);
drawableObject.ApplyCustomUpdateState += updateStateTransforms;
}
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
{
const float move_distance = -12;
const double move_out_duration = 35;
const double move_in_duration = 250;
const double total = 300;
switch (state)
{
case ArmedState.Idle:
main.ScaleTo(1.3f, move_out_duration, Easing.Out)
.Then()
.ScaleTo(1f, move_in_duration, Easing.Out)
.Loop(total - (move_in_duration + move_out_duration));
side
.MoveToX(move_distance, move_out_duration, Easing.Out)
.Then()
.MoveToX(0, move_in_duration, Easing.Out)
.Loop(total - (move_in_duration + move_out_duration));
break;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}
@@ -9,7 +9,6 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
@@ -22,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
public CirclePiece()
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Masking = true;
CornerRadius = Size.X / 2;
@@ -0,0 +1,69 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public partial class DefaultReverseArrow : CompositeDrawable
{
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
public DefaultReverseArrow()
{
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Size = OsuHitObject.OBJECT_DIMENSIONS;
InternalChild = new SpriteIcon
{
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
Icon = FontAwesome.Solid.ChevronRight,
Size = new Vector2(0.35f),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
}
[BackgroundDependencyLoader]
private void load()
{
drawableObject.ApplyCustomUpdateState += updateStateTransforms;
}
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
{
const double move_out_duration = 35;
const double move_in_duration = 250;
const double total = 300;
switch (state)
{
case ArmedState.Idle:
InternalChild.ScaleTo(1.3f, move_out_duration, Easing.Out)
.Then()
.ScaleTo(1f, move_in_duration, Easing.Out)
.Loop(total - (move_in_duration + move_out_duration));
break;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}
@@ -7,7 +7,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
@@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
public ExplodePiece()
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
@@ -5,7 +5,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
@@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public FlashPiece()
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
@@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Default
@@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
public MainCirclePiece()
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
@@ -1,51 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public partial class ReverseArrowPiece : BeatSyncedContainer
{
[Resolved]
private DrawableHitObject drawableRepeat { get; set; } = null!;
public ReverseArrowPiece()
{
Divisor = 2;
MinimumBeatLength = 200;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Child = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon
{
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
Icon = FontAwesome.Solid.ChevronRight,
Size = new Vector2(0.35f)
})
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
}
protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
if (!drawableRepeat.IsHit)
Child.ScaleTo(1.3f).ScaleTo(1f, timingPoint.BeatLength, Easing.Out);
}
}
}
@@ -5,7 +5,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Default
@@ -14,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public RingPiece(float thickness = 9)
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
@@ -5,12 +5,14 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
// todo: this should probably not be a SkinnableSprite, as this is always created for legacy skins and is recreated on skin change.
public partial class LegacyApproachCircle : SkinnableSprite
{
private readonly IBindable<Color4> accentColour = new Bindable<Color4>();
@@ -19,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private DrawableHitObject drawableObject { get; set; } = null!;
public LegacyApproachCircle()
: base("Gameplay/osu/approachcircle")
: base("Gameplay/osu/approachcircle", OsuHitObject.OBJECT_DIMENSIONS * 2)
{
}
@@ -14,7 +14,6 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
@@ -51,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
this.priorityLookupPrefix = priorityLookupPrefix;
this.hasNumber = hasNumber;
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
Size = OsuHitObject.OBJECT_DIMENSIONS;
}
[BackgroundDependencyLoader]
@@ -68,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
// expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png.
InternalChildren = new[]
{
CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) })
CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS * 2) })
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -77,7 +76,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d))
Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d, maxSize: OsuHitObject.OBJECT_DIMENSIONS * 2))
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -4,9 +4,11 @@
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning;
using osuTK.Graphics;
@@ -15,8 +17,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
public partial class LegacyReverseArrow : CompositeDrawable
{
[Resolved(canBeNull: true)]
private DrawableHitObject? drawableHitObject { get; set; }
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
private Drawable proxy = null!;
@@ -26,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private Drawable arrow = null!;
private bool shouldRotate;
[BackgroundDependencyLoader]
private void load(ISkinSource skinSource)
{
@@ -35,8 +39,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null);
InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true) ?? Empty());
InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true, maxSize: OsuHitObject.OBJECT_DIMENSIONS * 2) ?? Empty()).With(d =>
{
d.Anchor = Anchor.Centre;
d.Origin = Anchor.Centre;
});
textureIsDefaultSkin = skin is ISkinTransformer transformer && transformer.Skin is DefaultLegacySkin;
drawableObject.ApplyCustomUpdateState += updateStateTransforms;
shouldRotate = skinSource.GetConfig<SkinConfiguration.LegacySetting, decimal>(SkinConfiguration.LegacySetting.Version)?.Value <= 1;
}
protected override void LoadComplete()
@@ -45,17 +58,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
proxy = CreateProxy();
if (drawableHitObject != null)
{
drawableHitObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(drawableHitObject);
drawableObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(drawableObject);
accentColour = drawableHitObject.AccentColour.GetBoundCopy();
accentColour.BindValueChanged(c =>
{
arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White;
}, true);
}
accentColour = drawableObject.AccentColour.GetBoundCopy();
accentColour.BindValueChanged(c =>
{
arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White;
}, true);
}
private void onHitObjectApplied(DrawableHitObject drawableObject)
@@ -67,11 +77,43 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
.OverlayElementContainer.Add(proxy);
}
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
{
const double duration = 300;
const float rotation = 5.625f;
switch (state)
{
case ArmedState.Idle:
if (shouldRotate)
{
InternalChild.ScaleTo(1.3f)
.RotateTo(rotation)
.Then()
.ScaleTo(1f, duration)
.RotateTo(-rotation, duration)
.Loop();
}
else
{
InternalChild.ScaleTo(1.3f).Then()
.ScaleTo(1f, duration, Easing.Out)
.Loop();
}
break;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableHitObject != null)
drawableHitObject.HitObjectApplied -= onHitObjectApplied;
if (drawableObject.IsNotNull())
{
drawableObject.HitObjectApplied -= onHitObjectApplied;
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}
}
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-nd"),
Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
Colour = new Color4(5, 5, 5, 255),
},
LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d =>
@@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-spec"),
Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
Blending = BlendingParameters.Additive,
},
};
@@ -4,6 +4,7 @@
using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Skinning;
using osuTK;
@@ -20,7 +21,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
/// Their hittable area is 128px, but the actual circle portion is 118px.
/// We must account for some gameplay elements such as slider bodies, where this padding is not present.
/// </summary>
public const float LEGACY_CIRCLE_RADIUS = 64 - 5;
public const float LEGACY_CIRCLE_RADIUS = OsuHitObject.OBJECT_RADIUS - 5;
/// <summary>
/// The maximum allowed size of sprites that reside in the follow circle area of a slider.
/// </summary>
/// <remarks>
/// The reason this is extracted out to a constant, rather than be inlined in the follow circle sprite retrieval,
/// is that some skins will use `sliderb` elements to emulate a slider follow circle with slightly different visual effects applied
/// (`sliderb` is always shown and doesn't pulsate; `sliderfollowcircle` isn't always shown and pulsates).
/// </remarks>
public static readonly Vector2 MAX_FOLLOW_CIRCLE_AREA_SIZE = OsuHitObject.OBJECT_DIMENSIONS * 3;
public OsuLegacySkinTransformer(ISkin skin)
: base(skin)
@@ -41,14 +52,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
return this.GetAnimation("sliderscorepoint", false, false);
case OsuSkinComponents.SliderFollowCircle:
var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true);
var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
if (followCircleContent != null)
return new LegacyFollowCircle(followCircleContent);
return null;
case OsuSkinComponents.SliderBall:
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "");
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "", maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
// todo: slider ball has a custom frame delay based on velocity
// Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME);
@@ -138,10 +149,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
if (!this.HasFont(LegacyFont.HitCircle))
return null;
return new LegacySpriteText(LegacyFont.HitCircle)
const float hitcircle_text_scale = 0.8f;
return new LegacySpriteText(LegacyFont.HitCircle, OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale)
{
// stable applies a blanket 0.8x scale to hitcircle fonts
Scale = new Vector2(0.8f),
Scale = new Vector2(hitcircle_text_scale),
};
case OsuSkinComponents.SpinnerBody:
@@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
{
new RingPiece(3)
{
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2),
Size = OsuHitObject.OBJECT_DIMENSIONS,
Alpha = 0.1f,
}
};
@@ -115,6 +115,48 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AddAssert("all tick offsets are 0", () => JudgementResults.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0));
}
[Test]
public void TestAtMostOneSwellTickJudgedPerFrame()
{
const double swell_time = 1000;
Swell swell = new Swell
{
StartTime = swell_time,
Duration = 1000,
RequiredHits = 10
};
List<ReplayFrame> frames = new List<ReplayFrame>
{
new TaikoReplayFrame(1000),
new TaikoReplayFrame(1250, TaikoAction.LeftCentre, TaikoAction.LeftRim),
new TaikoReplayFrame(1251),
new TaikoReplayFrame(1500, TaikoAction.LeftCentre, TaikoAction.LeftRim, TaikoAction.RightCentre, TaikoAction.RightRim),
new TaikoReplayFrame(1501),
new TaikoReplayFrame(2000),
};
PerformTest(frames, CreateBeatmap(swell));
AssertJudgementCount(11);
// this is a charitable interpretation of the inputs.
//
// for the frame at time 1250, we only count either one of the input actions - simple.
//
// for the frame at time 1500, we give the user the benefit of the doubt,
// and we ignore actions that wouldn't otherwise cause a hit due to not alternating,
// but we still count one (just one) of the actions that _would_ normally cause a hit.
// this is done as a courtesy to avoid stuff like key chattering after press blocking legitimate inputs.
for (int i = 0; i < 2; i++)
AssertResult<SwellTick>(i, HitResult.IgnoreHit);
for (int i = 2; i < swell.RequiredHits; i++)
AssertResult<SwellTick>(i, HitResult.IgnoreMiss);
AssertResult<Swell>(0, HitResult.IgnoreMiss);
}
/// <summary>
/// Ensure input is correctly sent to subsequent hits if a swell is fully completed.
/// </summary>
@@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
[SetUp]
public void SetUp() => Schedule(() =>
{
gameplayClock = new GameplayClockContainer(manualClock)
gameplayClock = new GameplayClockContainer(manualClock, false, false)
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
@@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
@@ -99,15 +96,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
};
if (ComputeLegacyScoringValues)
{
TaikoLegacyScoreSimulator sv1Simulator = new TaikoLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -10,31 +10,24 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
internal class TaikoLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int standardisedBonusScore;
private int combo;
private double modMultiplier;
private int difficultyPeppyStars;
private IBeatmap playableBeatmap = null!;
private IReadOnlyList<Mod> mods = null!;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
this.playableBeatmap = playableBeatmap;
this.mods = mods;
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
@@ -76,13 +69,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
modMultiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
return attributes;
}
private void simulateHit(HitObject hitObject)
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
@@ -109,21 +106,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
case Swell swell:
// The taiko swell generally does not match the osu-stable implementation in any way.
// We'll redo the calculations to match osu-stable here...
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
double secondsDuration = swell.Duration / 1000;
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises rotations.
const double minimum_rotations_per_second = 7.5;
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
int halfSpinsRequiredForCompletion = (int)(swell.Duration / 1000 * minimum_rotations_per_second);
halfSpinsRequiredForCompletion = (int)Math.Max(1, halfSpinsRequiredForCompletion * 1.65f);
if (mods.Any(m => m is ModDoubleTime))
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 0.75f));
if (mods.Any(m => m is ModHalfTime))
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f));
//
// Normally, this multiplier depends on the active mods (DT = 0.75, HT = 1.5). For simplicity, we'll only consider the worst case that maximises rotations.
// This way, scores remain beatable at the cost of the conversion being slightly inaccurate.
// - A perfect DT/NM score will have less than 1M total score (excluding bonus).
// - A perfect HT score will have 1M total score (excluding bonus).
//
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f));
for (int i = 0; i <= halfSpinsRequiredForCompletion; i++)
simulateHit(new SwellTick());
simulateHit(new SwellTick(), ref attributes);
scoreIncrease = 300;
addScoreComboMultiplier = true;
@@ -139,7 +139,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
case DrumRoll:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
return;
}
@@ -159,8 +159,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
int oldScoreIncrease = scoreIncrease;
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
scoreIncrease += (int)(scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * modMultiplier) * (Math.Min(100, combo) / 10);
scoreIncrease += scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * (Math.Min(100, combo) / 10);
if (hitObject is Swell)
{
@@ -185,18 +184,60 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
scoreIncrease -= comboScoreIncrease;
if (addScoreComboMultiplier)
ComboScore += comboScoreIncrease;
attributes.ComboScore += comboScoreIncrease;
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
attributes.AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
{
bool scoreV2 = mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in mods)
{
switch (mod)
{
case TaikoModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case TaikoModEasy:
multiplier *= 0.5;
break;
case TaikoModHalfTime:
case TaikoModDaycore:
multiplier *= 0.3;
break;
case TaikoModHidden:
case TaikoModHardRock:
multiplier *= 1.06;
break;
case TaikoModDoubleTime:
case TaikoModNightcore:
case TaikoModFlashlight:
multiplier *= 1.12;
break;
case TaikoModRelax:
return 0;
}
}
return multiplier;
}
}
}
@@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
public partial class DrawableTaikoEditorRuleset : DrawableTaikoRuleset, ISupportConstantAlgorithmToggle
{
public BindableBool ShowSpeedChanges { get; set; } = new BindableBool();
public BindableBool ShowSpeedChanges { get; } = new BindableBool();
public DrawableTaikoEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
: base(ruleset, beatmap, mods)
@@ -17,6 +17,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
@@ -38,6 +39,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
private readonly CircularContainer targetRing;
private readonly CircularContainer expandingRing;
private double? lastPressHandleTime;
public override bool DisplayResult => false;
public DrawableSwell()
@@ -140,6 +143,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
UnproxyContent();
lastWasCentre = null;
lastPressHandleTime = null;
}
protected override void AddNestedHitObject(DrawableHitObject hitObject)
@@ -266,6 +270,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
ProxyContent();
else
UnproxyContent();
if ((Clock as IGameplayClock)?.IsRewinding == true)
lastPressHandleTime = null;
}
private bool? lastWasCentre;
@@ -285,7 +292,14 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
if (lastWasCentre == isCentre)
return false;
// If we've already successfully judged a tick this frame, do not judge more.
// Note that the ordering is important here - this is intentionally placed after the alternating check.
// That is done to prevent accidental double inputs blocking simultaneous but legitimate hits from registering.
if (lastPressHandleTime == Time.Current)
return true;
lastWasCentre = isCentre;
lastPressHandleTime = Time.Current;
UpdateResult(true);
@@ -22,6 +22,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
{
public partial class LegacyCirclePiece : CompositeDrawable, IHasAccentColour
{
private static readonly Vector2 circle_piece_size = new Vector2(128);
private static readonly Vector2 max_circle_sprite_size = new Vector2(160);
private Drawable backgroundLayer = null!;
private Drawable? foregroundLayer;
@@ -52,9 +55,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
string prefix = ((drawableHitObject.HitObject as TaikoStrongableHitObject)?.IsStrong ?? false) ? big_hit : normal_hit;
return skin.GetAnimation($"{prefix}{lookup}", true, false) ??
return skin.GetAnimation($"{prefix}{lookup}", true, false, maxSize: max_circle_sprite_size) ??
// fallback to regular size if "big" version doesn't exist.
skin.GetAnimation($"{normal_hit}{lookup}", true, false);
skin.GetAnimation($"{normal_hit}{lookup}", true, false, maxSize: max_circle_sprite_size);
}
// backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer.
@@ -96,7 +99,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
// Not all skins (including the default osu-stable) have similar sizes for "hitcircle" and "hitcircleoverlay".
// This ensures they are scaled relative to each other but also match the expected DrawableHit size.
foreach (var c in InternalChildren)
c.Scale = new Vector2(DrawHeight / 128);
c.Scale = new Vector2(DrawHeight / circle_piece_size.Y);
if (foregroundLayer is IFramedAnimation animatableForegroundLayer)
animateForegroundLayer(animatableForegroundLayer);
+1
View File
@@ -34,6 +34,7 @@ using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning;
using osu.Game.Rulesets.Configuration;
using osu.Game.Configuration;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.Taiko.Configuration;
namespace osu.Game.Rulesets.Taiko
@@ -33,7 +33,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
[Test]
public void TestDecodeBeatmapVersion()
{
using (var resStream = TestResources.OpenResource("beatmap-version.osu"))
using (var resStream = TestResources.OpenResource("beatmap-version-6.osu"))
using (var stream = new LineBufferedReader(resStream))
{
var decoder = Decoder.GetDecoder<Beatmap>(stream);
@@ -45,6 +45,25 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
[TestCase(false)]
[TestCase(true)]
public void TestPreviewPointWithOffsets(bool applyOffsets)
{
using (var resStream = TestResources.OpenResource("beatmap-version-4.osu"))
using (var stream = new LineBufferedReader(resStream))
{
var decoder = Decoder.GetDecoder<Beatmap>(stream);
((LegacyBeatmapDecoder)decoder).ApplyOffsets = applyOffsets;
var working = new TestWorkingBeatmap(decoder.Decode(stream));
Assert.AreEqual(4, working.BeatmapInfo.BeatmapVersion);
Assert.AreEqual(4, working.Beatmap.BeatmapInfo.BeatmapVersion);
Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty<Mod>()).BeatmapInfo.BeatmapVersion);
Assert.AreEqual(-1, working.BeatmapInfo.Metadata.PreviewTime);
}
}
[Test]
public void TestDecodeBeatmapGeneral()
{
@@ -915,10 +934,11 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
[Test]
public void TestLegacyDefaultsPreserved()
[TestCase(false)]
[TestCase(true)]
public void TestLegacyDefaultsPreserved(bool applyOffsets)
{
var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };
var decoder = new LegacyBeatmapDecoder { ApplyOffsets = applyOffsets };
using (var memoryStream = new MemoryStream())
using (var stream = new LineBufferedReader(memoryStream))
@@ -16,7 +16,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestSingleSpan()
{
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null).ToArray();
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray();
Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head));
Assert.That(events[0].Time, Is.EqualTo(start_time));
@@ -31,7 +31,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestRepeat()
{
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null).ToArray();
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2).ToArray();
Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head));
Assert.That(events[0].Time, Is.EqualTo(start_time));
@@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestNonEvenTicks()
{
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null).ToArray();
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2).ToArray();
Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head));
Assert.That(events[0].Time, Is.EqualTo(start_time));
@@ -83,12 +83,12 @@ namespace osu.Game.Tests.Beatmaps
}
[Test]
public void TestLegacyLastTickOffset()
public void TestLastTickOffset()
{
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100).ToArray();
var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray();
Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick));
Assert.That(events[2].Time, Is.EqualTo(900));
Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LastTick));
Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET));
}
[Test]
@@ -97,7 +97,7 @@ namespace osu.Game.Tests.Beatmaps
const double velocity = 5;
const double min_distance = velocity * 10;
var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2, 0).ToArray();
var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2).ToArray();
Assert.Multiple(() =>
{
@@ -127,8 +127,9 @@ namespace osu.Game.Tests.Database
});
}
[Test]
public void TestScoreUpgradeSuccess()
[TestCase(30000002)]
[TestCase(30000003)]
public void TestScoreUpgradeSuccess(int scoreVersion)
{
ScoreInfo scoreInfo = null!;
@@ -138,7 +139,7 @@ namespace osu.Game.Tests.Database
{
r.Add(scoreInfo = new ScoreInfo(ruleset: r.All<RulesetInfo>().First(), beatmap: r.All<BeatmapInfo>().First())
{
TotalScoreVersion = 30000002,
TotalScoreVersion = scoreVersion,
LegacyTotalScore = 123456,
IsLegacyScore = true,
});
@@ -44,17 +44,23 @@ namespace osu.Game.Tests.Database
createFile(subdirectory2, Path.Combine("beatmap5", "beatmap.osu"));
createFile(subdirectory2, Path.Combine("beatmap6", "beatmap.osu"));
// songs subdirectory with random file
var subdirectory3 = songsStorage.GetStorageForDirectory("subdirectory3");
createFile(subdirectory3, "silly readme.txt");
createFile(subdirectory3, Path.Combine("beatmap7", "beatmap.osu"));
// empty songs subdirectory
songsStorage.GetStorageForDirectory("subdirectory3");
string[] paths = importer.GetStableImportPaths(songsStorage).ToArray();
Assert.That(paths.Length, Is.EqualTo(6));
Assert.That(paths.Length, Is.EqualTo(7));
Assert.That(paths.Contains(songsStorage.GetFullPath("beatmap1")));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap2"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap3"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "sub-subdirectory", "beatmap4"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap5"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap6"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory3", "beatmap7"))));
}
static void createFile(Storage storage, string path)
@@ -126,7 +126,7 @@ namespace osu.Game.Tests.Gameplay
public void TestSamplePlaybackWithBeatmapHitsoundsOff()
{
GameplayClockContainer gameplayContainer = null;
TestDrawableStoryboardSample sample = null;
DrawableStoryboardSample sample = null;
AddStep("disable beatmap hitsounds", () => config.SetValue(OsuSetting.BeatmapHitsounds, false));
@@ -141,7 +141,7 @@ namespace osu.Game.Tests.Gameplay
Child = beatmapSkinSourceContainer
});
beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1))
beatmapSkinSourceContainer.Add(sample = new DrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1))
{
Clock = gameplayContainer
});
@@ -199,14 +199,6 @@ namespace osu.Game.Tests.Gameplay
protected internal override ISkin GetSkin() => new TestSkin("test-sample", resources);
}
private partial class TestDrawableStoryboardSample : DrawableStoryboardSample
{
public TestDrawableStoryboardSample(StoryboardSampleInfo sampleInfo)
: base(sampleInfo)
{
}
}
#region IResourceStorageProvider
public IRenderer Renderer => host.Renderer;
@@ -42,22 +42,22 @@ namespace osu.Game.Tests.Mods
private class ClassWithSettings
{
[SettingSource("Unordered setting", "Should be last")]
public BindableFloat UnorderedSetting { get; set; } = new BindableFloat();
public BindableFloat UnorderedSetting { get; } = new BindableFloat();
[SettingSource("Second setting", "Another description", 2)]
public BindableBool SecondSetting { get; set; } = new BindableBool();
public BindableBool SecondSetting { get; } = new BindableBool();
[SettingSource("First setting", "A description", 1)]
public BindableDouble FirstSetting { get; set; } = new BindableDouble();
public BindableDouble FirstSetting { get; } = new BindableDouble();
[SettingSource("Third setting", "Yet another description", 3)]
public BindableInt ThirdSetting { get; set; } = new BindableInt();
public BindableInt ThirdSetting { get; } = new BindableInt();
}
private class ClassWithCustomSettingControl
{
[SettingSource("Custom setting", "Should be a custom control", SettingControlType = typeof(CustomSettingsControl))]
public BindableInt UnorderedSetting { get; set; } = new BindableInt();
public BindableInt UnorderedSetting { get; } = new BindableInt();
}
private partial class CustomSettingsControl : SettingsItem<int>
@@ -3,6 +3,7 @@
using NUnit.Framework;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Timing;
using osu.Game.Screens.Play;
@@ -16,16 +17,16 @@ namespace osu.Game.Tests.NonVisual
[TestCase(1)]
public void TestTrueGameplayRateWithGameplayAdjustment(double underlyingClockRate)
{
var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate });
var gameplayClock = new TestGameplayClockContainer(framedClock);
var trackVirtual = new TrackVirtual(60000) { Frequency = { Value = underlyingClockRate } };
var gameplayClock = new TestGameplayClockContainer(trackVirtual);
Assert.That(gameplayClock.GetTrueGameplayRate(), Is.EqualTo(2));
}
private partial class TestGameplayClockContainer : GameplayClockContainer
{
public TestGameplayClockContainer(IFrameBasedClock underlyingClock)
: base(underlyingClock)
public TestGameplayClockContainer(IClock underlyingClock)
: base(underlyingClock, false, false)
{
AdjustmentsFromMods.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(2.0));
}
@@ -15,6 +15,9 @@ using osu.Game.Tests.Visual;
namespace osu.Game.Tests.OnlinePlay
{
// NOTE: This test scene never calls ProcessFrame on clocks.
// The current tests are fine without this as they are testing very static scenarios, but it's worth knowing
// if adding further tests to this class.
[HeadlessTest]
public partial class TestSceneCatchUpSyncManager : OsuTestScene
{
@@ -28,7 +31,7 @@ namespace osu.Game.Tests.OnlinePlay
[SetUp]
public void Setup()
{
syncManager = new SpectatorSyncManager(master = new GameplayClockContainer(new TestManualClock()));
syncManager = new SpectatorSyncManager(master = new GameplayClockContainer(new TestManualClock(), false, false));
player1 = syncManager.CreateManagedClock();
player2 = syncManager.CreateManagedClock();
@@ -188,6 +191,8 @@ namespace osu.Game.Tests.OnlinePlay
public void Reset()
{
IsRunning = false;
CurrentTime = 0;
}
public void ResetSpeedAdjustments()
@@ -0,0 +1,4 @@
osu file format v4
[General]
PreviewTime: -1
@@ -45,9 +45,9 @@ namespace osu.Game.Tests.Rulesets.Scoring
[TestCase(ScoringMode.Standardised, HitResult.Meh, 116_667)]
[TestCase(ScoringMode.Standardised, HitResult.Ok, 233_338)]
[TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)]
[TestCase(ScoringMode.Classic, HitResult.Meh, 0)]
[TestCase(ScoringMode.Classic, HitResult.Ok, 2)]
[TestCase(ScoringMode.Classic, HitResult.Great, 36)]
[TestCase(ScoringMode.Classic, HitResult.Meh, 11_670)]
[TestCase(ScoringMode.Classic, HitResult.Ok, 23_341)]
[TestCase(ScoringMode.Classic, HitResult.Great, 100_033)]
public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore)
{
scoreProcessor.ApplyBeatmap(beatmap);
@@ -84,17 +84,17 @@ namespace osu.Game.Tests.Rulesets.Scoring
[TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)]
[TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)]
[TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)]
[TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 4)]
[TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15)]
[TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 53)]
[TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 140)]
[TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 140)]
[TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 7_975)]
[TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15_949)]
[TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 30_398)]
[TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 49_546)]
[TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 49_546)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 11)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 54_189)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 9)]
[TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 36)]
[TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 36)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 49_289)]
[TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 100_003)]
[TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 100_015)]
public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore)
{
var minResult = new TestJudgement(hitResult).MinResult;
+18 -17
View File
@@ -27,7 +27,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "skin.osk"));
// When the import filename doesn't match, it should be appended (and update the skin.ini).
assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu);
assertCorrectMetadata(import1, "test skin [skin]", "skinner", 1.0m, osu);
});
[Test]
@@ -36,7 +36,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner", iniFilename: "Skin.InI"), "skin.osk"));
// When the import filename doesn't match, it should be appended (and update the skin.ini).
assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu);
assertCorrectMetadata(import1, "test skin [skin]", "skinner", 1.0m, osu);
});
[Test]
@@ -45,7 +45,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner", includeSectionHeader: false), "skin.osk"));
// When the import filename doesn't match, it should be appended (and update the skin.ini).
assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu);
assertCorrectMetadata(import1, "test skin [skin]", "skinner", 1.0m, osu);
});
[Test]
@@ -54,7 +54,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "test skin.osk"));
// When the import filename matches it shouldn't be appended.
assertCorrectMetadata(import1, "test skin", "skinner", osu);
assertCorrectMetadata(import1, "test skin", "skinner", 1.0m, osu);
});
[Test]
@@ -63,7 +63,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithNonIniFile(), "test skin.osk"));
// When the import filename matches it shouldn't be appended.
assertCorrectMetadata(import1, "test skin", "Unknown", osu);
assertCorrectMetadata(import1, "test skin", "Unknown", SkinConfiguration.LATEST_VERSION, osu);
});
[Test]
@@ -72,7 +72,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createEmptyOsk(), "test skin.osk"));
// When the import filename matches it shouldn't be appended.
assertCorrectMetadata(import1, "test skin", "Unknown", osu);
assertCorrectMetadata(import1, "test skin", "Unknown", SkinConfiguration.LATEST_VERSION, osu);
});
#endregion
@@ -102,7 +102,7 @@ namespace osu.Game.Tests.Skins.IO
public Task TestImportUpperCasedOskArchive() => runSkinTest(async osu =>
{
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "name 1.OsK"));
assertCorrectMetadata(import1, "name 1", "author 1", osu);
assertCorrectMetadata(import1, "name 1", "author 1", 1.0m, osu);
var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "name 1.oSK"));
@@ -115,14 +115,14 @@ namespace osu.Game.Tests.Skins.IO
MemoryStream exportStream = new MemoryStream();
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "custom.osk"));
assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu);
assertCorrectMetadata(import1, "name 1 [custom]", "author 1", 1.0m, osu);
await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(import1, exportStream);
string exportFilename = import1.GetDisplayString();
var import2 = await loadSkinIntoOsu(osu, new ImportTask(exportStream, $"{exportFilename}.osk"));
assertCorrectMetadata(import2, "name 1 [custom]", "author 1", osu);
assertCorrectMetadata(import2, "name 1 [custom]", "author 1", 1.0m, osu);
assertImportedOnce(import1, import2);
});
@@ -133,14 +133,14 @@ namespace osu.Game.Tests.Skins.IO
MemoryStream exportStream = new MemoryStream();
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 『1』", "author 1"), "custom.osk"));
assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", osu);
assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", 1.0m, osu);
await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(import1, exportStream);
string exportFilename = import1.GetDisplayString().GetValidFilename();
var import2 = await loadSkinIntoOsu(osu, new ImportTask(exportStream, $"{exportFilename}.osk"));
assertCorrectMetadata(import2, "name 『1』 [custom]", "author 1", osu);
assertCorrectMetadata(import2, "name 『1』 [custom]", "author 1", 1.0m, osu);
});
[Test]
@@ -150,7 +150,7 @@ namespace osu.Game.Tests.Skins.IO
var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 1"), batchImport);
assertImportedOnce(import1, import2);
assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", osu);
assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", 1.0m, osu);
});
#endregion
@@ -183,8 +183,8 @@ namespace osu.Game.Tests.Skins.IO
var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin v2.1", "skinner"), "skin.osk"));
assertImportedBoth(import1, import2);
assertCorrectMetadata(import1, "test skin v2 [skin]", "skinner", osu);
assertCorrectMetadata(import2, "test skin v2.1 [skin]", "skinner", osu);
assertCorrectMetadata(import1, "test skin v2 [skin]", "skinner", 1.0m, osu);
assertCorrectMetadata(import2, "test skin v2.1 [skin]", "skinner", 1.0m, osu);
});
[Test]
@@ -194,8 +194,8 @@ namespace osu.Game.Tests.Skins.IO
var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 2"));
assertImportedBoth(import1, import2);
assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", osu);
assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", osu);
assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", 1.0m, osu);
assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", 1.0m, osu);
});
[Test]
@@ -264,7 +264,7 @@ namespace osu.Game.Tests.Skins.IO
#endregion
private void assertCorrectMetadata(Live<SkinInfo> import1, string name, string creator, OsuGameBase osu)
private void assertCorrectMetadata(Live<SkinInfo> import1, string name, string creator, decimal version, OsuGameBase osu)
{
import1.PerformRead(i =>
{
@@ -276,6 +276,7 @@ namespace osu.Game.Tests.Skins.IO
Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name));
Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator));
Assert.That(instance.Configuration.LegacyVersion, Is.EqualTo(version));
});
}
@@ -52,7 +52,9 @@ namespace osu.Game.Tests.Skins
// Covers player avatar and flag.
"Archives/modified-argon-20230305.osk",
// Covers key counters
"Archives/modified-argon-pro-20230618.osk"
"Archives/modified-argon-pro-20230618.osk",
// Covers "Argon" health display
"Archives/modified-argon-pro-20231001.osk"
};
/// <summary>
@@ -125,7 +125,7 @@ namespace osu.Game.Tests.Visual.Editing
});
AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual);
AddAssert("track length changed", () => Beatmap.Value.Track.Length > 60000);
AddUntilStep("track length changed", () => Beatmap.Value.Track.Length > 60000);
AddStep("test play", () => Editor.TestGameplay());
@@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("seek near end", () => EditorClock.Seek(EditorClock.TrackLength - 250));
AddUntilStep("clock stops", () => !EditorClock.IsRunning);
AddUntilStep("clock stopped at end", () => EditorClock.CurrentTime - EditorClock.TotalAppliedOffset, () => Is.EqualTo(EditorClock.TrackLength));
AddUntilStep("clock stopped at end", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength));
AddStep("start clock again", () => EditorClock.Start());
AddAssert("clock looped to start", () => EditorClock.IsRunning && EditorClock.CurrentTime < 500);
@@ -0,0 +1,91 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.Gameplay
{
public partial class TestSceneArgonHealthDisplay : OsuTestScene
{
[Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
private ArgonHealthDisplay healthDisplay = null!;
[SetUpSteps]
public void SetUpSteps()
{
AddStep(@"Reset all", delegate
{
healthProcessor.Health.Value = 1;
healthProcessor.Failed += () => false; // health won't be updated if the processor gets into a "fail" state.
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Gray,
},
healthDisplay = new ArgonHealthDisplay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
};
});
AddSliderStep("Width", 0, 1f, 1f, val =>
{
if (healthDisplay.IsNotNull())
healthDisplay.BarLength.Value = val;
});
AddSliderStep("Height", 0, 64, 0, val =>
{
if (healthDisplay.IsNotNull())
healthDisplay.BarHeight.Value = val;
});
}
[Test]
public void TestHealthDisplayIncrementing()
{
AddRepeatStep("apply miss judgement", delegate
{
healthProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Miss });
}, 5);
AddRepeatStep(@"decrease hp slightly", delegate
{
healthProcessor.Health.Value -= 0.01f;
}, 10);
AddRepeatStep(@"increase hp without flash", delegate
{
healthProcessor.Health.Value += 0.1f;
}, 3);
AddRepeatStep(@"increase hp with flash", delegate
{
healthProcessor.Health.Value += 0.1f;
healthProcessor.ApplyResult(new JudgementResult(new HitCircle(), new OsuJudgement())
{
Type = HitResult.Perfect
});
}, 3);
}
}
}
@@ -2,17 +2,23 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.IO.Stores;
using osu.Framework.Testing;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Storyboards;
using osu.Game.Storyboards.Drawables;
using osu.Game.Tests.Resources;
using osuTK;
namespace osu.Game.Tests.Visual.Gameplay
@@ -21,17 +27,21 @@ namespace osu.Game.Tests.Visual.Gameplay
{
protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset();
[Cached]
private Storyboard storyboard { get; set; } = new Storyboard();
[Cached(typeof(Storyboard))]
private TestStoryboard storyboard { get; set; } = new TestStoryboard();
private IEnumerable<DrawableStoryboardSprite> sprites => this.ChildrenOfType<DrawableStoryboardSprite>();
private const string lookup_name = "hitcircleoverlay";
[Test]
public void TestSkinSpriteDisallowedByDefault()
{
const string lookup_name = "hitcircleoverlay";
AddStep("allow skin lookup", () => storyboard.UseSkinSprites = false);
AddStep("disallow all lookups", () =>
{
storyboard.UseSkinSprites = false;
storyboard.AlwaysProvideTexture = false;
});
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
@@ -40,11 +50,13 @@ namespace osu.Game.Tests.Visual.Gameplay
}
[Test]
public void TestAllowLookupFromSkin()
public void TestLookupFromStoryboard()
{
const string lookup_name = "hitcircleoverlay";
AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true);
AddStep("allow storyboard lookup", () =>
{
storyboard.UseSkinSprites = false;
storyboard.AlwaysProvideTexture = true;
});
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
@@ -52,16 +64,54 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("sprite found texture", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Texture != null)));
AddAssert("skinnable sprite has correct size", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Size == new Vector2(128))));
assertStoryboardSourced();
}
[Test]
public void TestSkinLookupPreferredOverStoryboard()
{
AddStep("allow all lookups", () =>
{
storyboard.UseSkinSprites = true;
storyboard.AlwaysProvideTexture = true;
});
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
// Only checking for at least one sprite that succeeded, as not all skins in this test provide the hitcircleoverlay texture.
AddAssert("sprite found texture", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Texture != null)));
assertSkinSourced();
}
[Test]
public void TestAllowLookupFromSkin()
{
AddStep("allow skin lookup", () =>
{
storyboard.UseSkinSprites = true;
storyboard.AlwaysProvideTexture = false;
});
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
// Only checking for at least one sprite that succeeded, as not all skins in this test provide the hitcircleoverlay texture.
AddAssert("sprite found texture", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Texture != null)));
assertSkinSourced();
}
[Test]
public void TestFlippedSprite()
{
const string lookup_name = "hitcircleoverlay";
AddStep("allow all lookups", () =>
{
storyboard.UseSkinSprites = true;
storyboard.AlwaysProvideTexture = true;
});
AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true);
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
AddStep("flip sprites", () => sprites.ForEach(s =>
{
@@ -74,9 +124,12 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestZeroScale()
{
const string lookup_name = "hitcircleoverlay";
AddStep("allow all lookups", () =>
{
storyboard.UseSkinSprites = true;
storyboard.AlwaysProvideTexture = true;
});
AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true);
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
AddAssert("sprites present", () => sprites.All(s => s.IsPresent));
AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(0, 1)));
@@ -86,9 +139,12 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestNegativeScale()
{
const string lookup_name = "hitcircleoverlay";
AddStep("allow all lookups", () =>
{
storyboard.UseSkinSprites = true;
storyboard.AlwaysProvideTexture = true;
});
AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true);
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(-1)));
AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight));
@@ -97,9 +153,12 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestNegativeScaleWithFlippedSprite()
{
const string lookup_name = "hitcircleoverlay";
AddStep("allow all lookups", () =>
{
storyboard.UseSkinSprites = true;
storyboard.AlwaysProvideTexture = true;
});
AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true);
AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero)));
AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(-1)));
AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight));
@@ -111,13 +170,78 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("origin back", () => sprites.All(s => s.Origin == Anchor.TopLeft));
}
private DrawableStoryboardSprite createSprite(string lookupName, Anchor origin, Vector2 initialPosition)
=> new DrawableStoryboardSprite(
new StoryboardSprite(lookupName, origin, initialPosition)
).With(s =>
private DrawableStoryboard createSprite(string lookupName, Anchor origin, Vector2 initialPosition)
{
var layer = storyboard.GetLayer("Background");
var sprite = new StoryboardSprite(lookupName, origin, initialPosition);
sprite.AddLoop(Time.Current, 100).Alpha.Add(Easing.None, 0, 10000, 1, 1);
layer.Elements.Clear();
layer.Add(sprite);
return storyboard.CreateDrawable().With(s => s.RelativeSizeAxes = Axes.Both);
}
private void assertStoryboardSourced()
{
AddAssert("sprite came from storyboard", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Size == new Vector2(200))));
}
private void assertSkinSourced()
{
AddAssert("sprite came from skin", () =>
sprites.Any(sprite => sprite.ChildrenOfType<Sprite>().All(s => s.Size == new Vector2(128))));
}
private partial class TestStoryboard : Storyboard
{
public override DrawableStoryboard CreateDrawable(IReadOnlyList<Mod>? mods = null)
{
s.LifetimeStart = double.MinValue;
s.LifetimeEnd = double.MaxValue;
});
return new TestDrawableStoryboard(this, mods);
}
public bool AlwaysProvideTexture { get; set; }
public override string GetStoragePathFromStoryboardPath(string path) => AlwaysProvideTexture ? path : string.Empty;
private partial class TestDrawableStoryboard : DrawableStoryboard
{
private readonly bool alwaysProvideTexture;
public TestDrawableStoryboard(TestStoryboard storyboard, IReadOnlyList<Mod>? mods)
: base(storyboard, mods)
{
alwaysProvideTexture = storyboard.AlwaysProvideTexture;
}
protected override IResourceStore<byte[]> CreateResourceLookupStore() => alwaysProvideTexture
? new AlwaysReturnsTextureStore()
: new ResourceStore<byte[]>();
internal class AlwaysReturnsTextureStore : IResourceStore<byte[]>
{
private const string test_image = "Resources/Textures/test-image.png";
private readonly DllResourceStore store;
public AlwaysReturnsTextureStore()
{
store = TestResources.GetStore();
}
public void Dispose() => store.Dispose();
public byte[] Get(string name) => store.Get(test_image);
public Task<byte[]> GetAsync(string name, CancellationToken cancellationToken = new CancellationToken()) => store.GetAsync(test_image, cancellationToken);
public Stream GetStream(string name) => store.GetStream(test_image);
public IEnumerable<string> GetAvailableResources() => store.GetAvailableResources();
}
}
}
}
}

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