1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 09:17:51 +08:00

Merge branch 'master' into fix-legacy-score-multipliers-2

This commit is contained in:
Bartłomiej Dach 2023-10-09 10:53:14 +02:00 committed by GitHub
commit 71c4b138fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 838 additions and 518 deletions

View File

@ -1,206 +1,365 @@
# Listens for new PR comments containing !pp check [id], and runs a diffcalc comparison against master. # ## Description
# Usage:
# !pp check 0 | Runs only the osu! ruleset.
# !pp check 0 2 | Runs only the osu! and catch rulesets.
# #
# 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: on:
issue_comment: issue_comment:
types: [ created ] 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: env:
CONCURRENCY: 4 COMMENT_TAG: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
ALLOW_DOWNLOAD: 1
SAVE_DOWNLOADED: 1
SKIP_INSERT_ATTRIBUTES: 1
jobs: jobs:
metadata: wait-for-queue:
name: Check for requests 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 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: outputs:
matrix: ${{ steps.generate-matrix.outputs.matrix }} GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }}
continue: ${{ steps.generate-matrix.outputs.continue }} GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }}
GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }}
steps: steps:
- name: Construct build matrix - name: Checkout
id: generate-matrix 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: | run: |
if [[ "${{ github.event.comment.body }}" =~ "osu" ]] ; then echo "GENERATOR_DIR=${{ github.workspace }}/diffcalc-sheet-generator" >> "${GITHUB_OUTPUT}"
MATRIX_PROJECTS_JSON+='{ "name": "osu", "id": 0 },' echo "GENERATOR_ENV=${{ github.workspace }}/diffcalc-sheet-generator/.env" >> "${GITHUB_OUTPUT}"
fi echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/diffcalc-sheet-generator/google-credentials.json" >> "${GITHUB_OUTPUT}"
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
if [[ "${MATRIX_PROJECTS_JSON}" != "" ]]; then environment:
MATRIX_JSON="{ \"ruleset\": [ ${MATRIX_PROJECTS_JSON} ] }" name: Setup environment
echo "${MATRIX_JSON}" needs: directory
CONTINUE="yes"
else
CONTINUE="no"
fi
echo "continue=${CONTINUE}" >> $GITHUB_OUTPUT
echo "matrix=${MATRIX_JSON}" >> $GITHUB_OUTPUT
diffcalc:
name: Run
runs-on: self-hosted runs-on: self-hosted
timeout-minutes: 1440 if: ${{ !cancelled() && needs.directory.result == 'success' }}
if: needs.metadata.outputs.continue == 'yes' env:
needs: metadata VARS_JSON: ${{ toJSON(vars) }}
strategy:
matrix: ${{ fromJson(needs.metadata.outputs.matrix) }}
steps: steps:
- name: Verify MySQL connection from host - name: Add base environment
run: | 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 # Add Google credentials
run: | echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}"
for db in osu_master osu_pr
do # Add repository variables
mysql -e "DROP DATABASE IF EXISTS $db" 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 done
- name: Create directory structure - name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: | run: |
mkdir -p $GITHUB_WORKSPACE/master/ sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
mkdir -p $GITHUB_WORKSPACE/pr/
- name: Get upstream branch # https://akaimo.hatenablog.jp/entry/2020/05/16/101251 - name: Add comment environment
id: upstreambranch if: ${{ github.event_name == 'issue_comment' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
echo "branchname=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" >> $GITHUB_OUTPUT # Add comment environment
echo "repo=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" >> $GITHUB_OUTPUT echo '${{ github.event.comment.body }}' | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do
opt=$(echo ${line} | cut -d '=' -f1)
# Checkout osu sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- 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;'
done done
- name: Run diffcalc (master) - name: Add dispatch environment
env: if: ${{ github.event_name == 'workflow_dispatch' }}
DB_NAME: osu_master
run: | run: |
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator/osu.Server.DifficultyCalculator sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }} sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Run diffcalc (pr) sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
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 }}
- name: Print diffs if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then
run: | sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
mysql -e " fi
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;"
# 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 }}

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.922.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2023.1006.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -41,7 +41,6 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
X = xPositionData?.X ?? 0, X = xPositionData?.X ?? 0,
NewCombo = comboData?.NewCombo ?? false, NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0, ComboOffset = comboData?.ComboOffset ?? 0,
LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0,
LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y, LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y,
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1 SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1
}.Yield(); }.Yield();

View File

@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects
int nodeIndex = 0; int nodeIndex = 0;
SliderEventDescriptor? lastEvent = null; 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 // generate tiny droplets since the last point
if (lastEvent != null) 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 also includes LastTick 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 means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied.
lastEvent = e; lastEvent = e;
switch (e.Type) switch (e.Type)
@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects
public double Distance => Path.Distance; public double Distance => Path.Distance;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>(); public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
public double? LegacyLastTickOffset { get; set; }
} }
} }

View File

@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece
{ {
private static readonly Vector2 banana_max_size = new Vector2(128); private static readonly Vector2 banana_max_size = new Vector2(160);
protected override void LoadComplete() protected override void LoadComplete()
{ {

View File

@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece
{ {
private static readonly Vector2 droplet_max_size = new Vector2(82, 103); private static readonly Vector2 droplet_max_size = new Vector2(160);
public LegacyDropletPiece() public LegacyDropletPiece()
{ {

View File

@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece
{ {
private static readonly Vector2 fruit_max_size = new Vector2(128); private static readonly Vector2 fruit_max_size = new Vector2(160);
protected override void LoadComplete() protected override void LoadComplete()
{ {

View File

@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Edit
{ {
public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset, ISupportConstantAlgorithmToggle 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; public new IScrollingInfo ScrollingInfo => base.ScrollingInfo;

View File

@ -163,7 +163,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
slider = new Slider slider = new Slider
{ {
Position = new Vector2(0, 50), Position = new Vector2(0, 50),
LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point
Path = new SliderPath(new[] Path = new SliderPath(new[]
{ {
new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),

View File

@ -44,7 +44,6 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
Position = positionData?.Position ?? Vector2.Zero, Position = positionData?.Position ?? Vector2.Zero,
NewCombo = comboData?.NewCombo ?? false, NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0, 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. // 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. // 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, TickDistanceMultiplier = beatmap.BeatmapInfo.BeatmapVersion < 8 ? 1f / ((LegacyControlPointInfo)beatmap.ControlPointInfo).DifficultyPointAt(original.StartTime).SliderVelocity : 1,

View File

@ -315,7 +315,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
StartTime = HitObject.StartTime, StartTime = HitObject.StartTime,
Position = HitObject.Position + splitControlPoints[0].Position, Position = HitObject.Position + splitControlPoints[0].Position,
NewCombo = HitObject.NewCombo, NewCombo = HitObject.NewCombo,
LegacyLastTickOffset = HitObject.LegacyLastTickOffset,
Samples = HitObject.Samples.Select(s => s.With()).ToList(), Samples = HitObject.Samples.Select(s => s.With()).ToList(),
RepeatCount = HitObject.RepeatCount, RepeatCount = HitObject.RepeatCount,
NodeSamples = HitObject.NodeSamples.Select(n => (IList<HitSampleInfo>)n.Select(s => s.With()).ToList()).ToList(), NodeSamples = HitObject.NodeSamples.Select(n => (IList<HitSampleInfo>)n.Select(s => s.With()).ToList()).ToList(),

View File

@ -96,14 +96,13 @@ namespace osu.Game.Rulesets.Osu.Mods
Position = original.Position; Position = original.Position;
NewCombo = original.NewCombo; NewCombo = original.NewCombo;
ComboOffset = original.ComboOffset; ComboOffset = original.ComboOffset;
LegacyLastTickOffset = original.LegacyLastTickOffset;
TickDistanceMultiplier = original.TickDistanceMultiplier; TickDistanceMultiplier = original.TickDistanceMultiplier;
SliderVelocityMultiplier = original.SliderVelocityMultiplier; SliderVelocityMultiplier = original.SliderVelocityMultiplier;
} }
protected override void CreateNestedHitObjects(CancellationToken cancellationToken) 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) foreach (var e in sliderEvents)
{ {
@ -130,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods
}); });
break; break;
case SliderEventType.LegacyLastTick: case SliderEventType.LastTick:
AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) AddNested(TailCircle = new StrictTrackingSliderTailCircle(this)
{ {
RepeatIndex = e.SpanIndex, RepeatIndex = e.SpanIndex,

View File

@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Mods
d.HitObjectApplied += _ => 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. // 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 double snapTime = d is DrawableSliderTail tail
? tail.Slider.GetEndTime() ? tail.Slider.GetEndTime()

View File

@ -296,7 +296,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public override void PlaySamples() public override void PlaySamples()
{ {
// rather than doing it this way, we should probably attach the sample to the tail circle. // 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) if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit)
base.PlaySamples(); base.PlaySamples();
} }

View File

@ -71,8 +71,6 @@ namespace osu.Game.Rulesets.Osu.Objects
} }
} }
public double? LegacyLastTickOffset { get; set; }
/// <summary> /// <summary>
/// The position of the cursor at the point of completion of this <see cref="Slider"/> if it was hit /// 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. /// 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); 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) foreach (var e in sliderEvents)
{ {
@ -206,10 +204,11 @@ namespace osu.Game.Rulesets.Osu.Objects
}); });
break; break;
case SliderEventType.LegacyLastTick: case SliderEventType.LastTick:
// we need to use the LegacyLastTick here for compatibility reasons (difficulty). // Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle.
// it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay. // It is required as difficulty calculation and gameplay relies on reading this value.
// if this is to change, we should revisit this. // (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) AddNested(TailCircle = new SliderTailCircle(this)
{ {
RepeatIndex = e.SpanIndex, RepeatIndex = e.SpanIndex,
@ -264,7 +263,9 @@ namespace osu.Game.Rulesets.Osu.Objects
if (HeadCircle != null) if (HeadCircle != null)
HeadCircle.Samples = this.GetNodeSamples(0); 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. // For now, the samples are played by the slider itself at the correct end time.
TailSamples = this.GetNodeSamples(repeatCount + 1); TailSamples = this.GetNodeSamples(repeatCount + 1);
} }

View File

@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Objects
{ {
/// <summary> /// <summary>
/// Note that this should not be used for timing correctness. /// 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> /// </summary>
public class SliderTailCircle : SliderEndCircle public class SliderTailCircle : SliderEndCircle
{ {

View File

@ -70,8 +70,11 @@ namespace osu.Game.Rulesets.Osu.Objects
double secondsDuration = Duration / 1000; double secondsDuration = Duration / 1000;
SpinsRequired = (int)(minRps * secondsDuration); // Allow a 0.1ms floating point precision error in the calculation of the duration.
MaximumBonusSpins = Math.Max(0, (int)(maxRps * secondsDuration) - SpinsRequired - bonus_spins_gap); 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) protected override void CreateNestedHitObjects(CancellationToken cancellationToken)

View File

@ -67,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. // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png.
InternalChildren = new[] InternalChildren = new[]
{ {
CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS) }) CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS * 2) })
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d, maxSize: OsuHitObject.OBJECT_DIMENSIONS)) Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d, maxSize: OsuHitObject.OBJECT_DIMENSIONS * 2))
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,

View File

@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null); var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null);
InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true, maxSize: OsuHitObject.OBJECT_DIMENSIONS) ?? Empty()).With(d => InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true, maxSize: OsuHitObject.OBJECT_DIMENSIONS * 2) ?? Empty()).With(d =>
{ {
d.Anchor = Anchor.Centre; d.Anchor = Anchor.Centre;
d.Origin = Anchor.Centre; d.Origin = Anchor.Centre;

View File

@ -7,7 +7,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK.Graphics; using osuTK.Graphics;
@ -47,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS), Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
Colour = new Color4(5, 5, 5, 255), Colour = new Color4(5, 5, 5, 255),
}, },
LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d => LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d =>
@ -59,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS), Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
Blending = BlendingParameters.Additive, Blending = BlendingParameters.Additive,
}, },
}; };

View File

@ -23,6 +23,16 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
/// </summary> /// </summary>
public const float LEGACY_CIRCLE_RADIUS = OsuHitObject.OBJECT_RADIUS - 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) public OsuLegacySkinTransformer(ISkin skin)
: base(skin) : base(skin)
{ {
@ -42,14 +52,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
return this.GetAnimation("sliderscorepoint", false, false); return this.GetAnimation("sliderscorepoint", false, false);
case OsuSkinComponents.SliderFollowCircle: case OsuSkinComponents.SliderFollowCircle:
var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: new Vector2(308f)); var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
if (followCircleContent != null) if (followCircleContent != null)
return new LegacyFollowCircle(followCircleContent); return new LegacyFollowCircle(followCircleContent);
return null; return null;
case OsuSkinComponents.SliderBall: case OsuSkinComponents.SliderBall:
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "", maxSize: OsuHitObject.OBJECT_DIMENSIONS); 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 // todo: slider ball has a custom frame delay based on velocity
// Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME); // Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME);
@ -139,10 +149,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
if (!this.HasFont(LegacyFont.HitCircle)) if (!this.HasFont(LegacyFont.HitCircle))
return null; return null;
return new LegacySpriteText(LegacyFont.HitCircle, OsuHitObject.OBJECT_DIMENSIONS) 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 // stable applies a blanket 0.8x scale to hitcircle fonts
Scale = new Vector2(0.8f), Scale = new Vector2(hitcircle_text_scale),
}; };
case OsuSkinComponents.SpinnerBody: case OsuSkinComponents.SpinnerBody:

View File

@ -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)); 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> /// <summary>
/// Ensure input is correctly sent to subsequent hits if a swell is fully completed. /// Ensure input is correctly sent to subsequent hits if a swell is fully completed.
/// </summary> /// </summary>

View File

@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
[SetUp] [SetUp]
public void SetUp() => Schedule(() => public void SetUp() => Schedule(() =>
{ {
gameplayClock = new GameplayClockContainer(manualClock) gameplayClock = new GameplayClockContainer(manualClock, false, false)
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new Drawable[] Children = new Drawable[]

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
{ {
public partial class DrawableTaikoEditorRuleset : DrawableTaikoRuleset, ISupportConstantAlgorithmToggle 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) public DrawableTaikoEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
: base(ruleset, beatmap, mods) : base(ruleset, beatmap, mods)

View File

@ -17,6 +17,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Screens.Play;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables 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 targetRing;
private readonly CircularContainer expandingRing; private readonly CircularContainer expandingRing;
private double? lastPressHandleTime;
public override bool DisplayResult => false; public override bool DisplayResult => false;
public DrawableSwell() public DrawableSwell()
@ -140,6 +143,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
UnproxyContent(); UnproxyContent();
lastWasCentre = null; lastWasCentre = null;
lastPressHandleTime = null;
} }
protected override void AddNestedHitObject(DrawableHitObject hitObject) protected override void AddNestedHitObject(DrawableHitObject hitObject)
@ -266,6 +270,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
ProxyContent(); ProxyContent();
else else
UnproxyContent(); UnproxyContent();
if ((Clock as IGameplayClock)?.IsRewinding == true)
lastPressHandleTime = null;
} }
private bool? lastWasCentre; private bool? lastWasCentre;
@ -285,7 +292,14 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
if (lastWasCentre == isCentre) if (lastWasCentre == isCentre)
return false; 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; lastWasCentre = isCentre;
lastPressHandleTime = Time.Current;
UpdateResult(true); UpdateResult(true);

View File

@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
public partial class LegacyCirclePiece : CompositeDrawable, IHasAccentColour public partial class LegacyCirclePiece : CompositeDrawable, IHasAccentColour
{ {
private static readonly Vector2 circle_piece_size = new Vector2(128); 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 backgroundLayer = null!;
private Drawable? foregroundLayer; private Drawable? foregroundLayer;
@ -54,9 +55,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
string prefix = ((drawableHitObject.HitObject as TaikoStrongableHitObject)?.IsStrong ?? false) ? big_hit : normal_hit; string prefix = ((drawableHitObject.HitObject as TaikoStrongableHitObject)?.IsStrong ?? false) ? big_hit : normal_hit;
return skin.GetAnimation($"{prefix}{lookup}", true, false, maxSize: circle_piece_size) ?? return skin.GetAnimation($"{prefix}{lookup}", true, false, maxSize: max_circle_sprite_size) ??
// fallback to regular size if "big" version doesn't exist. // fallback to regular size if "big" version doesn't exist.
skin.GetAnimation($"{normal_hit}{lookup}", true, false, maxSize: circle_piece_size); skin.GetAnimation($"{normal_hit}{lookup}", true, false, maxSize: max_circle_sprite_size);
} }
// backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer. // backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer.

View File

@ -16,7 +16,7 @@ namespace osu.Game.Tests.Beatmaps
[Test] [Test]
public void TestSingleSpan() 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].Type, Is.EqualTo(SliderEventType.Head));
Assert.That(events[0].Time, Is.EqualTo(start_time)); Assert.That(events[0].Time, Is.EqualTo(start_time));
@ -31,7 +31,7 @@ namespace osu.Game.Tests.Beatmaps
[Test] [Test]
public void TestRepeat() 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].Type, Is.EqualTo(SliderEventType.Head));
Assert.That(events[0].Time, Is.EqualTo(start_time)); Assert.That(events[0].Time, Is.EqualTo(start_time));
@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps
[Test] [Test]
public void TestNonEvenTicks() 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].Type, Is.EqualTo(SliderEventType.Head));
Assert.That(events[0].Time, Is.EqualTo(start_time)); Assert.That(events[0].Time, Is.EqualTo(start_time));
@ -83,12 +83,12 @@ namespace osu.Game.Tests.Beatmaps
} }
[Test] [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].Type, Is.EqualTo(SliderEventType.LastTick));
Assert.That(events[2].Time, Is.EqualTo(900)); Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET));
} }
[Test] [Test]
@ -97,7 +97,7 @@ namespace osu.Game.Tests.Beatmaps
const double velocity = 5; const double velocity = 5;
const double min_distance = velocity * 10; 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(() => Assert.Multiple(() =>
{ {

View File

@ -126,7 +126,7 @@ namespace osu.Game.Tests.Gameplay
public void TestSamplePlaybackWithBeatmapHitsoundsOff() public void TestSamplePlaybackWithBeatmapHitsoundsOff()
{ {
GameplayClockContainer gameplayContainer = null; GameplayClockContainer gameplayContainer = null;
TestDrawableStoryboardSample sample = null; DrawableStoryboardSample sample = null;
AddStep("disable beatmap hitsounds", () => config.SetValue(OsuSetting.BeatmapHitsounds, false)); AddStep("disable beatmap hitsounds", () => config.SetValue(OsuSetting.BeatmapHitsounds, false));
@ -141,7 +141,7 @@ namespace osu.Game.Tests.Gameplay
Child = beatmapSkinSourceContainer 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 Clock = gameplayContainer
}); });
@ -199,14 +199,6 @@ namespace osu.Game.Tests.Gameplay
protected internal override ISkin GetSkin() => new TestSkin("test-sample", resources); protected internal override ISkin GetSkin() => new TestSkin("test-sample", resources);
} }
private partial class TestDrawableStoryboardSample : DrawableStoryboardSample
{
public TestDrawableStoryboardSample(StoryboardSampleInfo sampleInfo)
: base(sampleInfo)
{
}
}
#region IResourceStorageProvider #region IResourceStorageProvider
public IRenderer Renderer => host.Renderer; public IRenderer Renderer => host.Renderer;

View File

@ -42,22 +42,22 @@ namespace osu.Game.Tests.Mods
private class ClassWithSettings private class ClassWithSettings
{ {
[SettingSource("Unordered setting", "Should be last")] [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)] [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)] [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)] [SettingSource("Third setting", "Yet another description", 3)]
public BindableInt ThirdSetting { get; set; } = new BindableInt(); public BindableInt ThirdSetting { get; } = new BindableInt();
} }
private class ClassWithCustomSettingControl private class ClassWithCustomSettingControl
{ {
[SettingSource("Custom setting", "Should be a custom control", SettingControlType = typeof(CustomSettingsControl))] [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> private partial class CustomSettingsControl : SettingsItem<int>

View File

@ -3,6 +3,7 @@
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Timing; using osu.Framework.Timing;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
@ -16,16 +17,16 @@ namespace osu.Game.Tests.NonVisual
[TestCase(1)] [TestCase(1)]
public void TestTrueGameplayRateWithGameplayAdjustment(double underlyingClockRate) public void TestTrueGameplayRateWithGameplayAdjustment(double underlyingClockRate)
{ {
var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate }); var trackVirtual = new TrackVirtual(60000) { Frequency = { Value = underlyingClockRate } };
var gameplayClock = new TestGameplayClockContainer(framedClock); var gameplayClock = new TestGameplayClockContainer(trackVirtual);
Assert.That(gameplayClock.GetTrueGameplayRate(), Is.EqualTo(2)); Assert.That(gameplayClock.GetTrueGameplayRate(), Is.EqualTo(2));
} }
private partial class TestGameplayClockContainer : GameplayClockContainer private partial class TestGameplayClockContainer : GameplayClockContainer
{ {
public TestGameplayClockContainer(IFrameBasedClock underlyingClock) public TestGameplayClockContainer(IClock underlyingClock)
: base(underlyingClock) : base(underlyingClock, false, false)
{ {
AdjustmentsFromMods.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(2.0)); AdjustmentsFromMods.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(2.0));
} }

View File

@ -15,6 +15,9 @@ using osu.Game.Tests.Visual;
namespace osu.Game.Tests.OnlinePlay 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] [HeadlessTest]
public partial class TestSceneCatchUpSyncManager : OsuTestScene public partial class TestSceneCatchUpSyncManager : OsuTestScene
{ {
@ -28,7 +31,7 @@ namespace osu.Game.Tests.OnlinePlay
[SetUp] [SetUp]
public void 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(); player1 = syncManager.CreateManagedClock();
player2 = syncManager.CreateManagedClock(); player2 = syncManager.CreateManagedClock();
@ -188,6 +191,8 @@ namespace osu.Game.Tests.OnlinePlay
public void Reset() public void Reset()
{ {
IsRunning = false;
CurrentTime = 0;
} }
public void ResetSpeedAdjustments() public void ResetSpeedAdjustments()

View File

@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("seek near end", () => EditorClock.Seek(EditorClock.TrackLength - 250)); AddStep("seek near end", () => EditorClock.Seek(EditorClock.TrackLength - 250));
AddUntilStep("clock stops", () => !EditorClock.IsRunning); 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()); AddStep("start clock again", () => EditorClock.Start());
AddAssert("clock looped to start", () => EditorClock.IsRunning && EditorClock.CurrentTime < 500); AddAssert("clock looped to start", () => EditorClock.IsRunning && EditorClock.CurrentTime < 500);

View File

@ -3,6 +3,7 @@
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing; using osu.Framework.Testing;
@ -12,7 +13,6 @@ using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
@ -22,6 +22,8 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(HealthProcessor))] [Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
private ArgonHealthDisplay healthDisplay = null!;
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()
{ {
@ -37,14 +39,25 @@ namespace osu.Game.Tests.Visual.Gameplay
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Colour = Color4.Gray, Colour = Color4.Gray,
}, },
new ArgonHealthDisplay healthDisplay = new ArgonHealthDisplay
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Scale = new Vector2(2f),
}, },
}; };
}); });
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] [Test]

View File

@ -6,11 +6,11 @@ using System.Diagnostics;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset());
[Cached(typeof(IGameplayClock))] [Cached(typeof(IGameplayClock))]
private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false);
// best way to check without exposing. // best way to check without exposing.
private Drawable hideTarget => hudOverlay.ChildrenOfType<SkinComponentsContainer>().First(); private Drawable hideTarget => hudOverlay.ChildrenOfType<SkinComponentsContainer>().First();

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.IO;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -173,7 +174,7 @@ namespace osu.Game.Tests.Visual.Gameplay
string? filePath = null; string? filePath = null;
// Files starting with _ are temporary, created by CreateFileSafely call. // Files starting with _ are temporary, created by CreateFileSafely call.
AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !f.StartsWith("_", StringComparison.Ordinal)), () => Is.Not.Null); AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !Path.GetFileName(f).StartsWith("_", StringComparison.Ordinal)), () => Is.Not.Null);
AddAssert("filesize is non-zero", () => AddAssert("filesize is non-zero", () =>
{ {
using (var stream = LocalStorage.GetStream(filePath)) using (var stream = LocalStorage.GetStream(filePath))

View File

@ -3,14 +3,20 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.IO; using osu.Game.IO;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Skinning; using osu.Game.Skinning;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
{ {
@ -23,6 +29,9 @@ namespace osu.Game.Tests.Visual.Gameplay
/// </remarks> /// </remarks>
public partial class TestScenePlayerMaxDimensions : TestSceneAllRulesetPlayers public partial class TestScenePlayerMaxDimensions : TestSceneAllRulesetPlayers
{ {
// scale textures to 4 times their size.
private const int scale_factor = 4;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{ {
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
@ -63,18 +72,66 @@ namespace osu.Game.Tests.Visual.Gameplay
remove { } remove { }
} }
public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT)
{
var texture = base.GetTexture(componentName, wrapModeS, wrapModeT);
if (texture != null)
texture.ScaleAdjust /= 8f;
return texture;
}
public ISkin FindProvider(Func<ISkin, bool> lookupFunction) => this; public ISkin FindProvider(Func<ISkin, bool> lookupFunction) => this;
public IEnumerable<ISkin> AllSources => new[] { this }; public IEnumerable<ISkin> AllSources => new[] { this };
protected override IResourceStore<TextureUpload> CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore<byte[]> storage)
=> new UpscaledTextureLoaderStore(base.CreateTextureLoaderStore(resources, storage));
private class UpscaledTextureLoaderStore : IResourceStore<TextureUpload>
{
private readonly IResourceStore<TextureUpload>? textureStore;
public UpscaledTextureLoaderStore(IResourceStore<TextureUpload>? textureStore)
{
this.textureStore = textureStore;
}
public void Dispose()
{
textureStore?.Dispose();
}
public TextureUpload Get(string name)
{
var textureUpload = textureStore?.Get(name);
// NRT not enabled on framework side classes (IResourceStore / TextureLoaderStore), welp.
if (textureUpload == null)
return null!;
return upscale(textureUpload);
}
public async Task<TextureUpload> GetAsync(string name, CancellationToken cancellationToken = new CancellationToken())
{
// NRT not enabled on framework side classes (IResourceStore / TextureLoaderStore), welp.
if (textureStore == null)
return null!;
var textureUpload = await textureStore.GetAsync(name, cancellationToken).ConfigureAwait(false);
if (textureUpload == null)
return null!;
return await Task.Run(() => upscale(textureUpload), cancellationToken).ConfigureAwait(false);
}
private TextureUpload upscale(TextureUpload textureUpload)
{
var image = Image.LoadPixelData(textureUpload.Data.ToArray(), textureUpload.Width, textureUpload.Height);
// The original texture upload will no longer be returned or used.
textureUpload.Dispose();
image.Mutate(i => i.Resize(new Size(textureUpload.Width, textureUpload.Height) * scale_factor));
return new TextureUpload(image);
}
public Stream? GetStream(string name) => textureStore?.GetStream(name);
public IEnumerable<string> GetAvailableResources() => textureStore?.GetAvailableResources() ?? Array.Empty<string>();
}
} }
} }
} }

View File

@ -4,10 +4,10 @@
#nullable disable #nullable disable
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.SkinEditor;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset());
[Cached(typeof(IGameplayClock))] [Cached(typeof(IGameplayClock))]
private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false);
[Cached] [Cached]
public readonly EditorClipboard Clipboard = new EditorClipboard(); public readonly EditorClipboard Clipboard = new EditorClipboard();

View File

@ -8,11 +8,11 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset());
[Cached(typeof(IGameplayClock))] [Cached(typeof(IGameplayClock))]
private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false);
private IEnumerable<HUDOverlay> hudOverlays => CreatedDrawables.OfType<HUDOverlay>(); private IEnumerable<HUDOverlay> hudOverlays => CreatedDrawables.OfType<HUDOverlay>();

View File

@ -12,6 +12,7 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
{ {
@ -20,9 +21,9 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(HealthProcessor))] [Cached(typeof(HealthProcessor))]
private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); private HealthProcessor healthProcessor = new DrainingHealthProcessor(0);
protected override Drawable CreateArgonImplementation() => new ArgonHealthDisplay(); protected override Drawable CreateArgonImplementation() => new ArgonHealthDisplay { Scale = new Vector2(0.6f) };
protected override Drawable CreateDefaultImplementation() => new DefaultHealthDisplay(); protected override Drawable CreateDefaultImplementation() => new DefaultHealthDisplay { Scale = new Vector2(0.6f) };
protected override Drawable CreateLegacyImplementation() => new LegacyHealthDisplay(); protected override Drawable CreateLegacyImplementation() => new LegacyHealthDisplay { Scale = new Vector2(0.6f) };
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()
@ -62,4 +63,4 @@ namespace osu.Game.Tests.Visual.Gameplay
}, 3); }, 3);
} }
} }
} }

View File

@ -106,14 +106,12 @@ namespace osu.Game.Tests.Visual.Gameplay
if (storyboard != null) if (storyboard != null)
storyboardContainer.Remove(storyboard, true); storyboardContainer.Remove(storyboard, true);
var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; storyboardContainer.Clock = new FramedClock(Beatmap.Value.Track);
storyboardContainer.Clock = decoupledClock;
storyboard = toLoad.CreateDrawable(SelectedMods.Value); storyboard = toLoad.CreateDrawable(SelectedMods.Value);
storyboard.Passing = false; storyboard.Passing = false;
storyboardContainer.Add(storyboard); storyboardContainer.Add(storyboard);
decoupledClock.ChangeSource(Beatmap.Value.Track);
} }
private void loadStoryboard(string filename, Action<Storyboard>? setUpStoryboard = null) private void loadStoryboard(string filename, Action<Storyboard>? setUpStoryboard = null)

View File

@ -4,7 +4,6 @@
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
@ -80,11 +79,11 @@ namespace osu.Game.Tournament.Tests.Components
{ {
Team1 = Team1 =
{ {
Value = new TournamentTeam { Players = new BindableList<TournamentUser> { redUser } } Value = new TournamentTeam { Players = { redUser } }
}, },
Team2 = Team2 =
{ {
Value = new TournamentTeam { Players = new BindableList<TournamentUser> { blueUser, blueUserWithCustomColour } } Value = new TournamentTeam { Players = { blueUser, blueUserWithCustomColour } }
} }
}); });

View File

@ -56,7 +56,7 @@ namespace osu.Game.Tournament.Models
}; };
[JsonProperty] [JsonProperty]
public BindableList<TournamentUser> Players { get; set; } = new BindableList<TournamentUser>(); public BindableList<TournamentUser> Players { get; } = new BindableList<TournamentUser>();
public TournamentTeam() public TournamentTeam()
{ {

View File

@ -5,7 +5,6 @@ using System;
using System.Diagnostics; using System.Diagnostics;
using osu.Framework; using osu.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Timing; using osu.Framework.Timing;
@ -28,16 +27,6 @@ namespace osu.Game.Beatmaps
{ {
private readonly bool applyOffsets; private readonly bool applyOffsets;
/// <summary>
/// The length of the underlying beatmap track. Will default to 60 seconds if unavailable.
/// </summary>
public double TrackLength => Track.Length;
/// <summary>
/// The underlying beatmap track, if available.
/// </summary>
public Track Track { get; private set; } = new TrackVirtual(60000);
/// <summary> /// <summary>
/// The total frequency adjustment from pause transforms. Should eventually be handled in a better way. /// The total frequency adjustment from pause transforms. Should eventually be handled in a better way.
/// </summary> /// </summary>
@ -53,7 +42,7 @@ namespace osu.Game.Beatmaps
private IDisposable? beatmapOffsetSubscription; private IDisposable? beatmapOffsetSubscription;
private readonly DecoupleableInterpolatingFramedClock decoupledClock; private readonly DecouplingFramedClock decoupledTrack;
[Resolved] [Resolved]
private OsuConfigManager config { get; set; } = null!; private OsuConfigManager config { get; set; } = null!;
@ -66,25 +55,21 @@ namespace osu.Game.Beatmaps
public bool IsRewinding { get; private set; } public bool IsRewinding { get; private set; }
public bool IsCoupled public FramedBeatmapClock(bool applyOffsets, bool requireDecoupling, IClock? source = null)
{
get => decoupledClock.IsCoupled;
set => decoupledClock.IsCoupled = value;
}
public FramedBeatmapClock(bool applyOffsets = false)
{ {
this.applyOffsets = applyOffsets; this.applyOffsets = applyOffsets;
// A decoupled clock is used to ensure precise time values even when the host audio subsystem is not reporting decoupledTrack = new DecouplingFramedClock(source) { AllowDecoupling = requireDecoupling };
// An interpolating clock is used to ensure precise time values even when the host audio subsystem is not reporting
// high precision times (on windows there's generally only 5-10ms reporting intervals, as an example). // high precision times (on windows there's generally only 5-10ms reporting intervals, as an example).
decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; var interpolatedTrack = new InterpolatingFramedClock(decoupledTrack);
if (applyOffsets) if (applyOffsets)
{ {
// Audio timings in general with newer BASS versions don't match stable. // Audio timings in general with newer BASS versions don't match stable.
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
platformOffsetClock = new OffsetCorrectionClock(decoupledClock, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; platformOffsetClock = new OffsetCorrectionClock(interpolatedTrack, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
// User global offset (set in settings) should also be applied. // User global offset (set in settings) should also be applied.
userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust); userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust);
@ -94,7 +79,7 @@ namespace osu.Game.Beatmaps
} }
else else
{ {
finalClockSource = decoupledClock; finalClockSource = interpolatedTrack;
} }
} }
@ -110,6 +95,7 @@ namespace osu.Game.Beatmaps
userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset); userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset);
userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true); userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true);
// TODO: this doesn't update when using ChangeSource() to change beatmap.
beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( beatmapOffsetSubscription = realm.SubscribeToPropertyChanged(
r => r.Find<BeatmapInfo>(beatmap.Value.BeatmapInfo.ID)?.UserSettings, r => r.Find<BeatmapInfo>(beatmap.Value.BeatmapInfo.ID)?.UserSettings,
settings => settings.Offset, settings => settings.Offset,
@ -124,17 +110,7 @@ namespace osu.Game.Beatmaps
{ {
base.Update(); base.Update();
if (Source != null && Source is not IAdjustableClock && Source.CurrentTime < decoupledClock.CurrentTime - 100) finalClockSource.ProcessFrame();
{
// InterpolatingFramedClock won't interpolate backwards unless its source has an ElapsedFrameTime.
// See https://github.com/ppy/osu-framework/blob/ba1385330cc501f34937e08257e586c84e35d772/osu.Framework/Timing/InterpolatingFramedClock.cs#L91-L93
// This is not always the case here when doing large seeks.
// (Of note, this is not an issue if the source is adjustable, as the source is seeked to be in time by DecoupleableInterpolatingFramedClock).
// Rather than trying to get around this by fixing the framework clock stack, let's work around it for now.
Seek(Source.CurrentTime);
}
else
finalClockSource.ProcessFrame();
if (Clock.ElapsedFrameTime != 0) if (Clock.ElapsedFrameTime != 0)
IsRewinding = Clock.ElapsedFrameTime < 0; IsRewinding = Clock.ElapsedFrameTime < 0;
@ -157,46 +133,42 @@ namespace osu.Game.Beatmaps
#region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock. #region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock.
public void ChangeSource(IClock? source) public void ChangeSource(IClock? source) => decoupledTrack.ChangeSource(source);
{
Track = source as Track ?? new TrackVirtual(60000);
decoupledClock.ChangeSource(source);
}
public IClock? Source => decoupledClock.Source; public IClock Source => decoupledTrack.Source;
public void Reset() public void Reset()
{ {
decoupledClock.Reset(); decoupledTrack.Reset();
finalClockSource.ProcessFrame(); finalClockSource.ProcessFrame();
} }
public void Start() public void Start()
{ {
decoupledClock.Start(); decoupledTrack.Start();
finalClockSource.ProcessFrame(); finalClockSource.ProcessFrame();
} }
public void Stop() public void Stop()
{ {
decoupledClock.Stop(); decoupledTrack.Stop();
finalClockSource.ProcessFrame(); finalClockSource.ProcessFrame();
} }
public bool Seek(double position) public bool Seek(double position)
{ {
bool success = decoupledClock.Seek(position - TotalAppliedOffset); bool success = decoupledTrack.Seek(position - TotalAppliedOffset);
finalClockSource.ProcessFrame(); finalClockSource.ProcessFrame();
return success; return success;
} }
public void ResetSpeedAdjustments() => decoupledClock.ResetSpeedAdjustments(); public void ResetSpeedAdjustments() => decoupledTrack.ResetSpeedAdjustments();
public double Rate public double Rate
{ {
get => decoupledClock.Rate; get => decoupledTrack.Rate;
set => decoupledClock.Rate = value; set => decoupledTrack.Rate = value;
} }
#endregion #endregion

View File

@ -47,7 +47,7 @@ namespace osu.Game.Graphics.Containers
/// <summary> /// <summary>
/// The amount of dim to be used when <see cref="IgnoreUserSettings"/> is <c>true</c>. /// The amount of dim to be used when <see cref="IgnoreUserSettings"/> is <c>true</c>.
/// </summary> /// </summary>
public Bindable<float> DimWhenUserSettingsIgnored { get; set; } = new Bindable<float>(); public Bindable<float> DimWhenUserSettingsIgnored { get; } = new Bindable<float>();
protected Bindable<bool> LightenDuringBreaks { get; private set; } = null!; protected Bindable<bool> LightenDuringBreaks { get; private set; } = null!;

View File

@ -6,6 +6,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Net.Http; using System.Net.Http;
using System.Net.Sockets;
using Newtonsoft.Json; using Newtonsoft.Json;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -99,6 +100,11 @@ namespace osu.Game.Online.API
return true; return true;
} }
} }
catch (SocketException)
{
// Network failure.
return false;
}
catch (HttpRequestException) catch (HttpRequestException)
{ {
// Network failure. // Network failure.
@ -106,7 +112,7 @@ namespace osu.Game.Online.API
} }
catch catch
{ {
// Force a full re-reauthentication. // Force a full re-authentication.
Token.Value = null; Token.Value = null;
return false; return false;
} }

View File

@ -215,7 +215,7 @@ namespace osu.Game
/// For now, this is used as a source specifically for beat synced components. /// For now, this is used as a source specifically for beat synced components.
/// Going forward, it could potentially be used as the single source-of-truth for beatmap timing. /// Going forward, it could potentially be used as the single source-of-truth for beatmap timing.
/// </summary> /// </summary>
private readonly FramedBeatmapClock beatmapClock = new FramedBeatmapClock(true); private readonly FramedBeatmapClock beatmapClock = new FramedBeatmapClock(applyOffsets: true, requireDecoupling: false);
protected override Container<Drawable> Content => content; protected override Container<Drawable> Content => content;
@ -441,16 +441,7 @@ namespace osu.Game
} }
} }
private void onTrackChanged(WorkingBeatmap beatmap, TrackChangeDirection direction) private void onTrackChanged(WorkingBeatmap beatmap, TrackChangeDirection direction) => beatmapClock.ChangeSource(beatmap.Track);
{
// FramedBeatmapClock uses a decoupled clock internally which will mutate the source if it is an `IAdjustableClock`.
// We don't want this for now, as the intention of beatmapClock is to be a read-only source for beat sync components.
//
// Encapsulating in a FramedClock will avoid any mutations.
var framedClock = new FramedClock(beatmap.Track);
beatmapClock.ChangeSource(framedClock);
}
protected virtual void InitialiseFonts() protected virtual void InitialiseFonts()
{ {

View File

@ -13,7 +13,7 @@ using osu.Game.Beatmaps.ControlPoints;
namespace osu.Game.Rulesets.Objects.Legacy namespace osu.Game.Rulesets.Objects.Legacy
{ {
internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasLegacyLastTickOffset, IHasSliderVelocity internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity
{ {
/// <summary> /// <summary>
/// Scoring distance with a speed-adjusted beat length of 1 second. /// Scoring distance with a speed-adjusted beat length of 1 second.
@ -59,7 +59,5 @@ namespace osu.Game.Rulesets.Objects.Legacy
Velocity = scoringDistance / timingPoint.BeatLength; Velocity = scoringDistance / timingPoint.BeatLength;
} }
public double LegacyLastTickOffset => 36;
} }
} }

View File

@ -10,9 +10,17 @@ namespace osu.Game.Rulesets.Objects
{ {
public static class SliderEventGenerator public static class SliderEventGenerator
{ {
// ReSharper disable once MethodOverloadWithOptionalParameter /// <summary>
/// Historically, slider's final tick (aka the place where the slider would receive a final judgement) was offset by -36 ms. Originally this was
/// done to workaround a technical detail (unimportant), but over the years it has become an expectation of players that you don't need to hold
/// until the true end of the slider. This very small amount of leniency makes it easier to jump away from fast sliders to the next hit object.
///
/// After discussion on how this should be handled going forward, players have unanimously stated that this lenience should remain in some way.
/// </summary>
public const double LAST_TICK_OFFSET = -36;
public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount,
double? legacyLastTickOffset, CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// A very lenient maximum length of a slider for ticks to be generated. // A very lenient maximum length of a slider for ticks to be generated.
// This exists for edge cases such as /b/1573664 where the beatmap has been edited by the user, and should never be reached in normal usage. // This exists for edge cases such as /b/1573664 where the beatmap has been edited by the user, and should never be reached in normal usage.
@ -76,14 +84,14 @@ namespace osu.Game.Rulesets.Objects
int finalSpanIndex = spanCount - 1; int finalSpanIndex = spanCount - 1;
double finalSpanStartTime = startTime + finalSpanIndex * spanDuration; double finalSpanStartTime = startTime + finalSpanIndex * spanDuration;
double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) - (legacyLastTickOffset ?? 0)); double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) + LAST_TICK_OFFSET);
double finalProgress = (finalSpanEndTime - finalSpanStartTime) / spanDuration; double finalProgress = (finalSpanEndTime - finalSpanStartTime) / spanDuration;
if (spanCount % 2 == 0) finalProgress = 1 - finalProgress; if (spanCount % 2 == 0) finalProgress = 1 - finalProgress;
yield return new SliderEventDescriptor yield return new SliderEventDescriptor
{ {
Type = SliderEventType.LegacyLastTick, Type = SliderEventType.LastTick,
SpanIndex = finalSpanIndex, SpanIndex = finalSpanIndex,
SpanStartTime = finalSpanStartTime, SpanStartTime = finalSpanStartTime,
Time = finalSpanEndTime, Time = finalSpanEndTime,
@ -173,7 +181,11 @@ namespace osu.Game.Rulesets.Objects
public enum SliderEventType public enum SliderEventType
{ {
Tick, Tick,
LegacyLastTick,
/// <summary>
/// Occurs just before the tail. See <see cref="SliderEventGenerator.LAST_TICK_OFFSET"/>.
/// </summary>
LastTick,
Head, Head,
Tail, Tail,
Repeat Repeat

View File

@ -1,14 +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.
namespace osu.Game.Rulesets.Objects.Types
{
/// <summary>
/// A type of <see cref="HitObject"/> which may require the last tick to be offset.
/// This is specific to osu!stable conversion, and should not be used elsewhere.
/// </summary>
public interface IHasLegacyLastTickOffset
{
double LegacyLastTickOffset { get; }
}
}

View File

@ -862,7 +862,7 @@ namespace osu.Game.Screens.Edit
private void resetTrack(bool seekToStart = false) private void resetTrack(bool seekToStart = false)
{ {
Beatmap.Value.Track.Stop(); clock.Stop();
if (seekToStart) if (seekToStart)
{ {

View File

@ -54,7 +54,7 @@ namespace osu.Game.Screens.Edit
this.beatDivisor = beatDivisor ?? new BindableBeatDivisor(); this.beatDivisor = beatDivisor ?? new BindableBeatDivisor();
underlyingClock = new FramedBeatmapClock(applyOffsets: true) { IsCoupled = false }; underlyingClock = new FramedBeatmapClock(applyOffsets: true, requireDecoupling: true);
AddInternal(underlyingClock); AddInternal(underlyingClock);
} }
@ -158,8 +158,6 @@ namespace osu.Game.Screens.Edit
public double CurrentTime => underlyingClock.CurrentTime; public double CurrentTime => underlyingClock.CurrentTime;
public double TotalAppliedOffset => underlyingClock.TotalAppliedOffset;
public void Reset() public void Reset()
{ {
ClearTransforms(); ClearTransforms();

View File

@ -94,7 +94,7 @@ namespace osu.Game.Screens.Edit.Timing
controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups); controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups);
controlPointGroups.BindCollectionChanged((_, _) => updateTimingGroup()); controlPointGroups.BindCollectionChanged((_, _) => updateTimingGroup());
beatLength.BindValueChanged(_ => regenerateDisplay(true), true); beatLength.BindValueChanged(_ => Scheduler.AddOnce(regenerateDisplay, true), true);
displayLocked.BindValueChanged(locked => displayLocked.BindValueChanged(locked =>
{ {
@ -186,11 +186,18 @@ namespace osu.Game.Screens.Edit.Timing
return; return;
displayedTime = time; displayedTime = time;
regenerateDisplay(animated); Scheduler.AddOnce(regenerateDisplay, animated);
} }
private void regenerateDisplay(bool animated) private void regenerateDisplay(bool animated)
{ {
// Before a track is loaded, it won't have a valid length, which will break things.
if (!beatmap.Value.Track.IsLoaded)
{
Scheduler.AddOnce(regenerateDisplay, animated);
return;
}
double index = (displayedTime - selectedGroupStartTime) / timingPoint.BeatLength; double index = (displayedTime - selectedGroupStartTime) / timingPoint.BeatLength;
// Chosen as a pretty usable number across all BPMs. // Chosen as a pretty usable number across all BPMs.

View File

@ -36,7 +36,6 @@ namespace osu.Game.Screens.Menu
private Sample welcome; private Sample welcome;
private DecoupleableInterpolatingFramedClock decoupledClock;
private TrianglesIntroSequence intro; private TrianglesIntroSequence intro;
public IntroTriangles([CanBeNull] Func<MainMenu> createNextScreen = null) public IntroTriangles([CanBeNull] Func<MainMenu> createNextScreen = null)
@ -59,18 +58,12 @@ namespace osu.Game.Screens.Menu
{ {
PrepareMenuLoad(); PrepareMenuLoad();
decoupledClock = new DecoupleableInterpolatingFramedClock var decouplingClock = new DecouplingFramedClock(UsingThemedIntro ? Track : null);
{
IsCoupled = false
};
if (UsingThemedIntro)
decoupledClock.ChangeSource(Track);
LoadComponentAsync(intro = new TrianglesIntroSequence(logo, () => FadeInBackground()) LoadComponentAsync(intro = new TrianglesIntroSequence(logo, () => FadeInBackground())
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Clock = decoupledClock, Clock = new InterpolatingFramedClock(decouplingClock),
LoadMenu = LoadMenu LoadMenu = LoadMenu
}, _ => }, _ =>
{ {
@ -94,7 +87,7 @@ namespace osu.Game.Screens.Menu
StartTrack(); StartTrack();
// no-op for the case of themed intro, no harm in calling for both scenarios as a safety measure. // no-op for the case of themed intro, no harm in calling for both scenarios as a safety measure.
decoupledClock.Start(); decouplingClock.Start();
}); });
} }
} }

View File

@ -456,6 +456,7 @@ namespace osu.Game.Screens.OnlinePlay
private IEnumerable<Drawable> createButtons() => new[] private IEnumerable<Drawable> createButtons() => new[]
{ {
beatmap == null ? Empty() : new PlaylistDownloadButton(beatmap),
showResultsButton = new GrayButton(FontAwesome.Solid.ChartPie) showResultsButton = new GrayButton(FontAwesome.Solid.ChartPie)
{ {
Size = new Vector2(30, 30), Size = new Vector2(30, 30),
@ -463,7 +464,6 @@ namespace osu.Game.Screens.OnlinePlay
Alpha = AllowShowingResults ? 1 : 0, Alpha = AllowShowingResults ? 1 : 0,
TooltipText = "View results" TooltipText = "View results"
}, },
beatmap == null ? Empty() : new PlaylistDownloadButton(beatmap),
editButton = new PlaylistEditButton editButton = new PlaylistEditButton
{ {
Size = new Vector2(30, 30), Size = new Vector2(30, 30),

View File

@ -23,7 +23,18 @@ namespace osu.Game.Screens.OnlinePlay
{ {
public partial class FooterButtonFreeMods : FooterButton, IHasCurrentValue<IReadOnlyList<Mod>> public partial class FooterButtonFreeMods : FooterButton, IHasCurrentValue<IReadOnlyList<Mod>>
{ {
public Bindable<IReadOnlyList<Mod>> Current { get; set; } = new BindableWithCurrent<IReadOnlyList<Mod>>(); private readonly BindableWithCurrent<IReadOnlyList<Mod>> current = new BindableWithCurrent<IReadOnlyList<Mod>>(Array.Empty<Mod>());
public Bindable<IReadOnlyList<Mod>> Current
{
get => current.Current;
set
{
ArgumentNullException.ThrowIfNull(value);
current.Current = value;
}
}
private OsuSpriteText count = null!; private OsuSpriteText count = null!;
@ -106,17 +117,17 @@ namespace osu.Game.Screens.OnlinePlay
private void updateModDisplay() private void updateModDisplay()
{ {
int current = Current.Value.Count; int currentCount = Current.Value.Count;
if (current == allAvailableAndValidMods.Count()) if (currentCount == allAvailableAndValidMods.Count())
{ {
count.Text = "all"; count.Text = "all";
count.FadeColour(colours.Gray2, 200, Easing.OutQuint); count.FadeColour(colours.Gray2, 200, Easing.OutQuint);
circle.FadeColour(colours.Yellow, 200, Easing.OutQuint); circle.FadeColour(colours.Yellow, 200, Easing.OutQuint);
} }
else if (current > 0) else if (currentCount > 0)
{ {
count.Text = $"{current} mods"; count.Text = $"{currentCount} mods";
count.FadeColour(colours.Gray2, 200, Easing.OutQuint); count.FadeColour(colours.Gray2, 200, Easing.OutQuint);
circle.FadeColour(colours.YellowDark, 200, Easing.OutQuint); circle.FadeColour(colours.YellowDark, 200, Easing.OutQuint);
} }

View File

@ -49,7 +49,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
protected override void Update() protected override void Update()
{ {
// The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay. // The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay.
if (GameplayClockContainer.SourceClock.IsRunning) if (GameplayClockContainer.IsRunning)
GameplayClockContainer.Start(); GameplayClockContainer.Start();
else else
GameplayClockContainer.Stop(); GameplayClockContainer.Stop();
@ -67,7 +67,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
{ {
var gameplayClockContainer = new GameplayClockContainer(spectatorPlayerClock); // Importantly, we don't want to apply decoupling because SpectatorPlayerClock updates its IsRunning directly.
// If we applied decoupling, this state change wouldn't actually cause the clock to stop.
// TODO: Can we just use Start/Stop rather than this workaround, now that DecouplingClock is more sane?
var gameplayClockContainer = new GameplayClockContainer(spectatorPlayerClock, applyOffsets: false, requireDecoupling: false);
clockAdjustmentsFromMods.BindAdjustments(gameplayClockContainer.AdjustmentsFromMods); clockAdjustmentsFromMods.BindAdjustments(gameplayClockContainer.AdjustmentsFromMods);
return gameplayClockContainer; return gameplayClockContainer;
} }

View File

@ -27,10 +27,10 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Play namespace osu.Game.Screens.Play
{ {
/// <summary> /// <summary>
/// Manage the animation to be applied when a player fails. /// Manage the animation to be applied when a player fails. Applies the animation to children.
/// Single use and automatically disposed after use. /// Single use and automatically disposed after use.
/// </summary> /// </summary>
public partial class FailAnimation : Container public partial class FailAnimationContainer : Container
{ {
public Action? OnComplete; public Action? OnComplete;
@ -66,7 +66,7 @@ namespace osu.Game.Screens.Play
/// </summary> /// </summary>
public BackgroundScreen? Background { private get; set; } public BackgroundScreen? Background { private get; set; }
public FailAnimation(DrawableRuleset drawableRuleset) public FailAnimationContainer(DrawableRuleset drawableRuleset)
{ {
this.drawableRuleset = drawableRuleset; this.drawableRuleset = drawableRuleset;

View File

@ -23,11 +23,6 @@ namespace osu.Game.Screens.Play
public bool IsRewinding => GameplayClock.IsRewinding; public bool IsRewinding => GameplayClock.IsRewinding;
/// <summary>
/// The source clock. Should generally not be used for any timekeeping purposes.
/// </summary>
public IClock SourceClock { get; private set; }
/// <summary> /// <summary>
/// Invoked when a seek has been performed via <see cref="Seek"/> /// Invoked when a seek has been performed via <see cref="Seek"/>
/// </summary> /// </summary>
@ -60,15 +55,14 @@ namespace osu.Game.Screens.Play
/// </summary> /// </summary>
/// <param name="sourceClock">The source <see cref="IClock"/> used for timing.</param> /// <param name="sourceClock">The source <see cref="IClock"/> used for timing.</param>
/// <param name="applyOffsets">Whether to apply platform, user and beatmap offsets to the mix.</param> /// <param name="applyOffsets">Whether to apply platform, user and beatmap offsets to the mix.</param>
public GameplayClockContainer(IClock sourceClock, bool applyOffsets = false) /// <param name="requireDecoupling">Whether decoupling logic should be applied on the source clock.</param>
public GameplayClockContainer(IClock sourceClock, bool applyOffsets, bool requireDecoupling)
{ {
SourceClock = sourceClock;
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false }, GameplayClock = new FramedBeatmapClock(applyOffsets, requireDecoupling, sourceClock),
Content Content
}; };
} }
@ -83,8 +77,6 @@ namespace osu.Game.Screens.Play
isPaused.Value = false; isPaused.Value = false;
ensureSourceClockSet();
PrepareStart(); PrepareStart();
// The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time. // The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time.
@ -153,28 +145,11 @@ namespace osu.Game.Screens.Play
Stop(); Stop();
ensureSourceClockSet();
if (time != null) if (time != null)
StartTime = time.Value; StartTime = time.Value;
Seek(StartTime); Seek(StartTime);
// This is a workaround for the fact that DecoupleableInterpolatingFramedClock doesn't seek the source
// if the source is not IsRunning. (see https://github.com/ppy/osu-framework/blob/2102638056dfcf85d21b4d85266d53b5dd018767/osu.Framework/Timing/DecoupleableInterpolatingFramedClock.cs#L209-L210)
// I hope to remove this once we knock some sense into clocks in general.
//
// Without this seek, the multiplayer spectator start sequence breaks:
// - Individual clients' clocks are never updated to their expected time
// - The sync manager thinks they are running behind
// - Gameplay doesn't start when it should (until a timeout occurs because nothing is happening for 10+ seconds)
//
// In addition, we use `CurrentTime` for this seek instead of `StartTime` as the above seek may have applied inherent
// offsets which need to be accounted for (ie. FramedBeatmapClock.TotalAppliedOffset).
//
// See https://github.com/ppy/osu/pull/24451/files/87fee001c786b29db34063ef3350e9a9f024d3ab#diff-28ca02979641e2d98a15fe5d5e806f56acf60ac100258a059fa72503b6cc54e8.
(SourceClock as IAdjustableClock)?.Seek(CurrentTime);
if (!wasPaused || startClock) if (!wasPaused || startClock)
Start(); Start();
} }
@ -183,20 +158,7 @@ namespace osu.Game.Screens.Play
/// Changes the source clock. /// Changes the source clock.
/// </summary> /// </summary>
/// <param name="sourceClock">The new source.</param> /// <param name="sourceClock">The new source.</param>
protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(SourceClock = sourceClock); protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(sourceClock);
/// <summary>
/// Ensures that the <see cref="GameplayClock"/> is set to <see cref="SourceClock"/>, if it hasn't been given a source yet.
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
/// but not the actual source clock.
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
/// but it is not yet set on the adjustable source there.
/// </summary>
private void ensureSourceClockSet()
{
if (GameplayClock.Source == null)
ChangeSource(SourceClock);
}
#region IAdjustableClock #region IAdjustableClock

View File

@ -5,14 +5,16 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Lines; using osu.Framework.Graphics.Lines;
using osu.Framework.Graphics.Shapes; using osu.Framework.Layout;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -27,6 +29,22 @@ namespace osu.Game.Screens.Play.HUD
{ {
public bool UsesFixedAnchor { get; set; } public bool UsesFixedAnchor { get; set; }
[SettingSource("Bar height")]
public BindableFloat BarHeight { get; } = new BindableFloat(20)
{
MinValue = 0,
MaxValue = 64,
Precision = 1
};
[SettingSource("Bar length")]
public BindableFloat BarLength { get; } = new BindableFloat(0.98f)
{
MinValue = 0.2f,
MaxValue = 1,
Precision = 0.01f,
};
private BarPath mainBar = null!; private BarPath mainBar = null!;
/// <summary> /// <summary>
@ -46,7 +64,7 @@ namespace osu.Game.Screens.Play.HUD
private readonly List<Vector2> missBarVertices = new List<Vector2>(); private readonly List<Vector2> missBarVertices = new List<Vector2>();
private readonly List<Vector2> healthBarVertices = new List<Vector2>(); private readonly List<Vector2> healthBarVertices = new List<Vector2>();
private double glowBarValue = 1; private double glowBarValue;
public double GlowBarValue public double GlowBarValue
{ {
@ -61,7 +79,7 @@ namespace osu.Game.Screens.Play.HUD
} }
} }
private double healthBarValue = 1; private double healthBarValue;
public double HealthBarValue public double HealthBarValue
{ {
@ -76,59 +94,46 @@ namespace osu.Game.Screens.Play.HUD
} }
} }
private const float main_path_radius = 10f;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
AutoSizeAxes = Axes.Both; RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChild = new FillFlowContainer InternalChild = new Container
{ {
AutoSizeAxes = Axes.Both, AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(4f, 0f),
Children = new Drawable[] Children = new Drawable[]
{ {
new Circle background = new BackgroundPath
{ {
Margin = new MarginPadding { Top = 8.5f, Left = -2 }, PathRadius = main_path_radius,
Size = new Vector2(50f, 3f),
}, },
new Container glowBar = new BarPath
{ {
AutoSizeAxes = Axes.Both, BarColour = Color4.White,
Children = new Drawable[] GlowColour = OsuColour.Gray(0.5f),
{ Blending = BlendingParameters.Additive,
background = new BackgroundPath Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.8f), Color4.White),
{ PathRadius = 40f,
PathRadius = 10f, // Kinda hacky, but results in correct positioning with increased path radius.
}, Margin = new MarginPadding(-30f),
glowBar = new BarPath GlowPortion = 0.9f,
{ },
BarColour = Color4.White, mainBar = new BarPath
GlowColour = OsuColour.Gray(0.5f), {
Blending = BlendingParameters.Additive, AutoSizeAxes = Axes.None,
Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.8f), Color4.White), RelativeSizeAxes = Axes.Both,
PathRadius = 40f, Blending = BlendingParameters.Additive,
// Kinda hacky, but results in correct positioning with increased path radius. BarColour = main_bar_colour,
Margin = new MarginPadding(-30f), GlowColour = main_bar_glow_colour,
GlowPortion = 0.9f, PathRadius = main_path_radius,
}, GlowPortion = 0.6f,
mainBar = new BarPath },
{ }
AutoSizeAxes = Axes.None,
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
BarColour = main_bar_colour,
GlowColour = main_bar_glow_colour,
PathRadius = 10f,
GlowPortion = 0.6f,
},
}
}
},
}; };
updatePath();
} }
protected override void LoadComplete() protected override void LoadComplete()
@ -140,10 +145,24 @@ namespace osu.Game.Screens.Play.HUD
if (v.NewValue >= GlowBarValue) if (v.NewValue >= GlowBarValue)
finishMissDisplay(); finishMissDisplay();
this.TransformTo(nameof(HealthBarValue), v.NewValue, 300, Easing.OutQuint); double time = v.NewValue > GlowBarValue ? 500 : 250;
this.TransformTo(nameof(HealthBarValue), v.NewValue, time, Easing.OutQuint);
if (resetMissBarDelegate == null) if (resetMissBarDelegate == null)
this.TransformTo(nameof(GlowBarValue), v.NewValue, 300, Easing.OutQuint); this.TransformTo(nameof(GlowBarValue), v.NewValue, time, Easing.OutQuint);
}, true); }, true);
BarLength.BindValueChanged(l => Width = l.NewValue, true);
BarHeight.BindValueChanged(_ => updatePath());
updatePath();
}
protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source)
{
if ((invalidation & Invalidation.DrawSize) > 0)
updatePath();
return base.OnInvalidate(invalidation, source);
} }
protected override void Update() protected override void Update()
@ -214,25 +233,24 @@ namespace osu.Game.Screens.Play.HUD
private void updatePath() private void updatePath()
{ {
const float curve_start = 280; float barLength = DrawWidth - main_path_radius * 2;
const float curve_end = 310; float curveStart = barLength - 70;
float curveEnd = barLength - 40;
const float curve_smoothness = 10; const float curve_smoothness = 10;
const float bar_length = 350; Vector2 diagonalDir = (new Vector2(curveEnd, BarHeight.Value) - new Vector2(curveStart, 0)).Normalized();
const float bar_verticality = 32.5f;
Vector2 diagonalDir = (new Vector2(curve_end, bar_verticality) - new Vector2(curve_start, 0)).Normalized();
barPath = new SliderPath(new[] barPath = new SliderPath(new[]
{ {
new PathControlPoint(new Vector2(0, 0), PathType.Linear), new PathControlPoint(new Vector2(0, 0), PathType.Linear),
new PathControlPoint(new Vector2(curve_start - curve_smoothness, 0), PathType.Bezier), new PathControlPoint(new Vector2(curveStart - curve_smoothness, 0), PathType.Bezier),
new PathControlPoint(new Vector2(curve_start, 0)), new PathControlPoint(new Vector2(curveStart, 0)),
new PathControlPoint(new Vector2(curve_start, 0) + diagonalDir * curve_smoothness, PathType.Linear), new PathControlPoint(new Vector2(curveStart, 0) + diagonalDir * curve_smoothness, PathType.Linear),
new PathControlPoint(new Vector2(curve_end, bar_verticality) - diagonalDir * curve_smoothness, PathType.Bezier), new PathControlPoint(new Vector2(curveEnd, BarHeight.Value) - diagonalDir * curve_smoothness, PathType.Bezier),
new PathControlPoint(new Vector2(curve_end, bar_verticality)), new PathControlPoint(new Vector2(curveEnd, BarHeight.Value)),
new PathControlPoint(new Vector2(curve_end + curve_smoothness, bar_verticality), PathType.Linear), new PathControlPoint(new Vector2(curveEnd + curve_smoothness, BarHeight.Value), PathType.Linear),
new PathControlPoint(new Vector2(bar_length, bar_verticality)), new PathControlPoint(new Vector2(barLength, BarHeight.Value)),
}); });
List<Vector2> vertices = new List<Vector2>(); List<Vector2> vertices = new List<Vector2>();
@ -267,7 +285,7 @@ namespace osu.Game.Screens.Play.HUD
{ {
protected override Color4 ColourAt(float position) protected override Color4 ColourAt(float position)
{ {
if (position <= 0.128f) if (position <= 0.16f)
return Color4.White.Opacity(0.8f); return Color4.White.Opacity(0.8f);
return Interpolation.ValueAt(position, return Interpolation.ValueAt(position,

View File

@ -29,6 +29,8 @@ namespace osu.Game.Screens.Play.HUD
/// </summary> /// </summary>
public readonly Bindable<bool> ShowHealth = new Bindable<bool>(); public readonly Bindable<bool> ShowHealth = new Bindable<bool>();
protected override bool PlayInitialIncreaseAnimation => false;
private const float max_alpha = 0.4f; private const float max_alpha = 0.4f;
private const int fade_time = 400; private const int fade_time = 400;
private const float gradient_size = 0.2f; private const float gradient_size = 0.2f;

View File

@ -6,7 +6,9 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Threading;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
@ -23,12 +25,18 @@ namespace osu.Game.Screens.Play.HUD
[Resolved] [Resolved]
protected HealthProcessor HealthProcessor { get; private set; } = null!; protected HealthProcessor HealthProcessor { get; private set; } = null!;
public Bindable<double> Current { get; } = new BindableDouble(1) protected virtual bool PlayInitialIncreaseAnimation => true;
public Bindable<double> Current { get; } = new BindableDouble
{ {
MinValue = 0, MinValue = 0,
MaxValue = 1 MaxValue = 1
}; };
private BindableNumber<double> health = null!;
private ScheduledDelegate? initialIncrease;
/// <summary> /// <summary>
/// Triggered when a <see cref="Judgement"/> is a successful hit, signaling the health display to perform a flash animation (if designed to do so). /// Triggered when a <see cref="Judgement"/> is a successful hit, signaling the health display to perform a flash animation (if designed to do so).
/// </summary> /// </summary>
@ -52,14 +60,56 @@ namespace osu.Game.Screens.Play.HUD
{ {
base.LoadComplete(); base.LoadComplete();
Current.BindTo(HealthProcessor.Health);
HealthProcessor.NewJudgement += onNewJudgement; HealthProcessor.NewJudgement += onNewJudgement;
// Don't bind directly so we can animate the startup procedure.
health = HealthProcessor.Health.GetBoundCopy();
health.BindValueChanged(h =>
{
finishInitialAnimation();
Current.Value = h.NewValue;
});
if (hudOverlay != null) if (hudOverlay != null)
showHealthBar.BindTo(hudOverlay.ShowHealthBar); showHealthBar.BindTo(hudOverlay.ShowHealthBar);
// this probably shouldn't be operating on `this.` // this probably shouldn't be operating on `this.`
showHealthBar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); showHealthBar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true);
if (PlayInitialIncreaseAnimation)
startInitialAnimation();
else
Current.Value = 1;
}
private void startInitialAnimation()
{
// TODO: this should run in gameplay time, including showing a larger increase when skipping.
// TODO: it should also start increasing relative to the first hitobject.
const double increase_delay = 150;
initialIncrease = Scheduler.AddDelayed(() =>
{
double newValue = Current.Value + 0.05f;
this.TransformBindableTo(Current, newValue, increase_delay);
Flash(new JudgementResult(new HitObject(), new Judgement()));
if (newValue >= 1)
finishInitialAnimation();
}, increase_delay, true);
}
private void finishInitialAnimation()
{
initialIncrease?.Cancel();
initialIncrease = null;
// aside from the repeating `initialIncrease` scheduled task,
// there may also be a `Current` transform in progress from that schedule.
// ensure it plays out fully, to prevent changes to `Current.Value` being discarded by the ongoing transform.
// and yes, this funky `targetMember` spec is seemingly the only way to do this
// (see: https://github.com/ppy/osu-framework/blob/fe2769171c6e26d1b6fdd6eb7ea8353162fe9065/osu.Framework/Graphics/Transforms/TransformBindable.cs#L21)
FinishTransforms(targetMember: $"{Current.GetHashCode()}.{nameof(Current.Value)}");
} }
private void onNewJudgement(JudgementResult judgement) private void onNewJudgement(JudgementResult judgement)

View File

@ -22,16 +22,16 @@ namespace osu.Game.Screens.Play.HUD.JudgementCounter
public bool UsesFixedAnchor { get; set; } public bool UsesFixedAnchor { get; set; }
[SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayMode))] [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayMode))]
public Bindable<DisplayMode> Mode { get; set; } = new Bindable<DisplayMode>(); public Bindable<DisplayMode> Mode { get; } = new Bindable<DisplayMode>();
[SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.FlowDirection))] [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.FlowDirection))]
public Bindable<Direction> FlowDirection { get; set; } = new Bindable<Direction>(); public Bindable<Direction> FlowDirection { get; } = new Bindable<Direction>();
[SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.ShowJudgementNames))] [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.ShowJudgementNames))]
public BindableBool ShowJudgementNames { get; set; } = new BindableBool(true); public BindableBool ShowJudgementNames { get; } = new BindableBool(true);
[SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.ShowMaxJudgement))] [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.ShowMaxJudgement))]
public BindableBool ShowMaxJudgement { get; set; } = new BindableBool(true); public BindableBool ShowMaxJudgement { get; } = new BindableBool(true);
[Resolved] [Resolved]
private JudgementCountController judgementCountController { get; set; } = null!; private JudgementCountController judgementCountController { get; set; } = null!;

View File

@ -18,7 +18,7 @@ namespace osu.Game.Screens.Play.HUD
{ {
[SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.CornerRadius), nameof(SkinnableComponentStrings.CornerRadiusDescription), [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.CornerRadius), nameof(SkinnableComponentStrings.CornerRadiusDescription),
SettingControlType = typeof(SettingsPercentageSlider<float>))] SettingControlType = typeof(SettingsPercentageSlider<float>))]
public new BindableFloat CornerRadius { get; set; } = new BindableFloat(0.25f) public new BindableFloat CornerRadius { get; } = new BindableFloat(0.25f)
{ {
MinValue = 0, MinValue = 0,
MaxValue = 0.5f, MaxValue = 0.5f,

View File

@ -65,7 +65,7 @@ namespace osu.Game.Screens.Play
/// <param name="beatmap">The beatmap to be used for time and metadata references.</param> /// <param name="beatmap">The beatmap to be used for time and metadata references.</param>
/// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param> /// <param name="skipTargetTime">The latest time which should be used when introducing gameplay. Will be used when skipping forward.</param>
public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime) public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime)
: base(beatmap.Track, true) : base(beatmap.Track, applyOffsets: true, requireDecoupling: true)
{ {
this.beatmap = beatmap; this.beatmap = beatmap;
this.skipTargetTime = skipTargetTime; this.skipTargetTime = skipTargetTime;
@ -187,7 +187,13 @@ namespace osu.Game.Screens.Play
public void StopUsingBeatmapClock() public void StopUsingBeatmapClock()
{ {
removeSourceClockAdjustments(); removeSourceClockAdjustments();
ChangeSource(new TrackVirtual(beatmap.Track.Length));
var virtualTrack = new TrackVirtual(beatmap.Track.Length);
virtualTrack.Seek(CurrentTime);
if (IsRunning)
virtualTrack.Start();
ChangeSource(virtualTrack);
addSourceClockAdjustments(); addSourceClockAdjustments();
} }

View File

@ -257,7 +257,7 @@ namespace osu.Game.Screens.Play
rulesetSkinProvider.AddRange(new Drawable[] rulesetSkinProvider.AddRange(new Drawable[]
{ {
failAnimationLayer = new FailAnimation(DrawableRuleset) failAnimationContainer = new FailAnimationContainer(DrawableRuleset)
{ {
OnComplete = onFailComplete, OnComplete = onFailComplete,
Children = new[] Children = new[]
@ -310,7 +310,7 @@ namespace osu.Game.Screens.Play
// add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components.
// also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.)
// we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there.
failAnimationLayer.Add(createOverlayComponents(Beatmap.Value)); failAnimationContainer.Add(createOverlayComponents(Beatmap.Value));
if (!DrawableRuleset.AllowGameplayOverlays) if (!DrawableRuleset.AllowGameplayOverlays)
{ {
@ -587,7 +587,7 @@ namespace osu.Game.Screens.Play
// if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion). // if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion).
if (ValidForResume && GameplayState.HasFailed) if (ValidForResume && GameplayState.HasFailed)
{ {
failAnimationLayer.FinishTransforms(true); failAnimationContainer.FinishTransforms(true);
return; return;
} }
@ -888,7 +888,7 @@ namespace osu.Game.Screens.Play
protected FailOverlay FailOverlay { get; private set; } protected FailOverlay FailOverlay { get; private set; }
private FailAnimation failAnimationLayer; private FailAnimationContainer failAnimationContainer;
private bool onFail() private bool onFail()
{ {
@ -913,7 +913,7 @@ namespace osu.Game.Screens.Play
if (PauseOverlay.State.Value == Visibility.Visible) if (PauseOverlay.State.Value == Visibility.Visible)
PauseOverlay.Hide(); PauseOverlay.Hide();
failAnimationLayer.Start(); failAnimationContainer.Start();
if (GameplayState.Mods.OfType<IApplicableFailOverride>().Any(m => m.RestartOnFail)) if (GameplayState.Mods.OfType<IApplicableFailOverride>().Any(m => m.RestartOnFail))
Restart(true); Restart(true);
@ -1044,7 +1044,7 @@ namespace osu.Game.Screens.Play
b.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); b.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground);
failAnimationLayer.Background = b; failAnimationContainer.Background = b;
}); });
HUDOverlay.IsPlaying.BindTo(localUserPlaying); HUDOverlay.IsPlaying.BindTo(localUserPlaying);
@ -1099,7 +1099,7 @@ namespace osu.Game.Screens.Play
screenSuspension?.RemoveAndDisposeImmediately(); screenSuspension?.RemoveAndDisposeImmediately();
// Eagerly clean these up as disposal of child components is asynchronous and may leave sounds playing beyond user expectations. // Eagerly clean these up as disposal of child components is asynchronous and may leave sounds playing beyond user expectations.
failAnimationLayer?.Stop(); failAnimationContainer?.Stop();
PauseOverlay?.StopAllSamples(); PauseOverlay?.StopAllSamples();
if (LoadedBeatmapSuccessfully) if (LoadedBeatmapSuccessfully)

View File

@ -138,7 +138,7 @@ namespace osu.Game.Screens.Select
[Resolved] [Resolved]
internal IOverlayManager? OverlayManager { get; private set; } internal IOverlayManager? OverlayManager { get; private set; }
private Bindable<bool> configBackgroundBlur { get; set; } = new BindableBool(); private Bindable<bool> configBackgroundBlur = null!;
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(AudioManager audio, OsuColour colours, ManageCollectionsDialog? manageCollectionsDialog, DifficultyRecommender? recommender, OsuConfigManager config) private void load(AudioManager audio, OsuColour colours, ManageCollectionsDialog? manageCollectionsDialog, DifficultyRecommender? recommender, OsuConfigManager config)

View File

@ -109,6 +109,7 @@ namespace osu.Game.Skinning
case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: case SkinComponentsContainerLookup.TargetArea.MainHUDComponents:
var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container =>
{ {
var health = container.OfType<ArgonHealthDisplay>().FirstOrDefault();
var score = container.OfType<DefaultScoreCounter>().FirstOrDefault(); var score = container.OfType<DefaultScoreCounter>().FirstOrDefault();
var accuracy = container.OfType<DefaultAccuracyCounter>().FirstOrDefault(); var accuracy = container.OfType<DefaultAccuracyCounter>().FirstOrDefault();
var combo = container.OfType<DefaultComboCounter>().FirstOrDefault(); var combo = container.OfType<DefaultComboCounter>().FirstOrDefault();
@ -128,6 +129,13 @@ namespace osu.Game.Skinning
score.Position = new Vector2(0, vertical_offset); score.Position = new Vector2(0, vertical_offset);
if (health != null)
{
health.Origin = Anchor.TopCentre;
health.Anchor = Anchor.TopCentre;
health.Y = 5;
}
if (ppCounter != null) if (ppCounter != null)
{ {
ppCounter.Y = score.Position.Y + ppCounter.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).Y - 4; ppCounter.Y = score.Position.Y + ppCounter.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).Y - 4;
@ -191,7 +199,7 @@ namespace osu.Game.Skinning
new DefaultComboCounter(), new DefaultComboCounter(),
new DefaultScoreCounter(), new DefaultScoreCounter(),
new DefaultAccuracyCounter(), new DefaultAccuracyCounter(),
new DefaultHealthDisplay(), new ArgonHealthDisplay(),
new ArgonSongProgress(), new ArgonSongProgress(),
new ArgonKeyCounterDisplay(), new ArgonKeyCounterDisplay(),
new BarHitErrorMeter(), new BarHitErrorMeter(),

View File

@ -30,7 +30,7 @@ namespace osu.Game.Skinning.Components
public Bindable<BeatmapAttribute> Attribute { get; } = new Bindable<BeatmapAttribute>(BeatmapAttribute.StarRating); public Bindable<BeatmapAttribute> Attribute { get; } = new Bindable<BeatmapAttribute>(BeatmapAttribute.StarRating);
[SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Template), nameof(BeatmapAttributeTextStrings.TemplateDescription))] [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Template), nameof(BeatmapAttributeTextStrings.TemplateDescription))]
public Bindable<string> Template { get; set; } = new Bindable<string>("{Label}: {Value}"); public Bindable<string> Template { get; } = new Bindable<string>("{Label}: {Value}");
[Resolved] [Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!; private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;

View File

@ -66,6 +66,7 @@ namespace osu.Game.Skinning
marker.Current.BindTo(Current); marker.Current.BindTo(Current);
maxFillWidth = fill.Width; maxFillWidth = fill.Width;
fill.Width = 0;
} }
protected override void Update() protected override void Update()

View File

@ -9,6 +9,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osuTK; using osuTK;
@ -112,9 +113,11 @@ namespace osu.Game.Skinning
if (texture.DisplayWidth <= maxSize.X && texture.DisplayHeight <= maxSize.Y) if (texture.DisplayWidth <= maxSize.X && texture.DisplayHeight <= maxSize.Y)
return texture; return texture;
// use scale adjust property for downscaling the texture in order to meet the specified maximum dimensions. maxSize *= texture.ScaleAdjust;
texture.ScaleAdjust *= Math.Max(texture.DisplayWidth / maxSize.X, texture.DisplayHeight / maxSize.Y);
return texture; var croppedTexture = texture.Crop(new RectangleF(texture.Width / 2f - maxSize.X / 2f, texture.Height / 2f - maxSize.Y / 2f, maxSize.X, maxSize.Y));
croppedTexture.ScaleAdjust = texture.ScaleAdjust;
return croppedTexture;
} }
public static bool HasFont(this ISkin source, LegacyFont font) public static bool HasFont(this ISkin source, LegacyFont font)

View File

@ -88,7 +88,7 @@ namespace osu.Game.Skinning
} }
Samples = samples; Samples = samples;
Textures = new TextureStore(resources.Renderer, new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage))); Textures = new TextureStore(resources.Renderer, CreateTextureLoaderStore(resources, storage));
} }
else else
{ {
@ -171,6 +171,9 @@ namespace osu.Game.Skinning
} }
} }
protected virtual IResourceStore<TextureUpload> CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore<byte[]> storage)
=> new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage));
protected virtual void ParseConfigurationStream(Stream stream) protected virtual void ParseConfigurationStream(Stream stream)
{ {
using (LineBufferedReader reader = new LineBufferedReader(stream, true)) using (LineBufferedReader reader = new LineBufferedReader(stream, true))

View File

@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Realm" Version="11.5.0" /> <PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.922.0" /> <PackageReference Include="ppy.osu.Framework" Version="2023.1006.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.1003.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2023.1003.0" />
<PackageReference Include="Sentry" Version="3.39.1" /> <PackageReference Include="Sentry" Version="3.39.1" />
<PackageReference Include="SharpCompress" Version="0.33.0" /> <PackageReference Include="SharpCompress" Version="0.33.0" />

View File

@ -23,6 +23,6 @@
<RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier> <RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2023.922.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2023.1006.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>