diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml
new file mode 100644
index 0000000000..4e221d0550
--- /dev/null
+++ b/.github/workflows/_diffcalc_processor.yml
@@ -0,0 +1,228 @@
+name: "🔒diffcalc (do not use)"
+
+on:
+ workflow_call:
+ inputs:
+ id:
+ type: string
+ head-sha:
+ type: string
+ pr-url:
+ type: string
+ pr-text:
+ type: string
+ dispatch-inputs:
+ type: string
+ outputs:
+ target:
+ description: The comparison target.
+ value: ${{ jobs.generator.outputs.target }}
+ sheet:
+ description: The comparison spreadsheet.
+ value: ${{ jobs.generator.outputs.sheet }}
+ secrets:
+ DIFFCALC_GOOGLE_CREDENTIALS:
+ required: true
+
+env:
+ GENERATOR_DIR: ${{ github.workspace }}/${{ inputs.id }}
+ GENERATOR_ENV: ${{ github.workspace }}/${{ inputs.id }}/.env
+
+defaults:
+ run:
+ shell: bash -euo pipefail {0}
+
+jobs:
+ generator:
+ name: Run
+ runs-on: self-hosted
+ timeout-minutes: 720
+
+ outputs:
+ target: ${{ steps.run.outputs.target }}
+ sheet: ${{ steps.run.outputs.sheet }}
+
+ steps:
+ - name: Checkout diffcalc-sheet-generator
+ uses: actions/checkout@v4
+ with:
+ path: ${{ inputs.id }}
+ repository: 'smoogipoo/diffcalc-sheet-generator'
+
+ - name: Add base environment
+ env:
+ GOOGLE_CREDS_FILE: ${{ github.workspace }}/${{ inputs.id }}/google-credentials.json
+ VARS_JSON: ${{ (vars != null && toJSON(vars)) || '' }}
+ run: |
+ # Required by diffcalc-sheet-generator
+ cp '${{ env.GENERATOR_DIR }}/.env.sample' "${{ env.GENERATOR_ENV }}"
+
+ # Add Google credentials
+ echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ env.GOOGLE_CREDS_FILE }}"
+
+ # Add repository variables
+ echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do
+ opt=$(jq -r '.key' <<< ${line})
+ val=$(jq -r '.value' <<< ${line})
+
+ if [[ "${opt}" =~ ^DIFFCALC_ ]]; then
+ optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-)
+ sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ env.GENERATOR_ENV }}"
+ fi
+ done
+
+ - name: Add HEAD environment
+ run: |
+ sed -i "s;^OSU_A=.*$;OSU_A=${{ inputs.head-sha }};" "${{ env.GENERATOR_ENV }}"
+
+ - name: Add pull-request environment
+ if: ${{ inputs.pr-url != '' }}
+ run: |
+ sed -i "s;^OSU_B=.*$;OSU_B=${{ inputs.pr-url }};" "${{ env.GENERATOR_ENV }}"
+
+ - name: Add comment environment
+ if: ${{ inputs.pr-text != '' }}
+ env:
+ PR_TEXT: ${{ inputs.pr-text }}
+ run: |
+ # Add comment environment
+ echo "${PR_TEXT}" | sed -r 's/\r$//' | { grep -E '^\w+=' || true; } | while read -r line; do
+ opt=$(echo "${line}" | cut -d '=' -f1)
+ sed -i "s;^${opt}=.*$;${line};" "${{ env.GENERATOR_ENV }}"
+ done
+
+ - name: Add dispatch environment
+ if: ${{ inputs.dispatch-inputs != '' }}
+ env:
+ DISPATCH_INPUTS_JSON: ${{ inputs.dispatch-inputs }}
+ run: |
+ function get_input() {
+ echo "${DISPATCH_INPUTS_JSON}" | jq -r ".\"$1\""
+ }
+
+ osu_a=$(get_input 'osu-a')
+ osu_b=$(get_input 'osu-b')
+ ruleset=$(get_input 'ruleset')
+ generators=$(get_input 'generators')
+ difficulty_calculator_a=$(get_input 'difficulty-calculator-a')
+ difficulty_calculator_b=$(get_input 'difficulty-calculator-b')
+ score_processor_a=$(get_input 'score-processor-a')
+ score_processor_b=$(get_input 'score-processor-b')
+ converts=$(get_input 'converts')
+ ranked_only=$(get_input 'ranked-only')
+
+ sed -i "s;^OSU_B=.*$;OSU_B=${osu_b};" "${{ env.GENERATOR_ENV }}"
+ sed -i "s/^RULESET=.*$/RULESET=${ruleset}/" "${{ env.GENERATOR_ENV }}"
+ sed -i "s/^GENERATORS=.*$/GENERATORS=${generators}/" "${{ env.GENERATOR_ENV }}"
+
+ if [[ "${osu_a}" != 'latest' ]]; then
+ sed -i "s;^OSU_A=.*$;OSU_A=${osu_a};" "${{ env.GENERATOR_ENV }}"
+ fi
+
+ if [[ "${difficulty_calculator_a}" != 'latest' ]]; then
+ sed -i "s;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${difficulty_calculator_a};" "${{ env.GENERATOR_ENV }}"
+ fi
+
+ if [[ "${difficulty_calculator_b}" != 'latest' ]]; then
+ sed -i "s;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${difficulty_calculator_b};" "${{ env.GENERATOR_ENV }}"
+ fi
+
+ if [[ "${score_processor_a}" != 'latest' ]]; then
+ sed -i "s;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${score_processor_a};" "${{ env.GENERATOR_ENV }}"
+ fi
+
+ if [[ "${score_processor_b}" != 'latest' ]]; then
+ sed -i "s;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${score_processor_b};" "${{ env.GENERATOR_ENV }}"
+ fi
+
+ if [[ "${converts}" == 'true' ]]; then
+ sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ env.GENERATOR_ENV }}"
+ else
+ sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ env.GENERATOR_ENV }}"
+ fi
+
+ if [[ "${ranked_only}" == 'true' ]]; then
+ sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ env.GENERATOR_ENV }}"
+ else
+ sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ env.GENERATOR_ENV }}"
+ fi
+
+ - name: Query latest scores
+ id: query-scores
+ run: |
+ ruleset=$(cat ${{ env.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=${{ env.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
+ echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
+ echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
+
+ - name: Restore score cache
+ id: restore-score-cache
+ uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
+ with:
+ path: ${{ steps.query-scores.outputs.DATA_PKG }}
+ key: ${{ steps.query-scores.outputs.DATA_NAME }}
+
+ - name: Download scores
+ if: steps.restore-score-cache.outputs.cache-hit != 'true'
+ run: |
+ wget -q -O "${{ steps.query-scores.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-scores.outputs.DATA_PKG }}"
+
+ - name: Extract scores
+ run: |
+ tar -I lbzip2 -xf "${{ steps.query-scores.outputs.DATA_PKG }}"
+ rm -r "${{ steps.query-scores.outputs.TARGET_DIR }}"
+ mv "${{ steps.query-scores.outputs.DATA_NAME }}" "${{ steps.query-scores.outputs.TARGET_DIR }}"
+
+ - name: Query latest beatmaps
+ id: query-beatmaps
+ 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=${{ env.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
+ echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
+ echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
+
+ - name: Restore beatmap cache
+ id: restore-beatmap-cache
+ uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
+ with:
+ path: ${{ steps.query-beatmaps.outputs.DATA_PKG }}
+ key: ${{ steps.query-beatmaps.outputs.DATA_NAME }}
+
+ - name: Download beatmap
+ if: steps.restore-beatmap-cache.outputs.cache-hit != 'true'
+ run: |
+ wget -q -O "${{ steps.query-beatmaps.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-beatmaps.outputs.DATA_PKG }}"
+
+ - name: Extract beatmap
+ run: |
+ tar -I lbzip2 -xf "${{ steps.query-beatmaps.outputs.DATA_PKG }}"
+ rm -r "${{ steps.query-beatmaps.outputs.TARGET_DIR }}"
+ mv "${{ steps.query-beatmaps.outputs.DATA_NAME }}" "${{ steps.query-beatmaps.outputs.TARGET_DIR }}"
+
+ - 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 }}/' "${{ env.GENERATOR_ENV }}"
+
+ cd "${{ env.GENERATOR_DIR }}"
+
+ docker compose up --build --detach
+ docker compose logs --follow &
+ docker compose wait generator
+
+ link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
+ target=$(cat "${{ env.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
+
+ echo "target=${target}" >> "${GITHUB_OUTPUT}"
+ echo "sheet=${link}" >> "${GITHUB_OUTPUT}"
+
+ - name: Shutdown
+ if: ${{ always() }}
+ run: |
+ cd "${{ env.GENERATOR_DIR }}"
+ docker compose down --volumes
+ rm -rf "${{ env.GENERATOR_DIR }}"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6fbb74dfba..d75f09f184 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,7 +88,7 @@ jobs:
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
- name: Upload Test Results
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
@@ -114,7 +114,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET workloads
- run: dotnet workload install maui-android
+ run: dotnet workload install android
- name: Compile
run: dotnet build -c Debug osu.Android.slnf
@@ -133,10 +133,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
- run: dotnet workload install maui-ios
-
- - name: Select Xcode 16
- run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer
+ run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json
- name: Build
run: dotnet build -c Debug osu.iOS
diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml
index 9f129a697c..4297a88e89 100644
--- a/.github/workflows/diffcalc.yml
+++ b/.github/workflows/diffcalc.yml
@@ -103,6 +103,10 @@ permissions:
env:
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
+defaults:
+ run:
+ shell: bash -euo pipefail {0}
+
jobs:
check-permissions:
name: Check permissions
@@ -119,6 +123,20 @@ jobs:
done
exit 1
+ run-diffcalc:
+ name: Run spreadsheet generator
+ needs: check-permissions
+ uses: ./.github/workflows/_diffcalc_processor.yml
+ with:
+ # Can't reference env... Why GitHub, WHY?
+ id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
+ head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }}
+ pr-url: ${{ github.event.issue.pull_request.html_url || '' }}
+ pr-text: ${{ github.event.comment.body || '' }}
+ dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }}
+ secrets:
+ DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}
+
create-comment:
name: Create PR comment
needs: check-permissions
@@ -134,251 +152,43 @@ jobs:
*This comment will update on completion*
- directory:
- name: Prepare directory
- needs: check-permissions
- runs-on: self-hosted
- outputs:
- GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }}
- GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }}
- GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }}
- steps:
- - name: Checkout diffcalc-sheet-generator
- uses: actions/checkout@v4
- with:
- path: ${{ env.EXECUTION_ID }}
- repository: 'smoogipoo/diffcalc-sheet-generator'
-
- - name: Set outputs
- id: set-outputs
- run: |
- echo "GENERATOR_DIR=${{ github.workspace }}/${{ env.EXECUTION_ID }}" >> "${GITHUB_OUTPUT}"
- echo "GENERATOR_ENV=${{ github.workspace }}/${{ env.EXECUTION_ID }}/.env" >> "${GITHUB_OUTPUT}"
- echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/${{ env.EXECUTION_ID }}/google-credentials.json" >> "${GITHUB_OUTPUT}"
-
- environment:
- name: Setup environment
- needs: directory
- runs-on: self-hosted
- env:
- VARS_JSON: ${{ toJSON(vars) }}
- steps:
- - name: Add base environment
- run: |
- # Required by diffcalc-sheet-generator
- cp '${{ needs.directory.outputs.GENERATOR_DIR }}/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}"
-
- # Add Google credentials
- echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}"
-
- # Add repository variables
- echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do
- opt=$(jq -r '.key' <<< ${line})
- val=$(jq -r '.value' <<< ${line})
-
- if [[ "${opt}" =~ ^DIFFCALC_ ]]; then
- optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-)
- sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- fi
- done
-
- - name: Add pull-request environment
- if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
- run: |
- sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.html_url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
-
- - name: Add comment environment
- if: ${{ github.event_name == 'issue_comment' }}
- env:
- COMMENT_BODY: ${{ github.event.comment.body }}
- run: |
- # Add comment environment
- echo "$COMMENT_BODY" | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do
- opt=$(echo "${line}" | cut -d '=' -f1)
- sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- done
-
- - name: Add dispatch environment
- if: ${{ github.event_name == 'workflow_dispatch' }}
- run: |
- sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
-
- if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then
- sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- fi
-
- 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
- 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@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
- 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
- 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@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
- 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
- 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 -v
-
output-cli:
- name: Output info
- needs: generator
+ name: Info
+ needs: run-diffcalc
runs-on: ubuntu-latest
steps:
- name: Output info
run: |
- echo "Target: ${{ needs.generator.outputs.TARGET }}"
- echo "Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}"
-
- cleanup:
- name: Cleanup
- needs: [ directory, generator ]
- if: ${{ always() && needs.directory.result == 'success' }}
- runs-on: self-hosted
- steps:
- - name: Cleanup
- run: |
- rm -rf "${{ needs.directory.outputs.GENERATOR_DIR }}"
+ echo "Target: ${{ needs.run-diffcalc.outputs.target }}"
+ echo "Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}"
update-comment:
name: Update PR comment
- needs: [ create-comment, generator ]
+ needs: [ create-comment, run-diffcalc ]
runs-on: ubuntu-latest
if: ${{ always() && needs.create-comment.result == 'success' }}
steps:
- name: Update comment on success
- if: ${{ needs.generator.result == 'success' }}
+ if: ${{ needs.run-diffcalc.result == 'success' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
- mode: upsert
- create_if_not_exists: false
+ mode: recreate
message: |
- Target: ${{ needs.generator.outputs.TARGET }}
- Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}
+ Target: ${{ needs.run-diffcalc.outputs.target }}
+ Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}
- name: Update comment on failure
- if: ${{ needs.generator.result == 'failure' }}
+ if: ${{ needs.run-diffcalc.result == 'failure' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
- mode: upsert
- create_if_not_exists: false
+ mode: recreate
message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update comment on cancellation
- if: ${{ needs.generator.result == 'cancelled' }}
+ if: ${{ needs.run-diffcalc.result == 'cancelled' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml
index c44f46d70a..14f0208fc8 100644
--- a/.github/workflows/report-nunit.yml
+++ b/.github/workflows/report-nunit.yml
@@ -5,33 +5,40 @@
name: Annotate CI run with test results
on:
workflow_run:
- workflows: ["Continuous Integration"]
+ workflows: [ "Continuous Integration" ]
types:
- completed
-permissions: {}
+
+permissions:
+ contents: read
+ actions: read
+ checks: write
+
jobs:
annotate:
- permissions:
- checks: write # to create checks (dorny/test-reporter)
-
name: Annotate CI run with test results
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion != 'cancelled' }}
- strategy:
- fail-fast: false
- matrix:
- os:
- - { prettyname: Windows }
- - { prettyname: macOS }
- - { prettyname: Linux }
- threadingMode: ['SingleThread', 'MultiThreaded']
timeout-minutes: 5
steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ github.event.workflow_run.repository.full_name }}
+ ref: ${{ github.event.workflow_run.head_sha }}
+
+ - name: Download results
+ uses: actions/download-artifact@v4
+ with:
+ pattern: osu-test-results-*
+ merge-multiple: true
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ github.token }}
+
- name: Annotate CI run with test results
uses: dorny/test-reporter@v1.8.0
with:
- artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
- name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}})
+ name: Results
path: "*.trx"
reporter: dotnet-trx
list-suites: 'failed'
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 5b7a98f4ba..0793dcc76c 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,5 +1,6 @@
{
"recommendations": [
- "ms-dotnettools.csharp"
+ "editorconfig.editorconfig",
+ "ms-dotnettools.csdevkit"
]
}
diff --git a/README.md b/README.md
index cb722e5df3..6043497181 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed.
-When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed.
+When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed.
### Downloading the source code
diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
index 7d43eb2b05..f77cda1533 100644
--- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
+++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj
@@ -9,7 +9,7 @@
false
-
+
diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index 7dc8a1336b..47cabaddb1 100644
--- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -9,7 +9,7 @@
false
-
+
diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
index 9c4c8217f0..a7d62291d0 100644
--- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj
@@ -9,7 +9,7 @@
false
-
+
diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
index 7dc8a1336b..47cabaddb1 100644
--- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
+++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj
@@ -9,7 +9,7 @@
false
-
+
diff --git a/osu.Android.props b/osu.Android.props
index 6b42258b49..4699beeac0 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -10,7 +10,7 @@
true
-
+
Release Difference / ms
// release_threshold
if (isOverlapping)
- holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime)));
+ holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
// Decay and increase individualStrains in own column
individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs
index 6a12ec5088..5cfcf00b33 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs
@@ -3,21 +3,39 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
-using osu.Game.Rulesets.Mania.Skinning.Default;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
- public partial class EditBodyPiece : DefaultBodyPiece
+ public partial class EditBodyPiece : CompositeDrawable
{
+ private readonly Container border;
+
+ public EditBodyPiece()
+ {
+ InternalChildren = new Drawable[]
+ {
+ border = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Masking = true,
+ BorderThickness = 3,
+ Child = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Alpha = 0,
+ AlwaysPresent = true,
+ },
+ },
+ };
+ }
+
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
- AccentColour.Value = colours.Yellow;
-
- Background.Alpha = 0.5f;
+ border.BorderColour = colours.YellowDarker;
}
-
- protected override Drawable CreateForeground() => base.CreateForeground().With(d => d.Alpha = 0);
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs
index 0aa72c28b8..d4b61b4661 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs
@@ -4,6 +4,7 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
@@ -26,10 +27,11 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
Height = DefaultNotePiece.NOTE_HEIGHT;
- CornerRadius = 5;
- Masking = true;
-
- InternalChild = new DefaultNotePiece();
+ InternalChild = new EditNotePiece
+ {
+ RelativeSizeAxes = Axes.Both,
+ Height = 1,
+ };
}
protected override void LoadComplete()
@@ -60,19 +62,23 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
base.OnDrag(e);
Dragging?.Invoke(e.ScreenSpaceMousePosition);
+ updateState();
}
protected override void OnDragEnd(DragEndEvent e)
{
base.OnDragEnd(e);
DragEnded?.Invoke();
+ updateState();
}
private void updateState()
{
+ InternalChild.Colour = Colour4.White;
+
var colour = colours.Yellow;
- if (IsHovered)
+ if (IsHovered || IsDragged)
colour = colour.Lighten(1);
Colour = colour;
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs
index 48dde29a9f..f68004db28 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs
@@ -2,28 +2,63 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
+using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
-using osu.Game.Rulesets.Mania.Skinning.Default;
+using osu.Game.Rulesets.Mania.UI;
+using osu.Game.Rulesets.UI.Scrolling;
+using osuTK;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
public partial class EditNotePiece : CompositeDrawable
{
+ private readonly Container border;
+ private readonly Box box;
+
+ [Resolved]
+ private Column? column { get; set; }
+
public EditNotePiece()
{
- Height = DefaultNotePiece.NOTE_HEIGHT;
-
- CornerRadius = 5;
- Masking = true;
-
- InternalChild = new DefaultNotePiece();
+ InternalChildren = new Drawable[]
+ {
+ border = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Masking = true,
+ BorderThickness = 3,
+ Child = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Alpha = 0,
+ AlwaysPresent = true,
+ },
+ },
+ box = new Box
+ {
+ RelativeSizeAxes = Axes.X,
+ Height = 3,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ },
+ };
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
- Colour = colours.Yellow;
+ border.BorderColour = colours.YellowDark;
+ box.Colour = colours.YellowLight;
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (column != null)
+ Scale = new Vector2(1, column.ScrollingInfo.Direction.Value == ScrollingDirection.Down ? 1 : -1);
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs
index 991b7f476c..13cfc5f691 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs
@@ -4,8 +4,10 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
+using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
@@ -17,9 +19,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class HoldNotePlacementBlueprint : ManiaPlacementBlueprint
{
- private readonly EditBodyPiece bodyPiece;
- private readonly EditNotePiece headPiece;
- private readonly EditNotePiece tailPiece;
+ private EditBodyPiece bodyPiece = null!;
+ private Circle headPiece = null!;
+ private Circle tailPiece = null!;
[Resolved]
private IScrollingInfo scrollingInfo { get; set; } = null!;
@@ -28,14 +30,29 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
public HoldNotePlacementBlueprint()
: base(new HoldNote())
+ {
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
{
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
bodyPiece = new EditBodyPiece { Origin = Anchor.TopCentre },
- headPiece = new EditNotePiece { Origin = Anchor.Centre },
- tailPiece = new EditNotePiece { Origin = Anchor.Centre }
+ headPiece = new Circle
+ {
+ Origin = Anchor.Centre,
+ Colour = colours.Yellow,
+ Height = 10
+ },
+ tailPiece = new Circle
+ {
+ Origin = Anchor.Centre,
+ Colour = colours.Yellow,
+ Height = 10
+ },
};
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
index b8e6aa26a0..915706c044 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
@@ -2,14 +2,14 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
-using osu.Framework.Graphics.Shapes;
-using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Mania.Objects.Drawables;
+using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK;
@@ -17,9 +17,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint
{
- [Resolved]
- private OsuColour colours { get; set; } = null!;
-
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
@@ -29,9 +26,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
[Resolved]
private IPositionSnapProvider? positionSnapProvider { get; set; }
+ private EditBodyPiece body = null!;
private EditHoldNoteEndPiece head = null!;
private EditHoldNoteEndPiece tail = null!;
+ protected new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject;
+
public HoldNoteSelectionBlueprint(HoldNote hold)
: base(hold)
{
@@ -42,9 +42,17 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
InternalChildren = new Drawable[]
{
+ body = new EditBodyPiece
+ {
+ RelativeSizeAxes = Axes.Both,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ },
head = new EditHoldNoteEndPiece
{
RelativeSizeAxes = Axes.X,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
DragStarted = () => changeHandler?.BeginChange(),
Dragging = pos =>
{
@@ -64,6 +72,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
tail = new EditHoldNoteEndPiece
{
RelativeSizeAxes = Axes.X,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
DragStarted = () => changeHandler?.BeginChange(),
Dragging = pos =>
{
@@ -79,19 +89,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
},
DragEnded = () => changeHandler?.EndChange(),
},
- new Container
- {
- RelativeSizeAxes = Axes.Both,
- Masking = true,
- BorderThickness = 1,
- BorderColour = colours.Yellow,
- Child = new Box
- {
- RelativeSizeAxes = Axes.Both,
- Alpha = 0,
- AlwaysPresent = true,
- }
- }
};
}
@@ -99,11 +96,23 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
base.Update();
+ head.Height = DrawableObject.Head.DrawHeight;
head.Y = HitObjectContainer.PositionAtTime(HitObject.Head.StartTime, HitObject.StartTime);
+ tail.Height = DrawableObject.Tail.DrawHeight;
tail.Y = HitObjectContainer.PositionAtTime(HitObject.Tail.StartTime, HitObject.StartTime);
Height = HitObjectContainer.LengthAtTime(HitObject.StartTime, HitObject.EndTime) + tail.DrawHeight;
}
+ protected override void OnDirectionChanged(ValueChangedEvent direction)
+ {
+ Origin = direction.NewValue == ScrollingDirection.Down ? Anchor.BottomCentre : Anchor.TopCentre;
+
+ foreach (var child in InternalChildren)
+ child.Anchor = Origin;
+
+ head.Scale = tail.Scale = body.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1);
+ }
+
public override Quad SelectionQuad => ScreenSpaceDrawQuad;
public override Vector2 ScreenSpaceSelectionPoint => head.ScreenSpaceDrawQuad.Centre;
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs
index c645ddd98d..4bb9d5f5c1 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs
@@ -37,16 +37,10 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override void LoadComplete()
{
base.LoadComplete();
- directionBindable.BindValueChanged(onDirectionChanged, true);
+ directionBindable.BindValueChanged(OnDirectionChanged, true);
}
- private void onDirectionChanged(ValueChangedEvent direction)
- {
- var anchor = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
- Anchor = Origin = anchor;
- foreach (var child in InternalChildren)
- child.Anchor = child.Origin = anchor;
- }
+ protected abstract void OnDirectionChanged(ValueChangedEvent direction);
protected override void Update()
{
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs
index b3ec3ef3e4..422215db57 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs
@@ -1,10 +1,12 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
+using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
-using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
using osuTK.Input;
@@ -12,14 +14,25 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class NotePlacementBlueprint : ManiaPlacementBlueprint
{
- private readonly EditNotePiece piece;
+ private Circle piece = null!;
public NotePlacementBlueprint()
: base(new Note())
{
- RelativeSizeAxes = Axes.Both;
+ }
- InternalChild = piece = new EditNotePiece { Origin = Anchor.Centre };
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ RelativeSizeAxes = Axes.Both;
+ Masking = true;
+
+ InternalChild = piece = new Circle
+ {
+ Origin = Anchor.Centre,
+ Colour = colours.Yellow,
+ Height = 10
+ };
}
public override void UpdateTimeAndPosition(SnapResult result)
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs
index 01c7bd502a..3476f91568 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs
@@ -1,18 +1,42 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.UI.Scrolling;
+using osuTK;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class NoteSelectionBlueprint : ManiaSelectionBlueprint
{
+ private readonly EditNotePiece notePiece;
+
public NoteSelectionBlueprint(Note note)
: base(note)
{
- AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X });
+ Anchor = Anchor.BottomCentre;
+ Origin = Anchor.BottomCentre;
+ AddInternal(notePiece = new EditNotePiece
+ {
+ RelativeSizeAxes = Axes.X,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ });
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ notePiece.Height = DrawableObject.DrawHeight;
+ }
+
+ protected override void OnDirectionChanged(ValueChangedEvent direction)
+ {
+ notePiece.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1);
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
index 4c4cf519ce..181bc7341c 100644
--- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
+++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
@@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override void Update()
{
- TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value;
+ TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value;
base.Update();
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/EditorColumn.cs b/osu.Game.Rulesets.Mania/Edit/EditorColumn.cs
new file mode 100644
index 0000000000..11d1848173
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Edit/EditorColumn.cs
@@ -0,0 +1,45 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Game.Rulesets.Mania.Objects.Drawables;
+using osu.Game.Rulesets.Mania.UI;
+using osu.Game.Rulesets.Objects.Drawables;
+
+namespace osu.Game.Rulesets.Mania.Edit
+{
+ public partial class EditorColumn : Column
+ {
+ public EditorColumn(int index, bool isSpecial)
+ : base(index, isSpecial)
+ {
+ }
+
+ protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObject)
+ {
+ base.OnNewDrawableHitObject(drawableHitObject);
+ drawableHitObject.ApplyCustomUpdateState += (dho, state) =>
+ {
+ switch (dho)
+ {
+ // hold note heads are exempt from what follows due to the "freezing" mechanic
+ // which already ensures they'll never fade away on their own.
+ case DrawableHoldNoteHead:
+ break;
+
+ // mania features instantaneous hitobject fade-outs.
+ // this means that without manual intervention stopping the clock at the precise time of hitting the object
+ // means the object will fade out.
+ // this is anti-user in editor contexts, as the user is expecting to continue the see the note on the receptor line.
+ // therefore, apply a crude workaround to prevent it from going away.
+ default:
+ {
+ if (state == ArmedState.Hit)
+ dho.FadeTo(1).Delay(1).FadeOut().Expire();
+ break;
+ }
+ }
+ };
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Edit/EditorStage.cs b/osu.Game.Rulesets.Mania/Edit/EditorStage.cs
new file mode 100644
index 0000000000..c5f93f6182
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Edit/EditorStage.cs
@@ -0,0 +1,18 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.UI;
+
+namespace osu.Game.Rulesets.Mania.Edit
+{
+ public partial class EditorStage : Stage
+ {
+ public EditorStage(int firstColumnIndex, StageDefinition definition, ref ManiaAction columnStartAction)
+ : base(firstColumnIndex, definition, ref columnStartAction)
+ {
+ }
+
+ protected override Column CreateColumn(int index, bool isSpecial) => new EditorColumn(index, isSpecial);
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs
index 77e372d1d6..2dc2b8ae48 100644
--- a/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs
@@ -13,5 +13,8 @@ namespace osu.Game.Rulesets.Mania.Edit
: base(stages)
{
}
+
+ protected override Stage CreateStage(int firstColumnIndex, StageDefinition stageDefinition, ref ManiaAction columnAction)
+ => new EditorStage(firstColumnIndex, stageDefinition, ref columnAction);
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
index e3b4fa2fb7..926a4b2736 100644
--- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
@@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Mania.Edit
base.Update();
if (screenWithTimeline?.TimelineArea.Timeline != null)
- drawableRuleset.TimelineTimeRange = EditorClock.TrackLength / screenWithTimeline.TimelineArea.Timeline.CurrentZoom / 2;
+ drawableRuleset.TimelineTimeRange = EditorClock.TrackLength / screenWithTimeline.TimelineArea.Timeline.CurrentZoom.Value / 2;
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
index 7e0991a4d4..74e616ac3f 100644
--- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
@@ -54,9 +54,8 @@ namespace osu.Game.Rulesets.Mania.Edit
int firstColumn = flipOverOrigin ? 0 : selectedObjects.Min(ho => ho.Column);
int lastColumn = flipOverOrigin ? (int)EditorBeatmap.BeatmapInfo.Difficulty.CircleSize - 1 : selectedObjects.Max(ho => ho.Column);
- EditorBeatmap.PerformOnSelection(hitObject =>
+ performOnSelection(maniaObject =>
{
- var maniaObject = (ManiaHitObject)hitObject;
maniaPlayfield.Remove(maniaObject);
maniaObject.Column = firstColumn + (lastColumn - maniaObject.Column);
maniaPlayfield.Add(maniaObject);
@@ -71,7 +70,7 @@ namespace osu.Game.Rulesets.Mania.Edit
double selectionStartTime = selectedObjects.Min(ho => ho.StartTime);
double selectionEndTime = selectedObjects.Max(ho => ho.GetEndTime());
- EditorBeatmap.PerformOnSelection(hitObject =>
+ performOnSelection(hitObject =>
{
hitObject.StartTime = selectionStartTime + (selectionEndTime - hitObject.GetEndTime());
});
@@ -117,14 +116,21 @@ namespace osu.Game.Rulesets.Mania.Edit
columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn);
- EditorBeatmap.PerformOnSelection(h =>
+ performOnSelection(h =>
{
maniaPlayfield.Remove(h);
- ((ManiaHitObject)h).Column += columnDelta;
+ h.Column += columnDelta;
maniaPlayfield.Add(h);
});
+ }
- // `HitObjectUsageEventBuffer`'s usage transferal flows and the playfield's `SetKeepAlive()` functionality do not combine well with this operation's usage pattern,
+ private void performOnSelection(Action action)
+ {
+ var selectedObjects = EditorBeatmap.SelectedHitObjects.OfType().ToArray();
+
+ EditorBeatmap.PerformOnSelection(h => action.Invoke((ManiaHitObject)h));
+
+ // `HitObjectUsageEventBuffer`'s usage transferal flows and the playfield's `SetKeepAlive()` functionality do not combine well with mania's usage patterns,
// leading to selections being sometimes partially dropped if some of the objects being moved are off screen
// (check blame for detailed explanation).
// thus, ensure that selection is preserved manually.
diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs
index 7168504309..a23988362a 100644
--- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs
@@ -19,12 +19,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
- private LabelledSliderBar keyCountSlider { get; set; } = null!;
- private LabelledSwitchButton specialStyle { get; set; } = null!;
- private LabelledSliderBar healthDrainSlider { get; set; } = null!;
- private LabelledSliderBar overallDifficultySlider { get; set; } = null!;
- private LabelledSliderBar baseVelocitySlider { get; set; } = null!;
- private LabelledSliderBar tickRateSlider { get; set; } = null!;
+ private FormSliderBar keyCountSlider { get; set; } = null!;
+ private FormCheckBox specialStyle { get; set; } = null!;
+ private FormSliderBar healthDrainSlider { get; set; } = null!;
+ private FormSliderBar overallDifficultySlider { get; set; } = null!;
+ private FormSliderBar baseVelocitySlider { get; set; } = null!;
+ private FormSliderBar tickRateSlider { get; set; } = null!;
[Resolved]
private Editor? editor { get; set; }
@@ -37,77 +37,81 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
Children = new Drawable[]
{
- keyCountSlider = new LabelledSliderBar
+ keyCountSlider = new FormSliderBar
{
- Label = BeatmapsetsStrings.ShowStatsCsMania,
- FixedLabelWidth = LABEL_WIDTH,
- Description = "The number of columns in the beatmap",
+ Caption = BeatmapsetsStrings.ShowStatsCsMania,
+ HintText = "The number of columns in the beatmap",
Current = new BindableFloat(Beatmap.Difficulty.CircleSize)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 1,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
- specialStyle = new LabelledSwitchButton
+ specialStyle = new FormCheckBox
{
- Label = "Use special (N+1) style",
- FixedLabelWidth = LABEL_WIDTH,
- Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.",
+ Caption = "Use special (N+1) style",
+ HintText = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.",
Current = { Value = Beatmap.BeatmapInfo.SpecialStyle }
},
- healthDrainSlider = new LabelledSliderBar
+ healthDrainSlider = new FormSliderBar
{
- Label = BeatmapsetsStrings.ShowStatsDrain,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.DrainRateDescription,
+ Caption = BeatmapsetsStrings.ShowStatsDrain,
+ HintText = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
- overallDifficultySlider = new LabelledSliderBar
+ overallDifficultySlider = new FormSliderBar
{
- Label = BeatmapsetsStrings.ShowStatsAccuracy,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.OverallDifficultyDescription,
+ Caption = BeatmapsetsStrings.ShowStatsAccuracy,
+ HintText = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
- baseVelocitySlider = new LabelledSliderBar
+ baseVelocitySlider = new FormSliderBar
{
- Label = EditorSetupStrings.BaseVelocity,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.BaseVelocityDescription,
+ Caption = EditorSetupStrings.BaseVelocity,
+ HintText = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
MinValue = 0.4,
MaxValue = 3.6,
Precision = 0.01f,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
- tickRateSlider = new LabelledSliderBar
+ tickRateSlider = new FormSliderBar
{
- Label = EditorSetupStrings.TickRate,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.TickRateDescription,
+ Caption = EditorSetupStrings.TickRate,
+ HintText = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
MinValue = 1,
MaxValue = 4,
Precision = 1,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
};
diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index c01fa508fe..cdc7b0a951 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -419,9 +419,12 @@ namespace osu.Game.Rulesets.Mania
return new ManiaFilterCriteria();
}
- public override IEnumerable CreateEditorSetupSections() =>
+ public override IEnumerable CreateEditorSetupSections() =>
[
+ new MetadataSection(),
new ManiaDifficultySection(),
+ new ResourcesSection(),
+ new DesignSection(),
];
public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList? mods = null)
diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
index 30eca0636c..17add32513 100644
--- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
+++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs
@@ -33,11 +33,11 @@ namespace osu.Game.Rulesets.Mania
LabelText = RulesetSettingsStrings.ScrollingDirection,
Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection)
},
- new SettingsSlider
+ new SettingsSlider
{
LabelText = RulesetSettingsStrings.ScrollSpeed,
- Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed),
- KeyboardStep = 5
+ Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed),
+ KeyboardStep = 1
},
new SettingsCheckbox
{
@@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania
};
}
- private partial class ManiaScrollSlider : RoundedSliderBar
+ private partial class ManiaScrollSlider : RoundedSliderBar
{
public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value);
}
diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs
index 0444394d87..dfd6ed6dd2 100644
--- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs
+++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs
@@ -9,6 +9,7 @@ using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
+using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mania.Scoring
{
@@ -58,6 +59,24 @@ namespace osu.Game.Rulesets.Mania.Scoring
return GetBaseScoreForResult(result);
}
+ public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary results)
+ {
+ ScoreRank rank = base.RankFromScore(accuracy, results);
+
+ if (rank != ScoreRank.S)
+ return rank;
+
+ // SS is expected as long as all hitobjects have been hit with either a GREAT or PERFECT result.
+
+ bool anyImperfect =
+ results.GetValueOrDefault(HitResult.Good) > 0
+ || results.GetValueOrDefault(HitResult.Ok) > 0
+ || results.GetValueOrDefault(HitResult.Meh) > 0
+ || results.GetValueOrDefault(HitResult.Miss) > 0;
+
+ return anyImperfect ? rank : ScoreRank.X;
+ }
+
private class JudgementOrderComparer : IComparer
{
public static readonly JudgementOrderComparer DEFAULT = new JudgementOrderComparer();
diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs
index 758c8dd347..71618a4bc3 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs
@@ -54,7 +54,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
},
columnBackgrounds = new ColumnFlow(stageDefinition)
{
- RelativeSizeAxes = Axes.Y
+ RelativeSizeAxes = Axes.Y,
+ Masking = false,
},
new HitTargetInsetContainer
{
@@ -126,8 +127,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
},
new Container
{
+ X = isLastColumn ? -0.16f : 0,
Anchor = Anchor.TopRight,
- Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.Y,
Width = rightLineWidth,
Scale = new Vector2(0.740f, 1),
diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
index f444448797..5614a13a48 100644
--- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
+++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
@@ -28,6 +28,12 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly FillFlowContainer> columns;
private readonly StageDefinition stageDefinition;
+ public new bool Masking
+ {
+ get => base.Masking;
+ set => base.Masking = value;
+ }
+
public ColumnFlow(StageDefinition stageDefinition)
{
this.stageDefinition = stageDefinition;
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
index aed53e157a..d173ae4143 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
@@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.UI
protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config;
private readonly Bindable configDirection = new Bindable();
- private readonly BindableInt configScrollSpeed = new BindableInt();
+ private readonly BindableDouble configScrollSpeed = new BindableDouble();
private double currentTimeRange;
protected double TargetTimeRange;
@@ -160,7 +160,7 @@ namespace osu.Game.Rulesets.Mania.UI
///
/// The scroll speed.
/// The scroll time.
- public static double ComputeScrollTime(int scrollSpeed) => MAX_TIME_RANGE / scrollSpeed;
+ public static double ComputeScrollTime(double scrollSpeed) => MAX_TIME_RANGE / scrollSpeed;
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer();
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
index 1f388144bd..a4ebb3347a 100644
--- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
+++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
@@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using System;
using System.Collections.Generic;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Mania.Beatmaps;
@@ -71,7 +72,7 @@ namespace osu.Game.Rulesets.Mania.UI
for (int i = 0; i < stageDefinitions.Count; i++)
{
- var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref columnAction);
+ var newStage = CreateStage(firstColumnIndex, stageDefinitions[i], ref columnAction);
playfieldGrid.Content[0][i] = newStage;
@@ -82,6 +83,9 @@ namespace osu.Game.Rulesets.Mania.UI
}
}
+ [Pure]
+ protected virtual Stage CreateStage(int firstColumnIndex, StageDefinition stageDefinition, ref ManiaAction columnAction) => new Stage(firstColumnIndex, stageDefinition, ref columnAction);
+
public override void Add(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Add(hitObject);
public override bool Remove(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Remove(hitObject);
diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs
index 86f2243561..9fb77a4995 100644
--- a/osu.Game.Rulesets.Mania/UI/Stage.cs
+++ b/osu.Game.Rulesets.Mania/UI/Stage.cs
@@ -3,6 +3,7 @@
using System;
using System.Linq;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
@@ -134,12 +135,14 @@ namespace osu.Game.Rulesets.Mania.UI
{
bool isSpecial = definition.IsSpecialColumn(i);
- var column = new Column(firstColumnIndex + i, isSpecial)
+ var action = columnStartAction;
+ columnStartAction++;
+ var column = CreateColumn(firstColumnIndex + i, isSpecial).With(c =>
{
- RelativeSizeAxes = Axes.Both,
- Width = 1,
- Action = { Value = columnStartAction++ }
- };
+ c.RelativeSizeAxes = Axes.Both;
+ c.Width = 1;
+ c.Action.Value = action;
+ });
topLevelContainer.Add(column.TopLevelContainer.CreateProxy());
columnBackgrounds.Add(column.BackgroundContainer.CreateProxy());
@@ -154,6 +157,9 @@ namespace osu.Game.Rulesets.Mania.UI
RegisterPool(50, 200);
}
+ [Pure]
+ protected virtual Column CreateColumn(int index, bool isSpecial) => new Column(index, isSpecial);
+
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj
index cc0233d7fd..cd4fbcc00e 100644
--- a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj
+++ b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj
@@ -17,4 +17,8 @@
+
+
+
+
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs
index b70ecfbba8..fb109ba6f9 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs
@@ -9,6 +9,7 @@ using osu.Framework.Utils;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
+using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Visual;
using osu.Game.Utils;
@@ -129,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void gridActive(bool active) where T : PositionSnapGrid
{
+ AddAssert($"grid type is {typeof(T).Name}", () => this.ChildrenOfType().Any());
AddStep("choose placement tool", () => InputManager.Key(Key.Number2));
AddStep("move cursor to spacing + (1, 1)", () =>
{
@@ -161,7 +163,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
return grid switch
{
RectangularPositionSnapGrid rectangular => rectangular.StartPosition.Value + GeometryUtils.RotateVector(rectangular.Spacing.Value, -rectangular.GridLineRotation.Value),
- TriangularPositionSnapGrid triangular => triangular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(triangular.Spacing.Value / 2, triangular.Spacing.Value / 2 * MathF.Sqrt(3)), -triangular.GridLineRotation.Value),
+ TriangularPositionSnapGrid triangular => triangular.StartPosition.Value + GeometryUtils.RotateVector(
+ new Vector2(triangular.Spacing.Value / 2, triangular.Spacing.Value / 2 * MathF.Sqrt(3)), -triangular.GridLineRotation.Value),
CircularPositionSnapGrid circular => circular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(circular.Spacing.Value, 0), -45),
_ => Vector2.Zero
};
@@ -170,7 +173,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestGridSizeToggling()
{
- AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
+ AddStep("enable rectangular grid", () => InputManager.Key(Key.Y));
AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any());
gridSizeIs(4);
@@ -189,5 +192,97 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void gridSizeIs(int size)
=> AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing.Value == new Vector2(size)
&& EditorBeatmap.BeatmapInfo.GridSize == size);
+
+ [Test]
+ public void TestGridTypeToggling()
+ {
+ AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
+ AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any());
+ gridActive(true);
+
+ nextGridTypeIs();
+ nextGridTypeIs();
+ nextGridTypeIs();
+ }
+
+ private void nextGridTypeIs() where T : PositionSnapGrid
+ {
+ AddStep("toggle to next grid type", () =>
+ {
+ InputManager.PressKey(Key.ShiftLeft);
+ InputManager.Key(Key.G);
+ InputManager.ReleaseKey(Key.ShiftLeft);
+ });
+ gridActive(true);
+ }
+
+ [Test]
+ public void TestGridPlacementTool()
+ {
+ AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
+
+ AddStep("start grid placement", () => InputManager.Key(Key.Number5));
+ AddStep("move cursor to slider head + (1, 1)", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).Position + new Vector2(1, 1)));
+ });
+ AddStep("left click", () => InputManager.Click(MouseButton.Left));
+ AddStep("move cursor to slider tail + (1, 1)", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1)));
+ });
+ AddStep("left click", () => InputManager.Click(MouseButton.Left));
+
+ gridActive(true);
+ AddAssert("grid position at slider head", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).Position, composer.StartPosition.Value);
+ });
+ AddAssert("grid spacing is distance to slider tail", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01)
+ && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y);
+ });
+ AddAssert("grid rotation points to slider tail", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ return Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01);
+ });
+
+ AddStep("start grid placement", () => InputManager.Key(Key.Number5));
+ AddStep("move cursor to slider tail + (1, 1)", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1)));
+ });
+ AddStep("double click", () =>
+ {
+ InputManager.Click(MouseButton.Left);
+ InputManager.Click(MouseButton.Left);
+ });
+ AddStep("move cursor to (0, 0)", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ InputManager.MoveMouseTo(composer.ToScreenSpace(Vector2.Zero));
+ });
+
+ gridActive(true);
+ AddAssert("grid position at slider tail", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).EndPosition, composer.StartPosition.Value);
+ });
+ AddAssert("grid spacing and rotation unchanged", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01)
+ && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y)
+ && Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01);
+ });
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs
new file mode 100644
index 0000000000..0e36c1dc45
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs
@@ -0,0 +1,150 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Input;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.UI;
+using osu.Game.Screens.Edit.Components.RadioButtons;
+using osu.Game.Screens.Edit.Compose.Components;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ [TestFixture]
+ public partial class TestSceneSliderDrawing : TestSceneOsuEditor
+ {
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
+
+ [Test]
+ public void TestTouchInputPlaceHitCircleDirectly()
+ {
+ AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle")));
+
+ AddStep("tap to place circle", () => tap(this.ChildrenOfType().Single()));
+ AddAssert("circle placed correctly", () =>
+ {
+ var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
+ Assert.Multiple(() =>
+ {
+ Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f));
+ Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f));
+ });
+
+ return true;
+ });
+ }
+
+ [Test]
+ public void TestTouchInputPlaceCircleAfterTouchingComposeArea()
+ {
+ AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle")));
+
+ AddStep("tap playfield", () => tap(this.ChildrenOfType().Single()));
+ AddAssert("circle placed", () => EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate) is HitCircle);
+
+ AddStep("move forward", () => InputManager.Key(Key.Right));
+
+ AddStep("tap to place circle", () => tap(this.ChildrenOfType().Single()));
+ AddAssert("circle placed correctly", () =>
+ {
+ var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
+ Assert.Multiple(() =>
+ {
+ Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f));
+ Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f));
+ });
+
+ return true;
+ });
+ }
+
+ [Test]
+ public void TestTouchInputPlaceSliderDirectly()
+ {
+ AddStep("tap slider", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "Slider")));
+
+ AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(50, 20)))));
+ AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(200, 50)))));
+ AddAssert("selection not initiated", () => this.ChildrenOfType().All(d => d.State == Visibility.Hidden));
+ AddAssert("blueprint visible", () => this.ChildrenOfType().Single().Alpha > 0);
+ AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
+ AddAssert("slider placed correctly", () =>
+ {
+ var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
+ Assert.Multiple(() =>
+ {
+ Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f));
+ Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f));
+ Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2));
+ Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
+
+ // the final position may be slightly off from the mouse position when drawing, account for that.
+ Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5));
+ Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5));
+ });
+
+ return true;
+ });
+ }
+
+ [Test]
+ public void TestTouchInputPlaceSliderAfterTouchingComposeArea()
+ {
+ AddStep("tap slider", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "Slider")));
+
+ AddStep("tap playfield", () => tap(this.ChildrenOfType().Single()));
+ AddStep("tap and hold another spot", () => hold(this.ChildrenOfType().Single(), new Vector2(50, 0)));
+ AddUntilStep("wait for slider placement", () => EditorBeatmap.HitObjects.SingleOrDefault(h => h.StartTime == EditorClock.CurrentTimeAccurate) is Slider);
+ AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
+
+ AddStep("move forward", () => InputManager.Key(Key.Right));
+
+ AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(50, 20)))));
+ AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(200, 50)))));
+ AddAssert("selection not initiated", () => this.ChildrenOfType().All(d => d.State == Visibility.Hidden));
+ AddAssert("blueprint visible", () => this.ChildrenOfType().Single().IsPresent);
+ AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
+ AddAssert("slider placed correctly", () =>
+ {
+ var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
+ Assert.Multiple(() =>
+ {
+ Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f));
+ Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f));
+ Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2));
+ Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
+
+ // the final position may be slightly off from the mouse position when drawing, account for that.
+ Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5));
+ Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5));
+ });
+
+ return true;
+ });
+ }
+
+ private void tap(Drawable drawable, Vector2 offset = default) => tap(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset));
+
+ private void tap(Vector2 position)
+ {
+ hold(position);
+ InputManager.EndTouch(new Touch(TouchSource.Touch1, position));
+ }
+
+ private void hold(Drawable drawable, Vector2 offset = default) => hold(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset));
+
+ private void hold(Vector2 position)
+ {
+ InputManager.BeginTouch(new Touch(TouchSource.Touch1, position));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
index 019565ae29..5831cc0a8a 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
@@ -4,6 +4,7 @@
using System;
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Rulesets.Edit;
@@ -392,6 +393,29 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertFinalControlPointType(3, null);
}
+ [Test]
+ public void TestSliderDrawingViaTouch()
+ {
+ Vector2 startPoint = new Vector2(200);
+
+ AddStep("move mouse to a random point", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(Vector2.Zero)));
+ AddStep("begin touch at start point", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(startPoint))));
+
+ for (int i = 1; i < 20; i++)
+ addTouchMovementStep(startPoint + new Vector2(i * 40, MathF.Sin(i * MathF.PI / 5) * 50));
+
+ AddStep("release touch at end point", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
+
+ assertPlaced(true);
+ assertLength(808, tolerance: 10);
+ assertControlPointCount(5);
+ assertFinalControlPointType(0, PathType.BSpline(4));
+ assertFinalControlPointType(1, null);
+ assertFinalControlPointType(2, null);
+ assertFinalControlPointType(3, null);
+ assertFinalControlPointType(4, null);
+ }
+
[Test]
public void TestPlacePerfectCurveSegmentAlmostLinearlyExterior()
{
@@ -492,6 +516,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position)));
+ private void addTouchMovementStep(Vector2 position) => AddStep($"move touch1 to {position}", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(position))));
+
private void addClickStep(MouseButton button)
{
AddStep($"click {button}", () => InputManager.Click(button));
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs
index d68cbe6265..d5bacc25bc 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs
@@ -180,7 +180,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
if (slider == null) return;
- sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70);
+ sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70, editorAutoBank: false);
slider.Samples.Add(sample.With());
});
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs
new file mode 100644
index 0000000000..d5ab349a16
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs
@@ -0,0 +1,55 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Visual;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ public partial class TestSceneToolSwitching : EditorTestScene
+ {
+ protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
+
+ [Test]
+ public void TestSliderAnchorMoveOperationEndsOnSwitchingTool()
+ {
+ var initialPosition = Vector2.Zero;
+
+ AddStep("store original anchor position", () => initialPosition = EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints.ElementAt(1).Position);
+ AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First()));
+ AddStep("move to second anchor", () => InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1)));
+ AddStep("start dragging", () => InputManager.PressButton(MouseButton.Left));
+ AddStep("drag away", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200)));
+ AddStep("switch tool", () => InputManager.PressButton(MouseButton.Button1));
+ AddStep("undo", () => Editor.Undo());
+ AddAssert("anchor back at original position",
+ () => EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints.ElementAt(1).Position,
+ () => Is.EqualTo(initialPosition));
+ }
+
+ [Test]
+ public void TestSliderAnchorCreationOperationEndsOnSwitchingTool()
+ {
+ AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First()));
+ AddStep("move to second anchor", () => InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1), new Vector2(-50, 0)));
+ AddStep("quick-create anchor", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.PressButton(MouseButton.Left);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddStep("drag away", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200)));
+ AddStep("switch tool", () => InputManager.PressKey(Key.Number3));
+ AddStep("drag away further", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200)));
+ AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First()));
+ AddStep("undo", () => Editor.Undo());
+ AddAssert("slider has three anchors again", () => EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints, () => Has.Count.EqualTo(3));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs
index 7375617aa8..27ff26b438 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModAlternate(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 4,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModAlternate(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModAlternate(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 1,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -131,7 +131,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModAlternate(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
Breaks =
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs
index ace7f23989..8786b17b92 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs
@@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
CreateModTest(new ModTestData
{
Autoplay = true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Autoplay = true,
Mod = mod,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects =
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs
index 472c341bdd..ca752fe918 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs
@@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
public void TestNoAdjustment() => CreateModTest(new ModTestData
{
Mod = new OsuModDifficultyAdjust(),
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
BeatmapInfo = new BeatmapInfo
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs
index 075fdd88ca..6bd3f25bdb 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs
@@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Player.GameplayClockContainer.CurrentTime < 1000 && Player.ChildrenOfType.Flashlight>().Single().FlashlightDim > 0;
return Player.GameplayState.HasPassed && !sliderDimmedBeforeStartTime;
},
- Beatmap = new OsuBeatmap
+ CreateBeatmap = () => new OsuBeatmap
{
HitObjects = new List
{
@@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Player.GameplayClockContainer.CurrentTime >= 1000 && Player.ChildrenOfType.Flashlight>().Single().FlashlightDim > 0;
return Player.GameplayState.HasPassed && sliderDimmed;
},
- Beatmap = new OsuBeatmap
+ CreateBeatmap = () => new OsuBeatmap
{
HitObjects = new List
{
@@ -153,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Player.GameplayClockContainer.CurrentTime >= 1000 && Player.ChildrenOfType.Flashlight>().Single().FlashlightDim > 0;
return Player.GameplayState.HasPassed && sliderDimmed;
},
- Beatmap = new OsuBeatmap
+ CreateBeatmap = () => new OsuBeatmap
{
HitObjects = new List
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs
index 58bdd805c1..c513f98f21 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = new TestOsuModHidden(),
Autoplay = true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -63,7 +63,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = new TestOsuModHidden(),
Autoplay = true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = new TestOsuModHidden(),
Autoplay = true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -122,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = new OsuModHidden { OnlyFadeApproachCircles = { Value = true } },
Autoplay = true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs
new file mode 100644
index 0000000000..076cb9ae15
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs
@@ -0,0 +1,64 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Framework.Utils;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Beatmaps;
+using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Objects.Drawables;
+using osu.Game.Rulesets.Osu.UI;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Mods
+{
+ public partial class TestSceneOsuModMirror : OsuModTestScene
+ {
+ [Test]
+ public void TestCorrectReflections([Values] OsuModMirror.MirrorType type, [Values] bool withStrictTracking) => CreateModTest(new ModTestData
+ {
+ Autoplay = true,
+ CreateBeatmap = () => new OsuBeatmap
+ {
+ HitObjects =
+ {
+ new Slider
+ {
+ Position = new Vector2(0),
+ Path = new SliderPath
+ {
+ ControlPoints =
+ {
+ new PathControlPoint(),
+ new PathControlPoint(new Vector2(100, 0))
+ }
+ },
+ TickDistanceMultiplier = 0.5,
+ RepeatCount = 1,
+ }
+ }
+ },
+ Mods = withStrictTracking
+ ? [new OsuModMirror { Reflection = { Value = type } }, new OsuModStrictTracking()]
+ : [new OsuModMirror { Reflection = { Value = type } }],
+ PassCondition = () =>
+ {
+ var slider = this.ChildrenOfType().SingleOrDefault();
+ var playfield = this.ChildrenOfType().Single();
+
+ if (slider == null)
+ return false;
+
+ return Precision.AlmostEquals(playfield.ToLocalSpace(slider.HeadCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position)
+ && Precision.AlmostEquals(playfield.ToLocalSpace(slider.TailCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position)
+ && Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType().Single().ScreenSpaceDrawQuad.Centre),
+ slider.HitObject.Position + slider.HitObject.Path.PositionAt(1))
+ && Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType().First().ScreenSpaceDrawQuad.Centre),
+ slider.HitObject.Position + slider.HitObject.Path.PositionAt(0.7f));
+ }
+ });
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs
index d3996ebc3b..b9559aeba3 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs
@@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
},
Autoplay = true,
PassCondition = () => true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
},
Autoplay = true,
PassCondition = () => true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -113,7 +113,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
},
Autoplay = true,
PassCondition = () => true,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs
index b01bbbfca1..8498e53bf0 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs
@@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModPerfect(),
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(true),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs
index 060a845137..3456fcbe84 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
AngleSharpness = { Value = angleSharpness }
},
- Beatmap = jumpBeatmap,
+ CreateBeatmap = jumpBeatmap,
Autoplay = true,
PassCondition = () => true
});
@@ -50,15 +50,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
AngleSharpness = { Value = angleSharpness }
},
- Beatmap = streamBeatmap,
+ CreateBeatmap = streamBeatmap,
Autoplay = true,
PassCondition = () => true
});
- private OsuBeatmap jumpBeatmap =>
+ private OsuBeatmap jumpBeatmap() =>
createHitCircleBeatmap(new[] { 100, 200, 300, 400 }, 8, 300, 2 * 300);
- private OsuBeatmap streamBeatmap =>
+ private OsuBeatmap streamBeatmap() =>
createHitCircleBeatmap(new[] { 10, 20, 30, 40, 50, 60, 70, 80 }, 16, 150, 4 * 150);
private OsuBeatmap createHitCircleBeatmap(IEnumerable spacings, int objectsPerSpacing, int interval, int beatLength)
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
index bd2b205ac8..b0be70e85e 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 2,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 1,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -130,7 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModSingleTap(),
PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2,
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
Breaks =
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs
index de3ea5f148..3706b9ac07 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs
@@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = new OsuModSpunOut(),
Autoplay = false,
- Beatmap = singleSpinnerBeatmap,
+ CreateBeatmap = singleSpinnerBeatmap,
PassCondition = () =>
{
// Bind to the first spinner's results for further tracking.
@@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mods = mods,
Autoplay = false,
- Beatmap = singleSpinnerBeatmap,
+ CreateBeatmap = singleSpinnerBeatmap,
PassCondition = () =>
{
var counter = Player.ChildrenOfType().SingleOrDefault();
@@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = new OsuModSpunOut(),
Autoplay = false,
- Beatmap = singleSpinnerBeatmap,
+ CreateBeatmap = singleSpinnerBeatmap,
PassCondition = () =>
{
// Bind to the first spinner's results for further tracking.
@@ -130,7 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
});
}
- private Beatmap singleSpinnerBeatmap => new Beatmap
+ private Beatmap singleSpinnerBeatmap() => new Beatmap
{
HitObjects = new List
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs
index 726b415977..66a60e3542 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = new OsuModStrictTracking(),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs
index ea048aaa6e..688cf70f71 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs
@@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModSuddenDeath(),
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(false),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Mod = new OsuModSuddenDeath(),
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(true),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
index e35cf10d95..efda3fa369 100644
--- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
@@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
- [TestCase(6.710442985146793d, 239, "diffcalc-test")]
- [TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
- [TestCase(0.42506480230838789d, 4, "very-fast-slider")]
- [TestCase(0.14102693012101306d, 2, "nan-slider")]
+ [TestCase(6.7171144000821119d, 239, "diffcalc-test")]
+ [TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
+ [TestCase(0.42630400627180914d, 4, "very-fast-slider")]
+ [TestCase(0.14143808967817237d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
- [TestCase(8.9742952703071666d, 239, "diffcalc-test")]
- [TestCase(1.743180218215227d, 54, "zero-length-sliders")]
- [TestCase(0.55071082800473514d, 4, "very-fast-slider")]
+ [TestCase(8.9825709931204205d, 239, "diffcalc-test")]
+ [TestCase(1.7550169162648608d, 54, "zero-length-sliders")]
+ [TestCase(0.55231632896800109d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
- [TestCase(6.710442985146793d, 239, "diffcalc-test")]
- [TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
- [TestCase(0.42506480230838789d, 4, "very-fast-slider")]
+ [TestCase(6.7171144000821119d, 239, "diffcalc-test")]
+ [TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
+ [TestCase(0.42630400627180914d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs
index c37660831b..7a89140fc4 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
Autoplay = false,
Mod = new TestAutoMod(),
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = { new HitCircle { Position = new Vector2(256, 192) } }
},
@@ -47,18 +47,16 @@ namespace osu.Game.Rulesets.Osu.Tests
[Test]
public void TestMissViaNotHitting()
{
- var beatmap = new Beatmap
- {
- HitObjects = { new HitCircle { Position = new Vector2(256, 192) } }
- };
-
var hitWindows = new OsuHitWindows();
- hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+ hitWindows.SetDifficulty(IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY);
CreateModTest(new ModTestData
{
Autoplay = false,
- Beatmap = beatmap,
+ CreateBeatmap = () => new Beatmap
+ {
+ HitObjects = { new HitCircle { Position = new Vector2(256, 192) } }
+ },
PassCondition = () => Player.Results.Count > 0 && Player.Results[0].TimeOffset >= hitWindows.WindowFor(HitResult.Meh) && !Player.Results[0].IsHit
});
}
diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
index ea54c8d313..5ea231e606 100644
--- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
+++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
@@ -1,7 +1,7 @@
-
+
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
index 3d1939acac..9816f6d0a4 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
@@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@@ -33,6 +34,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
+ const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
+ const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
+
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime;
@@ -77,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
- if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2.
+ if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2.
acuteAngleBonus = 0;
else
{
acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
- * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
+ * Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4
- * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter).
+ * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter.
}
// Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
@@ -104,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
- double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity));
+ double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
index f2218a89a7..d503dd2bcc 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
@@ -2,7 +2,10 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
+using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@@ -10,8 +13,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class RhythmEvaluator
{
- private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max.
- private const double rhythm_multiplier = 0.75;
+ private const int history_time_max = 5 * 1000; // 5 seconds
+ private const int history_objects_max = 32;
+ private const double rhythm_overall_multiplier = 0.95;
+ private const double rhythm_ratio_multiplier = 12.0;
///
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current .
@@ -21,15 +26,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (current.BaseObject is Spinner)
return 0;
- int previousIslandSize = 0;
-
double rhythmComplexitySum = 0;
- int islandSize = 1;
+
+ double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;
+
+ var island = new Island(deltaDifferenceEpsilon);
+ var previousIsland = new Island(deltaDifferenceEpsilon);
+
+ // we can't use dictionary here because we need to compare island with a tolerance
+ // which is impossible to pass into the hash comparer
+ var islandCounts = new List<(Island Island, int Count)>();
+
double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms
bool firstDeltaSwitch = false;
- int historicalNoteCount = Math.Min(current.Index, 32);
+ int historicalNoteCount = Math.Min(current.Index, history_objects_max);
int rhythmStart = 0;
@@ -39,74 +51,175 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart);
OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1);
+ // we go from the furthest object back to the current one
for (int i = rhythmStart; i > 0; i--)
{
OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1);
- double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now
+ // scales note 0 to 1 from history to now
+ double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max;
+ double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount;
- currHistoricalDecay = Math.Min((double)(historicalNoteCount - i) / historicalNoteCount, currHistoricalDecay); // either we're limited by time or limited by object count.
+ double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
double currDelta = currObj.StrainTime;
double prevDelta = prevObj.StrainTime;
double lastDelta = lastObj.StrainTime;
- double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses.
- double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3));
+ // calculate how much current delta difference deserves a rhythm bonus
+ // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
+ double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta);
+ double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2));
- windowPenalty = Math.Min(1, windowPenalty);
+ // reduce ratio bonus if delta difference is too big
+ double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta);
+ double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0);
- double effectiveRatio = windowPenalty * currRatio;
+ double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
+
+ double effectiveRatio = windowPenalty * currRatio * fractionMultiplier;
if (firstDeltaSwitch)
{
- if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta))
+ if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
- if (islandSize < 7)
- islandSize++; // island is still progressing, count size.
+ // island is still progressing
+ island.AddDelta((int)currDelta);
}
else
{
- if (currObj.BaseObject is Slider) // bpm change is into slider, this is easy acc window
+ // bpm change is into slider, this is easy acc window
+ if (currObj.BaseObject is Slider)
effectiveRatio *= 0.125;
- if (prevObj.BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle
- effectiveRatio *= 0.25;
+ // bpm change was from a slider, this is easier typically than circle -> circle
+ // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
+ if (prevObj.BaseObject is Slider)
+ effectiveRatio *= 0.3;
- if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet)
- effectiveRatio *= 0.25;
+ // repeated island polarity (2 -> 4, 3 -> 5)
+ if (island.IsSimilarPolarity(previousIsland))
+ effectiveRatio *= 0.5;
- if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5)
- effectiveRatio *= 0.50;
-
- if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
+ // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
+ if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon)
effectiveRatio *= 0.125;
- rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2;
+ // repeated island size (ex: triplet -> triplet)
+ // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
+ if (previousIsland.DeltaCount == island.DeltaCount)
+ effectiveRatio *= 0.5;
+
+ var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island));
+
+ if (islandCount != default)
+ {
+ int countIndex = islandCounts.IndexOf(islandCount);
+
+ // only add island to island counts if they're going one after another
+ if (previousIsland.Equals(island))
+ islandCount.Count++;
+
+ // repeated island (ex: triplet -> triplet)
+ double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33);
+ effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power));
+
+ islandCounts[countIndex] = (islandCount.Island, islandCount.Count);
+ }
+ else
+ {
+ islandCounts.Add((island, 1));
+ }
+
+ // scale down the difficulty if the object is doubletappable
+ double doubletapness = prevObj.GetDoubletapness(currObj);
+ effectiveRatio *= 1 - doubletapness * 0.75;
+
+ rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay;
startRatio = effectiveRatio;
- previousIslandSize = islandSize; // log the last island size.
+ previousIsland = island;
- if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting
- firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size.
+ if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting
+ firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size.
- islandSize = 1;
+ island = new Island((int)currDelta, deltaDifferenceEpsilon);
}
}
- else if (prevDelta > 1.25 * currDelta) // we want to be speeding up.
+ else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up
{
// Begin counting island until we change speed again.
firstDeltaSwitch = true;
+
+ // bpm change is into slider, this is easy acc window
+ if (currObj.BaseObject is Slider)
+ effectiveRatio *= 0.6;
+
+ // bpm change was from a slider, this is easier typically than circle -> circle
+ // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
+ if (prevObj.BaseObject is Slider)
+ effectiveRatio *= 0.6;
+
startRatio = effectiveRatio;
- islandSize = 1;
+
+ island = new Island((int)currDelta, deltaDifferenceEpsilon);
}
lastObj = prevObj;
prevObj = currObj;
}
- return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though)
+ return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
+ }
+
+ private class Island : IEquatable
+ {
+ private readonly double deltaDifferenceEpsilon;
+
+ public Island(double epsilon)
+ {
+ deltaDifferenceEpsilon = epsilon;
+ }
+
+ public Island(int delta, double epsilon)
+ {
+ deltaDifferenceEpsilon = epsilon;
+ Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);
+ DeltaCount++;
+ }
+
+ public int Delta { get; private set; } = int.MaxValue;
+ public int DeltaCount { get; private set; }
+
+ public void AddDelta(int delta)
+ {
+ if (Delta == int.MaxValue)
+ Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);
+
+ DeltaCount++;
+ }
+
+ public bool IsSimilarPolarity(Island other)
+ {
+ // TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
+ // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
+ return DeltaCount % 2 == other.DeltaCount % 2;
+ }
+
+ public bool Equals(Island? other)
+ {
+ if (other == null)
+ return false;
+
+ return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon &&
+ DeltaCount == other.DeltaCount;
+ }
+
+ public override string ToString()
+ {
+ return $"{Delta}x{DeltaCount}";
+ }
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
index 37fd11391c..a5f6468f17 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
@@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@@ -10,9 +11,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class SpeedEvaluator
{
- private const double single_spacing_threshold = 125; // 1.25 circles distance between centers
- private const double min_speed_bonus = 75; // ~200BPM
+ private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
+ private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40;
+ private const double distance_multiplier = 0.94;
///
/// Evaluates the difficulty of tapping the current object, based on:
@@ -30,32 +32,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// derive strainTime for calculation
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
- var osuNextObj = (OsuDifficultyHitObject?)current.Next(0);
double strainTime = osuCurrObj.StrainTime;
- double doubletapness = 1;
-
- // Nerf doubletappable doubles.
- if (osuNextObj != null)
- {
- double currDeltaTime = Math.Max(1, osuCurrObj.DeltaTime);
- double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
- double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
- double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
- double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2);
- doubletapness = Math.Pow(speedRatio, 1 - windowRatio);
- }
+ double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow.
// 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1);
- // speedBonus will be 1.0 for BPM < 200
- double speedBonus = 1.0;
+ // speedBonus will be 0.0 for BPM < 200
+ double speedBonus = 0.0;
// Add additional scaling bonus for streams/bursts higher than 200bpm
- if (strainTime < min_speed_bonus)
- speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
+ if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus)
+ speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2);
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.MinimumJumpDistance;
@@ -63,11 +53,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// Cap distance at single_spacing_threshold
distance = Math.Min(distance, single_spacing_threshold);
- // Max distance bonus is 2 at single_spacing_threshold
- double distanceBonus = 1 + Math.Pow(distance / single_spacing_threshold, 3.5);
+ // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
+ double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
// Base difficulty with all bonuses
- double difficulty = speedBonus * distanceBonus * 1000 / strainTime;
+ double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
// Apply penalty if there's doubletappable doubles
return difficulty * doubletapness;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
index 83538a2f42..a3c0209a08 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
@@ -46,6 +46,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("slider_factor")]
public double SliderFactor { get; set; }
+ [JsonProperty("aim_difficult_strain_count")]
+ public double AimDifficultStrainCount { get; set; }
+
+ [JsonProperty("speed_difficult_strain_count")]
+ public double SpeedDifficultStrainCount { get; set; }
+
///
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
///
@@ -99,6 +105,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor);
+
+ yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount);
+ yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
}
@@ -113,8 +122,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
+ AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT];
+ SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
-
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index c4fcd1f760..575e03051c 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
private const double difficulty_multiplier = 0.0675;
- public override int Version => 20220902;
+ public override int Version => 20241007;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
@@ -48,6 +48,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
+ double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountTopWeightedStrains();
+ double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountTopWeightedStrains();
+
if (mods.Any(m => m is OsuModTouchDevice))
{
aimRating = Math.Pow(aimRating, 0.8);
@@ -100,6 +103,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedNoteCount = speedNotes,
FlashlightDifficulty = flashlightRating,
SliderFactor = sliderFactor,
+ AimDifficultStrainCount = aimDifficultyStrainCount,
+ SpeedDifficultStrainCount = speedDifficultyStrainCount,
ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5,
OverallDifficulty = (80 - hitWindowGreat) / 6,
DrainRate = drainRate,
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index 6defa9739c..31b00dba2b 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
@@ -14,7 +14,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuPerformanceCalculator : PerformanceCalculator
{
- public const double PERFORMANCE_BASE_MULTIPLIER = 1.05; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
+ public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
+
+ private bool usingClassicSliderAccuracy;
private double accuracy;
private int scoreMaxCombo;
@@ -23,6 +25,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private int countMeh;
private int countMiss;
+ ///
+ /// Missed slider ticks that includes missed reverse arrows. Will only be correct on non-classic scores
+ ///
+ private int countSliderTickMiss;
+
+ ///
+ /// Amount of missed slider tails that don't break combo. Will only be correct on non-classic scores
+ ///
+ private int countSliderEndsDropped;
+
+ ///
+ /// Estimated total amount of combo breaks
+ ///
private double effectiveMissCount;
public OsuPerformanceCalculator()
@@ -34,13 +49,46 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
var osuAttributes = (OsuDifficultyAttributes)attributes;
+ usingClassicSliderAccuracy = score.Mods.OfType().Any(m => m.NoSliderHeadAccuracy.Value);
+
accuracy = score.Accuracy;
scoreMaxCombo = score.MaxCombo;
countGreat = score.Statistics.GetValueOrDefault(HitResult.Great);
countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
- effectiveMissCount = calculateEffectiveMissCount(osuAttributes);
+ countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit);
+ countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss);
+ effectiveMissCount = countMiss;
+
+ if (osuAttributes.SliderCount > 0)
+ {
+ if (usingClassicSliderAccuracy)
+ {
+ // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
+ // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
+ double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount;
+
+ if (scoreMaxCombo < fullComboThreshold)
+ effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
+
+ // In classic scores there can't be more misses than a sum of all non-perfect judgements
+ effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits);
+ }
+ else
+ {
+ double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped;
+
+ if (scoreMaxCombo < fullComboThreshold)
+ effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
+
+ // Combine regular misses with tick misses since tick misses break combo as well
+ effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss);
+ }
+ }
+
+ effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
+ effectiveMissCount = Math.Min(totalHits, effectiveMissCount);
double multiplier = PERFORMANCE_BASE_MULTIPLIER;
@@ -93,11 +141,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
aimValue *= lengthBonus;
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)
- aimValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), effectiveMissCount);
-
- aimValue *= getComboScalingFactor(attributes);
+ aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount);
double approachRateFactor = 0.0;
if (attributes.ApproachRate > 10.33)
@@ -123,8 +168,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (attributes.SliderCount > 0)
{
- double estimateSliderEndsDropped = Math.Clamp(Math.Min(countOk + countMeh + countMiss, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders);
- double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + attributes.SliderFactor;
+ double estimateImproperlyFollowedDifficultSliders;
+
+ if (usingClassicSliderAccuracy)
+ {
+ // When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders
+ int maximumPossibleDroppedSliders = totalImperfectHits;
+ estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders);
+ }
+ else
+ {
+ // We add tick misses here since they too mean that the player didn't follow the slider properly
+ // We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly
+ estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, estimateDifficultSliders);
+ }
+
+ double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor;
aimValue *= sliderNerfFactor;
}
@@ -146,11 +205,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
speedValue *= lengthBonus;
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)
- speedValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875));
-
- speedValue *= getComboScalingFactor(attributes);
+ speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount);
double approachRateFactor = 0.0;
if (attributes.ApproachRate > 10.33)
@@ -177,7 +233,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Scale the speed value with accuracy and OD.
- speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2);
+ speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2);
// Scale the speed value with # of 50s to punish doubletapping.
speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
@@ -193,6 +249,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window.
double betterAccuracyPercentage;
int amountHitObjectsWithAccuracy = attributes.HitCircleCount;
+ if (!usingClassicSliderAccuracy)
+ amountHitObjectsWithAccuracy += attributes.SliderCount;
if (amountHitObjectsWithAccuracy > 0)
betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6);
@@ -247,25 +305,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return flashlightValue;
}
- private double calculateEffectiveMissCount(OsuDifficultyAttributes attributes)
- {
- // Guess the number of misses + slider breaks from combo
- double comboBasedMissCount = 0.0;
-
- if (attributes.SliderCount > 0)
- {
- double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
- if (scoreMaxCombo < fullComboThreshold)
- comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
- }
-
- // Clamp miss count to maximum amount of possible breaks
- comboBasedMissCount = Math.Min(comboBasedMissCount, countOk + countMeh + countMiss);
-
- return Math.Max(countMiss, comboBasedMissCount);
- }
-
+ // Miss penalty assumes that a player will miss on the hardest parts of a map,
+ // so we use the amount of relatively difficult sections to adjust miss penalty
+ // to make it more punishing on maps with lower amount of hard sections.
+ private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
private int totalHits => countGreat + countOk + countMeh + countMiss;
+ private int totalImperfectHits => countOk + countMeh + countMiss;
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
index 0e537632b1..5e4c5c1ee9 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
@@ -20,7 +20,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
///
public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
- private const int min_delta_time = 25;
+ public const int NORMALISED_DIAMETER = NORMALISED_RADIUS * 2;
+
+ public const int MIN_DELTA_TIME = 25;
+
private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f;
private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f;
@@ -93,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
this.lastObject = (OsuHitObject)lastObject;
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
- StrainTime = Math.Max(DeltaTime, min_delta_time);
+ StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
if (BaseObject is Slider sliderObject)
{
@@ -136,6 +139,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0);
}
+ ///
+ /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1
+ ///
+ public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj)
+ {
+ if (osuNextObj != null)
+ {
+ double currDeltaTime = Math.Max(1, DeltaTime);
+ double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
+ double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
+ double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
+ double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2);
+ return 1.0 - Math.Pow(speedRatio, 1 - windowRatio);
+ }
+
+ return 0;
+ }
+
private void setDistances(double clockRate)
{
if (BaseObject is Slider currentSlider)
@@ -143,7 +164,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
computeSliderCursorPosition(currentSlider);
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
- TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time);
+ TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
}
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
@@ -167,8 +188,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (lastObject is Slider lastSlider)
{
- double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time);
- MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time);
+ double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
+ MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME);
//
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
index 1fbe03395c..faf91e4652 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain;
- private double skillMultiplier => 24.963;
+ private double skillMultiplier => 25.18;
private double strainDecayBase => 0.15;
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
index 0fe648c4e8..f87ec6ff8c 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
@@ -6,7 +6,6 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
-using System.Collections.Generic;
using System.Linq;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@@ -22,8 +21,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain;
private double currentRhythm;
- private readonly List objectStrains = new List();
-
public Speed(Mod[] mods)
: base(mods)
{
@@ -42,22 +39,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
double totalStrain = currentStrain * currentRhythm;
- objectStrains.Add(totalStrain);
-
return totalStrain;
}
public double RelevantNoteCount()
{
- if (objectStrains.Count == 0)
+ if (ObjectStrains.Count == 0)
return 0;
- double maxStrain = objectStrains.Max();
-
+ double maxStrain = ObjectStrains.Max();
if (maxStrain == 0)
return 0;
- return objectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
+ return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs
new file mode 100644
index 0000000000..163b42bcfd
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs
@@ -0,0 +1,139 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Input.Events;
+using osu.Game.Rulesets.Edit;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Edit.Blueprints
+{
+ public partial class GridPlacementBlueprint : PlacementBlueprint
+ {
+ [Resolved]
+ private HitObjectComposer? hitObjectComposer { get; set; }
+
+ private OsuGridToolboxGroup gridToolboxGroup = null!;
+ private Vector2 originalOrigin;
+ private float originalSpacing;
+ private float originalRotation;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuGridToolboxGroup gridToolboxGroup)
+ {
+ this.gridToolboxGroup = gridToolboxGroup;
+ originalOrigin = gridToolboxGroup.StartPosition.Value;
+ originalSpacing = gridToolboxGroup.Spacing.Value;
+ originalRotation = gridToolboxGroup.GridLinesRotation.Value;
+ }
+
+ public override void EndPlacement(bool commit)
+ {
+ if (!commit && PlacementActive != PlacementState.Finished)
+ resetGridState();
+
+ base.EndPlacement(commit);
+
+ // You typically only place the grid once, so we switch back to the last tool after placement.
+ if (commit && hitObjectComposer is OsuHitObjectComposer osuHitObjectComposer)
+ osuHitObjectComposer.SetLastTool();
+ }
+
+ protected override bool OnClick(ClickEvent e)
+ {
+ if (e.Button == MouseButton.Left)
+ {
+ switch (PlacementActive)
+ {
+ case PlacementState.Waiting:
+ BeginPlacement(true);
+ return true;
+
+ case PlacementState.Active:
+ EndPlacement(true);
+ return true;
+ }
+ }
+
+ return base.OnClick(e);
+ }
+
+ protected override bool OnMouseDown(MouseDownEvent e)
+ {
+ if (e.Button == MouseButton.Right)
+ {
+ // Reset the grid to the default values.
+ gridToolboxGroup.StartPosition.Value = gridToolboxGroup.StartPosition.Default;
+ gridToolboxGroup.Spacing.Value = gridToolboxGroup.Spacing.Default;
+ if (!gridToolboxGroup.GridLinesRotation.Disabled)
+ gridToolboxGroup.GridLinesRotation.Value = gridToolboxGroup.GridLinesRotation.Default;
+ EndPlacement(true);
+ return true;
+ }
+
+ return base.OnMouseDown(e);
+ }
+
+ protected override bool OnDragStart(DragStartEvent e)
+ {
+ if (e.Button == MouseButton.Left)
+ {
+ BeginPlacement(true);
+ return true;
+ }
+
+ return base.OnDragStart(e);
+ }
+
+ protected override void OnDragEnd(DragEndEvent e)
+ {
+ if (PlacementActive == PlacementState.Active)
+ EndPlacement(true);
+
+ base.OnDragEnd(e);
+ }
+
+ public override SnapType SnapType => ~SnapType.GlobalGrids;
+
+ public override void UpdateTimeAndPosition(SnapResult result)
+ {
+ if (State.Value == Visibility.Hidden)
+ return;
+
+ var pos = ToLocalSpace(result.ScreenSpacePosition);
+
+ if (PlacementActive != PlacementState.Active)
+ gridToolboxGroup.StartPosition.Value = pos;
+ else
+ {
+ // Default to the original spacing and rotation if the distance is too small.
+ if (Vector2.Distance(gridToolboxGroup.StartPosition.Value, pos) < 2)
+ {
+ gridToolboxGroup.Spacing.Value = originalSpacing;
+ if (!gridToolboxGroup.GridLinesRotation.Disabled)
+ gridToolboxGroup.GridLinesRotation.Value = originalRotation;
+ }
+ else
+ {
+ gridToolboxGroup.SetGridFromPoints(gridToolboxGroup.StartPosition.Value, pos);
+ }
+ }
+ }
+
+ protected override void PopOut()
+ {
+ base.PopOut();
+ resetGridState();
+ }
+
+ private void resetGridState()
+ {
+ gridToolboxGroup.StartPosition.Value = originalOrigin;
+ gridToolboxGroup.Spacing.Value = originalSpacing;
+ if (!gridToolboxGroup.GridLinesRotation.Disabled)
+ gridToolboxGroup.GridLinesRotation.Value = originalRotation;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
index df369dcef5..f114516300 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
@@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (segment.Count == 0)
return;
- var first = segment[0];
+ PathControlPoint first = segment[0];
if (first.Type != PathType.PERFECT_CURVE)
return;
@@ -178,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
private bool isSplittable(PathControlPointPiece p) =>
// A hit object can only be split on control points which connect two different path segments.
- p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault();
+ p.ControlPoint.Type.HasValue && p.ControlPoint != controlPoints.FirstOrDefault() && p.ControlPoint != controlPoints.LastOrDefault();
private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
@@ -273,10 +273,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (selectedPieces.Length != 1)
return false;
- var selectedPiece = selectedPieces.Single();
- var selectedPoint = selectedPiece.ControlPoint;
+ PathControlPointPiece selectedPiece = selectedPieces.Single();
+ PathControlPoint selectedPoint = selectedPiece.ControlPoint;
- var validTypes = path_types;
+ PathType?[] validTypes = path_types;
if (selectedPoint == controlPoints[0])
validTypes = validTypes.Where(t => t != null).ToArray();
@@ -313,7 +313,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (Pieces.All(p => !p.IsSelected.Value))
return false;
- var type = path_types[e.Key - Key.Number1];
+ PathType? type = path_types[e.Key - Key.Number1];
// The first control point can never be inherit type
if (Pieces[0].IsSelected.Value && type == null)
@@ -333,6 +333,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
base.Dispose(isDisposing);
foreach (var p in Pieces)
p.ControlPoint.Changed -= controlPointChanged;
+
+ if (draggedControlPointIndex >= 0)
+ DragEnded();
}
private void selectionRequested(PathControlPointPiece piece, MouseButtonEvent e)
@@ -353,9 +356,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
changeHandler?.BeginChange();
+ double originalDistance = hitObject.Path.Distance;
+
foreach (var p in Pieces.Where(p => p.IsSelected.Value))
{
- var pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint);
+ List pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint);
int indexInSegment = pointsInSegment.IndexOf(p.ControlPoint);
if (type?.Type == SplineType.PerfectCurve)
@@ -375,6 +380,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
EnsureValidPathTypes();
+ if (hitObject.Path.Distance < originalDistance)
+ hitObject.SnapTo(distanceSnapProvider);
+ else
+ hitObject.Path.ExpectedDistance.Value = originalDistance;
+
changeHandler?.EndChange();
}
@@ -385,7 +395,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
private Vector2[] dragStartPositions;
private PathType?[] dragPathTypes;
- private int draggedControlPointIndex;
+ private int draggedControlPointIndex = -1;
private HashSet selectedControlPoints;
private List
-
+
+
diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs
new file mode 100644
index 0000000000..c523652ae1
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs
@@ -0,0 +1,37 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Testing;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Rulesets.Taiko.Objects;
+using osu.Game.Rulesets.Taiko.Objects.Drawables;
+using osu.Game.Tests.Visual;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Taiko.Tests.Editor
+{
+ public partial class TestSceneEditorPlacement : EditorTestScene
+ {
+ protected override Ruleset CreateEditorRuleset() => new TaikoRuleset();
+
+ [Test]
+ public void TestPlacementBlueprintDoesNotCauseCrashes()
+ {
+ AddStep("clear objects", () => EditorBeatmap.Clear());
+ AddStep("add two objects", () =>
+ {
+ EditorBeatmap.Add(new Hit { StartTime = 1818 });
+ EditorBeatmap.Add(new Hit { StartTime = 1584 });
+ });
+ AddStep("seek back", () => EditorClock.Seek(1584));
+ AddStep("choose hit placement tool", () => InputManager.Key(Key.Number2));
+ AddStep("hover over first hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(1)));
+ AddStep("hover over second hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(0)));
+ AddStep("right click", () => InputManager.Click(MouseButton.Right));
+ AddUntilStep("context menu open", () => Editor.ChildrenOfType().Any(menu => menu.State == MenuState.Open));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs
index f3e37736b2..30ecec2366 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs
@@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(beatmap);
+ Ruleset.Value = new TaikoRuleset().RulesetInfo;
SelectedMods.Value = mods ?? Array.Empty();
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs
index 6e6be26e43..e6d5c51902 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs
@@ -31,40 +31,42 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
const double hit_time = 1;
- var beatmap = new Beatmap
- {
- HitObjects = new List
- {
- new Hit
- {
- Type = HitType.Rim,
- StartTime = hit_time,
- },
- new Hit
- {
- Type = HitType.Centre,
- StartTime = hit_time * 2,
- },
- },
- BeatmapInfo =
- {
- Difficulty = new BeatmapDifficulty
- {
- SliderTickRate = 4,
- OverallDifficulty = 0,
- },
- Ruleset = new TaikoRuleset().RulesetInfo
- },
- };
-
- beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f });
-
CreateModTest(new ModTestData
{
Mod = new TaikoModHidden(),
Autoplay = true,
PassCondition = checkAllMaxResultJudgements(2),
- Beatmap = beatmap,
+ CreateBeatmap = () =>
+ {
+ var beatmap = new Beatmap
+ {
+ HitObjects = new List
+ {
+ new Hit
+ {
+ Type = HitType.Rim,
+ StartTime = hit_time,
+ },
+ new Hit
+ {
+ Type = HitType.Centre,
+ StartTime = hit_time * 2,
+ },
+ },
+ BeatmapInfo =
+ {
+ Difficulty = new BeatmapDifficulty
+ {
+ SliderTickRate = 4,
+ OverallDifficulty = 0,
+ },
+ Ruleset = new TaikoRuleset().RulesetInfo
+ },
+ };
+
+ beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f });
+ return beatmap;
+ },
});
}
}
diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs
index caf8aa8e76..fffe42f1f8 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs
@@ -15,7 +15,26 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
[Test]
public void TestRelax()
{
- var beatmap = new TaikoBeatmap
+ var beatmapForReplay = createBeatmap();
+
+ foreach (var ho in beatmapForReplay.HitObjects)
+ ho.ApplyDefaults(beatmapForReplay.ControlPointInfo, beatmapForReplay.Difficulty);
+
+ var replay = new TaikoAutoGenerator(beatmapForReplay).Generate();
+
+ foreach (var frame in replay.Frames.OfType().Where(r => r.Actions.Any()))
+ frame.Actions = [TaikoAction.LeftCentre];
+
+ CreateModTest(new ModTestData
+ {
+ Mod = new TaikoModRelax(),
+ CreateBeatmap = createBeatmap,
+ ReplayFrames = replay.Frames,
+ Autoplay = false,
+ PassCondition = () => Player.ScoreProcessor.HasCompleted.Value && Player.ScoreProcessor.Accuracy.Value == 1,
+ });
+
+ TaikoBeatmap createBeatmap() => new TaikoBeatmap
{
HitObjects =
{
@@ -25,22 +44,6 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
new Swell { StartTime = 1250, Duration = 500 },
}
};
- foreach (var ho in beatmap.HitObjects)
- ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty);
-
- var replay = new TaikoAutoGenerator(beatmap).Generate();
-
- foreach (var frame in replay.Frames.OfType().Where(r => r.Actions.Any()))
- frame.Actions = [TaikoAction.LeftCentre];
-
- CreateModTest(new ModTestData
- {
- Mod = new TaikoModRelax(),
- Beatmap = beatmap,
- ReplayFrames = replay.Frames,
- Autoplay = false,
- PassCondition = () => Player.ScoreProcessor.HasCompleted.Value && Player.ScoreProcessor.Accuracy.Value == 1,
- });
}
}
}
diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs
index 3a11a91f82..b12ac10d2d 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs
@@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
Mod = new TaikoModSingleTap(),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -65,7 +65,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
Mod = new TaikoModSingleTap(),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
Mod = new TaikoModSingleTap(),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -136,7 +136,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
Mod = new TaikoModSingleTap(),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
HitObjects = new List
{
@@ -175,7 +175,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods
{
Mod = new TaikoModSingleTap(),
Autoplay = false,
- Beatmap = new Beatmap
+ CreateBeatmap = () => new Beatmap
{
Breaks =
{
diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
index a2420fc679..2170009ae8 100644
--- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
+++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
@@ -1,7 +1,7 @@
-
+
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
index 9f63e84867..25428c8b2f 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
@@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
@@ -11,26 +12,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
{
public class ColourEvaluator
{
- ///
- /// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2).
- ///
- /// The input value.
- /// The center of the sigmoid, where the largest gradient occurs and value is equal to middle.
- /// The radius of the sigmoid, outside of which values are near the minimum/maximum.
- /// The middle of the sigmoid output.
- /// The height of the sigmoid output. This will be equal to max value - min value.
- private static double sigmoid(double val, double center, double width, double middle, double height)
- {
- double sigmoid = Math.Tanh(Math.E * -(val - center) / width);
- return sigmoid * (height / 2) + middle;
- }
-
///
/// Evaluate the difficulty of the first note of a .
///
public static double EvaluateDifficultyOf(MonoStreak monoStreak)
{
- return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
+ return DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
}
///
@@ -38,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
///
public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern)
{
- return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
+ return DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
}
///
@@ -46,7 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
///
public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern)
{
- return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1));
+ return 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E));
}
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject)
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
index e528c70699..f6914039f0 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
@@ -1,33 +1,55 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
{
///
/// Calculates the stamina coefficient of taiko difficulty.
///
- public class Stamina : StrainDecaySkill
+ public class Stamina : StrainSkill
{
- protected override double SkillMultiplier => 1.1;
- protected override double StrainDecayBase => 0.4;
+ private double skillMultiplier => 1.1;
+ private double strainDecayBase => 0.4;
+
+ private readonly bool singleColourStamina;
+
+ private double currentStrain;
///
/// Creates a skill.
///
/// Mods for use in skill calculations.
- public Stamina(Mod[] mods)
+ /// Reads when Stamina is from a single coloured pattern.
+ public Stamina(Mod[] mods, bool singleColourStamina)
: base(mods)
{
+ this.singleColourStamina = singleColourStamina;
}
- protected override double StrainValueOf(DifficultyHitObject current)
+ private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
+
+ protected override double StrainValueAt(DifficultyHitObject current)
{
- return StaminaEvaluator.EvaluateDifficultyOf(current);
+ currentStrain *= strainDecay(current.DeltaTime);
+ currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
+
+ // Safely prevents previous strains from shifting as new notes are added.
+ var currentObject = current as TaikoDifficultyHitObject;
+ int index = currentObject?.Colour.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0;
+
+ if (singleColourStamina)
+ return currentStrain / (1 + Math.Exp(-(index - 10) / 2.0));
+
+ return currentStrain;
}
+
+ protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => singleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime);
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
index 1664c941f8..c8f0448767 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
@@ -16,6 +16,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("stamina_difficulty")]
public double StaminaDifficulty { get; set; }
+ ///
+ /// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty.
+ ///
+ [JsonProperty("mono_stamina_factor")]
+ public double MonoStaminaFactor { get; set; }
+
///
/// The difficulty corresponding to the rhythm skill.
///
@@ -43,6 +49,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("great_hit_window")]
public double GreatHitWindow { get; set; }
+ ///
+ /// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc).
+ ///
+ ///
+ /// Rate-adjusting mods don't directly affect the hit window, but have a perceived effect as a result of adjusting audio timing.
+ ///
+ [JsonProperty("ok_hit_window")]
+ public double OkHitWindow { get; set; }
+
public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
{
foreach (var v in base.ToDatabaseAttributes())
@@ -50,6 +65,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
+ yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
+ yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo)
@@ -58,6 +75,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
+ OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
+ MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR];
}
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
index e3c550fbe9..7f2558c406 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
@@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private const double colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier;
- public override int Version => 20221107;
+ public override int Version => 20241007;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
@@ -38,7 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
new Rhythm(mods),
new Colour(mods),
- new Stamina(mods)
+ new Stamina(mods, false),
+ new Stamina(mods, true)
};
}
@@ -79,14 +80,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Colour colour = (Colour)skills.First(x => x is Colour);
Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm);
Stamina stamina = (Stamina)skills.First(x => x is Stamina);
+ Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina);
double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
+ double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
+ double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5);
double combinedRating = combinedDifficultyValue(rhythm, colour, stamina);
double starRating = rescale(combinedRating * 1.4);
+ // TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system.
+ if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0)
+ {
+ starRating *= 0.925;
+ // For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused.
+ if (colourRating < 2 && staminaRating > 8)
+ starRating *= 0.80;
+ }
+
HitWindows hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
@@ -95,10 +108,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
StarRating = starRating,
Mods = mods,
StaminaDifficulty = staminaRating,
+ MonoStaminaFactor = monoStaminaFactor,
RhythmDifficulty = rhythmRating,
ColourDifficulty = colourRating,
PeakDifficulty = combinedRating,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
+ OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate,
MaxCombo = beatmap.GetMaxCombo(),
};
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs
index b12c0ca29d..7c74e43db1 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs
@@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
+ [JsonProperty("estimated_unstable_rate")]
+ public double? EstimatedUnstableRate { get; set; }
+
public override IEnumerable GetAttributesForDisplay()
{
foreach (var attribute in base.GetAttributesForDisplay())
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
index ac4462c18b..c672b7a1d9 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
@@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Scoring;
+using osu.Game.Utils;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
@@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private int countOk;
private int countMeh;
private int countMiss;
- private double accuracy;
+ private double? estimatedUnstableRate;
private double effectiveMissCount;
@@ -35,24 +36,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
- accuracy = customAccuracy;
+ estimatedUnstableRate = computeDeviationUpperBound(taikoAttributes) * 10;
// The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000.
if (totalSuccessfulHits > 0)
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
- // TODO: The detection of rulesets is temporary until the leftover old skills have been reworked.
+ // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation.
bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1;
double multiplier = 1.13;
- if (score.Mods.Any(m => m is ModHidden))
+ if (score.Mods.Any(m => m is ModHidden) && !isConvert)
multiplier *= 1.075;
if (score.Mods.Any(m => m is ModEasy))
- multiplier *= 0.975;
+ multiplier *= 0.950;
- double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert);
+ double difficultyValue = computeDifficultyValue(score, taikoAttributes);
double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert);
double totalValue =
Math.Pow(
@@ -65,11 +66,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Difficulty = difficultyValue,
Accuracy = accuracyValue,
EffectiveMissCount = effectiveMissCount,
+ EstimatedUnstableRate = estimatedUnstableRate,
Total = totalValue
};
}
- private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
+ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
{
double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0;
@@ -79,41 +81,104 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
difficultyValue *= Math.Pow(0.986, effectiveMissCount);
if (score.Mods.Any(m => m is ModEasy))
- difficultyValue *= 0.985;
+ difficultyValue *= 0.90;
- if (score.Mods.Any(m => m is ModHidden) && !isConvert)
+ if (score.Mods.Any(m => m is ModHidden))
difficultyValue *= 1.025;
if (score.Mods.Any(m => m is ModHardRock))
- difficultyValue *= 1.050;
+ difficultyValue *= 1.10;
if (score.Mods.Any(m => m is ModFlashlight))
- difficultyValue *= 1.050 * lengthBonus;
+ difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus);
- return difficultyValue * Math.Pow(accuracy, 2.0);
+ if (estimatedUnstableRate == null)
+ return 0;
+
+ // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps.
+ double accScalingExponent = 2 + attributes.MonoStaminaFactor;
+ double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor;
+
+ return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent);
}
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
{
- if (attributes.GreatHitWindow <= 0)
+ if (attributes.GreatHitWindow <= 0 || estimatedUnstableRate == null)
return 0;
- double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0;
+ double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0;
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
- accuracyValue *= lengthBonus;
// Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values.
if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert)
- accuracyValue *= Math.Max(1.0, 1.1 * lengthBonus);
+ accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus);
return accuracyValue;
}
+ ///
+ /// Computes an upper bound on the player's tap deviation based on the OD, number of circles and sliders,
+ /// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that
+ /// two SS scores on the same map with the same settings will always return the same deviation.
+ ///
+ private double? computeDeviationUpperBound(TaikoDifficultyAttributes attributes)
+ {
+ if (totalSuccessfulHits == 0 || attributes.GreatHitWindow <= 0)
+ return null;
+
+ double h300 = attributes.GreatHitWindow;
+ double h100 = attributes.OkHitWindow;
+
+ const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
+
+ // The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window.
+ double? calcDeviationGreatWindow()
+ {
+ if (countGreat == 0) return null;
+
+ double n = totalHits;
+
+ // Proportion of greats hit.
+ double p = countGreat / n;
+
+ // We can be 99% confident that p is at least this value.
+ double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
+
+ // We can be 99% confident that the deviation is not higher than:
+ return h300 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
+ }
+
+ // The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window.
+ // This will return a lower value than the first method when the number of 100s is high, but the miss count is low.
+ double? calcDeviationGoodWindow()
+ {
+ if (totalSuccessfulHits == 0) return null;
+
+ double n = totalHits;
+
+ // Proportion of greats + goods hit.
+ double p = totalSuccessfulHits / n;
+
+ // We can be 99% confident that p is at least this value.
+ double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
+
+ // We can be 99% confident that the deviation is not higher than:
+ return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
+ }
+
+ double? deviationGreatWindow = calcDeviationGreatWindow();
+ double? deviationGoodWindow = calcDeviationGoodWindow();
+
+ if (deviationGreatWindow is null)
+ return deviationGoodWindow;
+
+ return Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value);
+ }
+
private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh;
-
- private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0;
}
}
diff --git a/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs b/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs
index 217bb8139c..147ceb3ba1 100644
--- a/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs
@@ -7,6 +7,7 @@ using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.UI;
+using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Rulesets.Taiko.Edit
@@ -20,6 +21,8 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
+ protected override Playfield CreatePlayfield() => new TaikoEditorPlayfield();
+
protected override void LoadComplete()
{
base.LoadComplete();
diff --git a/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs
index 2aaa16ee0b..52f7176b3f 100644
--- a/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs
@@ -16,10 +16,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{
public partial class TaikoDifficultySection : SetupSection
{
- private LabelledSliderBar healthDrainSlider { get; set; } = null!;
- private LabelledSliderBar overallDifficultySlider { get; set; } = null!;
- private LabelledSliderBar baseVelocitySlider { get; set; } = null!;
- private LabelledSliderBar tickRateSlider { get; set; } = null!;
+ private FormSliderBar healthDrainSlider { get; set; } = null!;
+ private FormSliderBar overallDifficultySlider { get; set; } = null!;
+ private FormSliderBar baseVelocitySlider { get; set; } = null!;
+ private FormSliderBar tickRateSlider { get; set; } = null!;
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
@@ -28,64 +28,68 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{
Children = new Drawable[]
{
- healthDrainSlider = new LabelledSliderBar
+ healthDrainSlider = new FormSliderBar
{
- Label = BeatmapsetsStrings.ShowStatsDrain,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.DrainRateDescription,
+ Caption = BeatmapsetsStrings.ShowStatsDrain,
+ HintText = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
- overallDifficultySlider = new LabelledSliderBar
+ overallDifficultySlider = new FormSliderBar
{
- Label = BeatmapsetsStrings.ShowStatsAccuracy,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.OverallDifficultyDescription,
+ Caption = BeatmapsetsStrings.ShowStatsAccuracy,
+ HintText = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
- baseVelocitySlider = new LabelledSliderBar
+ baseVelocitySlider = new FormSliderBar
{
- Label = EditorSetupStrings.BaseVelocity,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.BaseVelocityDescription,
+ Caption = EditorSetupStrings.BaseVelocity,
+ HintText = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
MinValue = 0.4,
MaxValue = 3.6,
Precision = 0.01f,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
- tickRateSlider = new LabelledSliderBar
+ tickRateSlider = new FormSliderBar
{
- Label = EditorSetupStrings.TickRate,
- FixedLabelWidth = LABEL_WIDTH,
- Description = EditorSetupStrings.TickRateDescription,
+ Caption = EditorSetupStrings.TickRate,
+ HintText = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
MinValue = 1,
MaxValue = 4,
Precision = 1,
- }
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
},
};
- foreach (var item in Children.OfType>())
+ foreach (var item in Children.OfType>())
item.Current.ValueChanged += _ => updateValues();
- foreach (var item in Children.OfType>())
+ foreach (var item in Children.OfType>())
item.Current.ValueChanged += _ => updateValues();
}
diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs
new file mode 100644
index 0000000000..760ed71662
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs
@@ -0,0 +1,25 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Game.Rulesets.Taiko.UI;
+using osu.Game.Skinning;
+
+namespace osu.Game.Rulesets.Taiko.Edit
+{
+ public partial class TaikoEditorPlayfield : TaikoPlayfield
+ {
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ // This is the simplest way to extend the taiko playfield beyond the left of the drum area.
+ // Required in the editor to not look weird underneath left toolbox area.
+ AddInternal(new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight())
+ {
+ Anchor = Anchor.TopLeft,
+ Origin = Anchor.TopRight,
+ });
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
index ae6dced9aa..be2a5ac144 100644
--- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
@@ -6,6 +6,7 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Input.Bindings;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
@@ -53,17 +54,17 @@ namespace osu.Game.Rulesets.Taiko.Edit
public void SetStrongState(bool state)
{
- if (SelectedItems.OfType().All(h => h.IsStrong == state))
+ if (SelectedItems.OfType().All(h => h.IsStrong == state))
return;
EditorBeatmap.PerformOnSelection(h =>
{
- if (!(h is Hit taikoHit)) return;
+ if (h is not TaikoStrongableHitObject strongable) return;
- if (taikoHit.IsStrong != state)
+ if (strongable.IsStrong != state)
{
- taikoHit.IsStrong = state;
- EditorBeatmap.Update(taikoHit);
+ strongable.IsStrong = state;
+ EditorBeatmap.Update(strongable);
}
});
}
@@ -86,10 +87,22 @@ namespace osu.Game.Rulesets.Taiko.Edit
protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection)
{
if (selection.All(s => s.Item is Hit))
- yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } };
+ {
+ yield return new TernaryStateToggleMenuItem("Rim")
+ {
+ State = { BindTarget = selectionRimState },
+ Hotkey = new Hotkey(new KeyCombination(InputKey.W), new KeyCombination(InputKey.R)),
+ };
+ }
if (selection.All(s => s.Item is TaikoHitObject))
- yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
+ {
+ yield return new TernaryStateToggleMenuItem("Strong")
+ {
+ State = { BindTarget = selectionStrongState },
+ Hotkey = new Hotkey(new KeyCombination(InputKey.E)),
+ };
+ }
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs
index 1af4719b02..547d0afe4a 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs
@@ -31,6 +31,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad;
+ // done strictly for editor purposes.
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => MainPiece.Drawable.ReceivePositionalInputAt(screenSpacePos);
+
///
/// Rolling number of tick hits. This increases for hits and decreases for misses.
///
@@ -78,6 +81,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
base.RecreatePieces();
updateColour();
+ Height = HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE;
}
protected override void OnFree()
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs
index 0333fd71a9..64d2020edc 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs
@@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -44,6 +45,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
IsFirstTick.Value = HitObject.FirstTick;
}
+ protected override void RecreatePieces()
+ {
+ base.RecreatePieces();
+ Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE);
+ }
+
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (!userTriggered)
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
index a5e63c373f..28831a6d2c 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
@@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -63,6 +64,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
updateActionsFromType();
base.RecreatePieces();
+ Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE);
}
protected override void OnFree()
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
index f2fcd185dd..28617b35f6 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
@@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -34,6 +35,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
///
private const double ring_appear_offset = 100;
+ private Vector2 baseSize;
+
private readonly Container ticks;
private readonly Container bodyContainer;
private readonly CircularContainer targetRing;
@@ -141,6 +144,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
Origin = Anchor.Centre,
});
+ protected override void RecreatePieces()
+ {
+ base.RecreatePieces();
+ Size = baseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE);
+ }
+
protected override void OnFree()
{
base.OnFree();
@@ -269,7 +278,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
base.Update();
- Size = BaseSize * Parent!.RelativeChildSize;
+ Size = baseSize * Parent!.RelativeChildSize;
// Make the swell stop at the hit target
X = Math.Max(0, X);
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
index 3f4694d71d..0cf9651965 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
@@ -130,7 +130,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public new TObject HitObject => (TObject)base.HitObject;
- protected Vector2 BaseSize;
protected SkinnableDrawable MainPiece;
protected DrawableTaikoHitObject([CanBeNull] TObject hitObject)
@@ -152,8 +151,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
protected virtual void RecreatePieces()
{
- Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE);
-
if (MainPiece != null)
Content.Remove(MainPiece, true);
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs
index 4d7cdf3243..7c3ff4f27e 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs
@@ -8,7 +8,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
-using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -44,13 +43,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
isStrong.UnbindEvents();
}
- protected override void RecreatePieces()
- {
- base.RecreatePieces();
- if (HitObject.IsStrong)
- Size = BaseSize = new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE);
- }
-
protected override void AddNestedHitObject(DrawableHitObject hitObject)
{
base.AddNestedHitObject(hitObject);
diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs
index 5543a31ec9..78be0ef643 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -9,6 +10,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Graphics;
using osu.Game.Skinning;
+using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
@@ -19,13 +21,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
{
get
{
- var headDrawQuad = headCircle.ScreenSpaceDrawQuad;
- var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad;
+ // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii.
+ // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box.
+ var headCentre = headCircle.ScreenSpaceDrawQuad.Centre;
+ var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2;
- return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight);
+ float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2;
+ float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2;
+ float radius = Math.Max(headRadius, tailRadius);
+
+ var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius);
+ return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight);
}
}
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos);
+
private LegacyCirclePiece headCircle = null!;
private Sprite body = null!;
diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
index 2447a4a247..70e429a344 100644
--- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs
@@ -190,9 +190,12 @@ namespace osu.Game.Rulesets.Taiko
public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this);
- public override IEnumerable CreateEditorSetupSections() =>
+ public override IEnumerable CreateEditorSetupSections() =>
[
+ new MetadataSection(),
new TaikoDifficultySection(),
+ new ResourcesSection(),
+ new DesignSection(),
];
public override IBeatmapVerifier CreateBeatmapVerifier() => new TaikoBeatmapVerifier();
diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
index 64ea9d88cd..4185b67f4c 100644
--- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
@@ -84,8 +84,11 @@ namespace osu.Game.Rulesets.Taiko.UI
protected virtual double ComputeTimeRange()
{
- // Adjust when we're using constant algorithm to not be sluggish.
- double multiplier = VisualisationMethod == ScrollVisualisationMethod.Constant ? 4 * Beatmap.Difficulty.SliderMultiplier : 1;
+ // Using the constant algorithm results in a sluggish scroll speed that's equal to 60 BPM.
+ // We need to adjust it to the expected default scroll speed (BPM * base SV multiplier).
+ double multiplier = VisualisationMethod == ScrollVisualisationMethod.Constant
+ ? (Beatmap.BeatmapInfo.BPM * Beatmap.Difficulty.SliderMultiplier) / 60
+ : 1;
return PlayfieldAdjustmentContainer.ComputeTimeRange() / multiplier;
}
diff --git a/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs b/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs
index 11c4c54ea6..82e54875ef 100644
--- a/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs
+++ b/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs
@@ -241,8 +241,8 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch);
- Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
- Assert.That(beatmap.OnlineID, Is.EqualTo(-1));
+ Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
+ Assert.That(beatmap.OnlineID, Is.EqualTo(654321));
}
[Test]
@@ -273,34 +273,6 @@ namespace osu.Game.Tests.Beatmaps
Assert.That(beatmap.OnlineID, Is.EqualTo(654321));
}
- [Test]
- public void TestMetadataLookupForBeatmapWithoutPopulatedIDAndIncorrectHash([Values] bool preferOnlineFetch)
- {
- var lookupResult = new OnlineBeatmapMetadata
- {
- BeatmapID = 654321,
- BeatmapStatus = BeatmapOnlineStatus.Ranked,
- MD5Hash = @"cafebabe",
- };
-
- var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
- targetMock.Setup(src => src.Available).Returns(true);
- targetMock.Setup(src => src.TryLookup(It.IsAny(), out lookupResult))
- .Returns(true);
-
- var beatmap = new BeatmapInfo
- {
- MD5Hash = @"deadbeef"
- };
- var beatmapSet = new BeatmapSetInfo(beatmap.Yield());
- beatmap.BeatmapSet = beatmapSet;
-
- metadataLookup.Update(beatmapSet, preferOnlineFetch);
-
- Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
- Assert.That(beatmap.OnlineID, Is.EqualTo(-1));
- }
-
[Test]
public void TestReturnedMetadataHasDifferentHash([Values] bool preferOnlineFetch)
{
@@ -383,58 +355,5 @@ namespace osu.Game.Tests.Beatmaps
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
}
-
- [Test]
- public void TestPartiallyMaliciousSet([Values] bool preferOnlineFetch)
- {
- var firstResult = new OnlineBeatmapMetadata
- {
- BeatmapID = 654321,
- BeatmapStatus = BeatmapOnlineStatus.Ranked,
- BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
- MD5Hash = @"cafebabe"
- };
- var secondResult = new OnlineBeatmapMetadata
- {
- BeatmapStatus = BeatmapOnlineStatus.Ranked,
- BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
- MD5Hash = @"dededede"
- };
-
- var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
- targetMock.Setup(src => src.Available).Returns(true);
- targetMock.Setup(src => src.TryLookup(It.Is(bi => bi.OnlineID == 654321), out firstResult))
- .Returns(true);
- targetMock.Setup(src => src.TryLookup(It.Is(bi => bi.OnlineID == 666666), out secondResult))
- .Returns(true);
-
- var firstBeatmap = new BeatmapInfo
- {
- OnlineID = 654321,
- MD5Hash = @"cafebabe",
- };
- var secondBeatmap = new BeatmapInfo
- {
- OnlineID = 666666,
- MD5Hash = @"deadbeef"
- };
- var beatmapSet = new BeatmapSetInfo(new[]
- {
- firstBeatmap,
- secondBeatmap
- });
- firstBeatmap.BeatmapSet = beatmapSet;
- secondBeatmap.BeatmapSet = beatmapSet;
-
- metadataLookup.Update(beatmapSet, preferOnlineFetch);
-
- Assert.That(firstBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
- Assert.That(firstBeatmap.OnlineID, Is.EqualTo(654321));
-
- Assert.That(secondBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
- Assert.That(secondBeatmap.OnlineID, Is.EqualTo(-1));
-
- Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
- }
}
}
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
index 54ebebeb7b..b5c299ed9d 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs
@@ -1000,7 +1000,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.That(decoded.BeatmapInfo.WidescreenStoryboard, Is.False);
Assert.That(decoded.BeatmapInfo.EpilepsyWarning, Is.False);
Assert.That(decoded.BeatmapInfo.SamplesMatchPlaybackRate, Is.False);
- Assert.That(decoded.BeatmapInfo.Countdown, Is.EqualTo(CountdownType.Normal));
+ Assert.That(decoded.BeatmapInfo.Countdown, Is.EqualTo(CountdownType.None));
Assert.That(decoded.BeatmapInfo.CountdownOffset, Is.EqualTo(0));
Assert.That(decoded.BeatmapInfo.Metadata.PreviewTime, Is.EqualTo(-1));
Assert.That(decoded.BeatmapInfo.Ruleset.OnlineID, Is.EqualTo(0));
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
index b931896898..c8a09786ec 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
@@ -120,11 +120,11 @@ namespace osu.Game.Tests.Beatmaps.Formats
private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual)
{
// Check all control points that are still considered to be at a global level.
- Assert.That(expected.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.TimingPoints.Serialize()));
- Assert.That(expected.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.EffectPoints.Serialize()));
+ Assert.That(actual.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.TimingPoints.Serialize()));
+ Assert.That(actual.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.EffectPoints.Serialize()));
// Check all hitobjects.
- Assert.That(expected.beatmap.HitObjects.Serialize(), Is.EqualTo(actual.beatmap.HitObjects.Serialize()));
+ Assert.That(actual.beatmap.HitObjects.Serialize(), Is.EqualTo(expected.beatmap.HitObjects.Serialize()));
// Check skin.
Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration));
diff --git a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs
new file mode 100644
index 0000000000..8a95d26782
--- /dev/null
+++ b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs
@@ -0,0 +1,106 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.IO;
+using System.Text;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions;
+using osu.Framework.Extensions.ObjectExtensions;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.IO.Archives;
+using osu.Game.Tests.Resources;
+using osu.Game.Tests.Visual;
+using MemoryStream = System.IO.MemoryStream;
+
+namespace osu.Game.Tests.Beatmaps.IO
+{
+ [HeadlessTest]
+ public partial class LegacyBeatmapExporterTest : OsuTestScene
+ {
+ [Resolved]
+ private BeatmapManager beatmaps { get; set; } = null!;
+
+ [Test]
+ public void TestObjectsSnappedAfterTruncatingExport()
+ {
+ IWorkingBeatmap beatmap = null!;
+ MemoryStream outStream = null!;
+
+ // Ensure importer encoding is correct
+ AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"decimal-timing-beatmap.olz"));
+ AddAssert("timing point has decimal offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284.725).Within(0.001));
+ AddAssert("kiai has decimal offset", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28520.019).Within(0.001));
+ AddAssert("hit object has decimal offset", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28520.019).Within(0.001));
+
+ // Ensure exporter legacy conversion is correct
+ AddStep("export", () =>
+ {
+ outStream = new MemoryStream();
+
+ new LegacyBeatmapExporter(LocalStorage)
+ .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
+ });
+
+ AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream));
+ AddAssert("timing point has truncated offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284).Within(0.001));
+ AddAssert("kiai is snapped", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28519).Within(0.001));
+ AddAssert("hit object is snapped", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28519).Within(0.001));
+ }
+
+ [Test]
+ public void TestExportStability()
+ {
+ IWorkingBeatmap beatmap = null!;
+ MemoryStream firstExport = null!;
+ MemoryStream secondExport = null!;
+
+ // Ensure importer encoding is correct
+ AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"legacy-export-stability-test.olz"));
+ AddStep("export once", () =>
+ {
+ firstExport = new MemoryStream();
+
+ new LegacyBeatmapExporter(LocalStorage)
+ .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, firstExport, null);
+ });
+
+ AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(firstExport));
+ AddStep("export again", () =>
+ {
+ secondExport = new MemoryStream();
+
+ new LegacyBeatmapExporter(LocalStorage)
+ .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, secondExport, null);
+ });
+
+ const string osu_filename = @"legacy export - stability test (spaceman_atlas) [].osu";
+
+ AddAssert("exports are identical",
+ () => getStringContentsOf(osu_filename, firstExport.GetBuffer()),
+ () => Is.EqualTo(getStringContentsOf(osu_filename, secondExport.GetBuffer())));
+
+ string getStringContentsOf(string filename, byte[] archiveBytes)
+ {
+ using var memoryStream = new MemoryStream(archiveBytes);
+ using var archiveReader = new ZipArchiveReader(memoryStream);
+ byte[] fileContent = archiveReader.GetStream(filename).ReadAllBytesToArray();
+ return Encoding.UTF8.GetString(fileContent);
+ }
+ }
+
+ private IWorkingBeatmap importBeatmapFromStream(Stream stream)
+ {
+ var imported = beatmaps.Import(new ImportTask(stream, "filename.osz")).GetResultSafely();
+ return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0]));
+ }
+
+ private IWorkingBeatmap importBeatmapFromArchives(string filename)
+ {
+ var imported = beatmaps.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely();
+ return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0]));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
index f9f9fa2622..c40624a3a0 100644
--- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
+++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs
@@ -22,9 +22,9 @@ namespace osu.Game.Tests.Database
[HeadlessTest]
public partial class BackgroundDataStoreProcessorTests : OsuTestScene, ILocalUserPlayInfo
{
- public IBindable IsPlaying => isPlaying;
+ public IBindable PlayingState => isPlaying;
- private readonly Bindable isPlaying = new Bindable();
+ private readonly Bindable isPlaying = new Bindable();
private BeatmapSetInfo importedSet = null!;
@@ -37,7 +37,7 @@ namespace osu.Game.Tests.Database
[SetUpSteps]
public void SetUpSteps()
{
- AddStep("Set not playing", () => isPlaying.Value = false);
+ AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying);
}
[Test]
@@ -89,7 +89,7 @@ namespace osu.Game.Tests.Database
});
});
- AddStep("Set playing", () => isPlaying.Value = true);
+ AddStep("Set playing", () => isPlaying.Value = LocalUserPlayingState.Playing);
AddStep("Reset difficulty", () =>
{
@@ -117,7 +117,7 @@ namespace osu.Game.Tests.Database
});
});
- AddStep("Set not playing", () => isPlaying.Value = false);
+ AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying);
AddUntilStep("wait for difficulties repopulated", () =>
{
diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs
index 0eac70f9c8..38746f2567 100644
--- a/osu.Game.Tests/Database/BeatmapImporterTests.cs
+++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs
@@ -716,7 +716,7 @@ namespace osu.Game.Tests.Database
{
foreach (var entry in zip.Entries.ToArray())
{
- if (entry.Key.EndsWith(".osu", StringComparison.InvariantCulture))
+ if (entry.Key!.EndsWith(".osu", StringComparison.InvariantCulture))
zip.RemoveEntry(entry);
}
diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
index cf8c3c6ef1..0f8583253b 100644
--- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
+++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs
@@ -112,6 +112,7 @@ namespace osu.Game.Tests.Editing
{
SliderVelocityMultiplier = slider_velocity
};
+ AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject));
assertSnapDistance(base_distance * slider_velocity, referenceObject, true);
assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject);
@@ -227,26 +228,65 @@ namespace osu.Game.Tests.Editing
assertSnappedDistance(400, 400);
}
+ [Test]
+ public void TestUnsnappedObject()
+ {
+ var slider = new Slider
+ {
+ StartTime = 0,
+ Path = new SliderPath
+ {
+ ControlPoints =
+ {
+ new PathControlPoint(),
+ // simulate object snapped to 1/3rds
+ // this object's end time will be 2000 / 3 = 666.66... ms
+ new PathControlPoint(new Vector2(200 / 3f, 0)),
+ }
+ }
+ };
+
+ AddStep("add slider", () => composer.EditorBeatmap.Add(slider));
+ AddStep("set snap to 1/4", () => BeatDivisor.Value = 4);
+
+ // with default beat length of 1000ms and snap at 1/4, the valid snap times are 500ms, 750ms, and 1000ms
+ // with default settings, the snapped distance will be a tenth of the difference of the time delta
+
+ // (500 - 666.66...) / 10 = -16.66... = -100 / 6
+ assertSnappedDistance(0, -100 / 6f, slider);
+ assertSnappedDistance(7, -100 / 6f, slider);
+
+ // (750 - 666.66...) / 10 = 8.33... = 100 / 12
+ assertSnappedDistance(9, 100 / 12f, slider);
+ assertSnappedDistance(33, 100 / 12f, slider);
+
+ // (1000 - 666.66...) / 10 = 33.33... = 100 / 3
+ assertSnappedDistance(34, 100 / 3f, slider);
+ }
+
[Test]
public void TestUseCurrentSnap()
{
+ ExpandableButton getCurrentSnapButton() => composer.ChildrenOfType().Single(g => g.Name == "snapping")
+ .ChildrenOfType().Single();
+
AddStep("add objects to beatmap", () =>
{
editorBeatmap.Add(new HitCircle { StartTime = 1000 });
editorBeatmap.Add(new HitCircle { Position = new Vector2(100), StartTime = 2000 });
});
- AddStep("hover use current snap button", () => InputManager.MoveMouseTo(composer.ChildrenOfType().Single()));
- AddUntilStep("use current snap expanded", () => composer.ChildrenOfType().Single().Expanded.Value, () => Is.True);
+ AddStep("hover use current snap button", () => InputManager.MoveMouseTo(getCurrentSnapButton()));
+ AddUntilStep("use current snap expanded", () => getCurrentSnapButton().Expanded.Value, () => Is.True);
AddStep("seek before first object", () => EditorClock.Seek(0));
- AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False);
+ AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False);
AddStep("seek to between objects", () => EditorClock.Seek(1500));
- AddUntilStep("use current snap available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.True);
+ AddUntilStep("use current snap available", () => getCurrentSnapButton().Enabled.Value, () => Is.True);
AddStep("seek after last object", () => EditorClock.Seek(2500));
- AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False);
+ AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False);
}
private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity)
@@ -262,7 +302,7 @@ namespace osu.Game.Tests.Editing
=> AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON));
private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null)
- => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
+ => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
private partial class TestHitObjectComposer : OsuHitObjectComposer
{
diff --git a/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs
new file mode 100644
index 0000000000..5f5a1760ea
--- /dev/null
+++ b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs
@@ -0,0 +1,161 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Screens.Edit.Timing;
+
+namespace osu.Game.Tests.Editing
+{
+ [TestFixture]
+ public class TimingSectionAdjustmentsTest
+ {
+ [Test]
+ public void TestOffsetAdjustment()
+ {
+ var controlPoints = new ControlPointInfo();
+
+ controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
+ controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
+ controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
+
+ var beatmap = new Beatmap
+ {
+ ControlPointInfo = controlPoints,
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0 },
+ new HitCircle { StartTime = 200 },
+ new HitCircle { StartTime = 49_900 },
+ new HitCircle { StartTime = 50_000 },
+ new HitCircle { StartTime = 50_200 },
+ new HitCircle { StartTime = 99_800 },
+ new HitCircle { StartTime = 100_000 },
+ new HitCircle { StartTime = 100_050 },
+ new HitCircle { StartTime = 100_550 },
+ }
+ };
+
+ moveTimingPoint(beatmap, 100, -50);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(-50));
+ Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(50_000));
+ });
+
+ moveTimingPoint(beatmap, 50_000, 1_000);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(51_000));
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(100_800));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(100_000));
+ });
+
+ moveTimingPoint(beatmap, 100_000, 10_000);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(110_800));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(110_000));
+ Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(110_050));
+ Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(110_550));
+ });
+ }
+
+ [Test]
+ public void TestBPMAdjustment()
+ {
+ var controlPoints = new ControlPointInfo();
+
+ controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
+ controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
+ controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
+
+ var beatmap = new Beatmap
+ {
+ ControlPointInfo = controlPoints,
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0 },
+ new HitCircle { StartTime = 200 },
+ new Spinner { StartTime = 500, EndTime = 1000 },
+ new HitCircle { StartTime = 49_900 },
+ new HitCircle { StartTime = 50_000 },
+ new HitCircle { StartTime = 50_200 },
+ new HitCircle { StartTime = 99_800 },
+ new HitCircle { StartTime = 100_000 },
+ new HitCircle { StartTime = 100_050 },
+ new HitCircle { StartTime = 100_550 },
+ }
+ };
+
+ adjustBeatLength(beatmap, 100, 50);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(50));
+ Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
+ Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
+ });
+
+ adjustBeatLength(beatmap, 50_000, 400);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
+ Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(149_600));
+ Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
+ });
+
+ adjustBeatLength(beatmap, 100_000, 100);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(199_200));
+ Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
+ Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(100_100));
+ Assert.That(beatmap.HitObjects[9].StartTime, Is.EqualTo(101_100));
+ });
+ }
+
+ private static void moveTimingPoint(IBeatmap beatmap, double originalTime, double adjustment)
+ {
+ var controlPoints = beatmap.ControlPointInfo;
+ var controlPointGroup = controlPoints.GroupAt(originalTime);
+ var timingPoint = controlPointGroup.ControlPoints.OfType().Single();
+ controlPoints.RemoveGroup(controlPointGroup);
+ TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, timingPoint, adjustment);
+ controlPoints.Add(originalTime - adjustment, timingPoint);
+ }
+
+ private static void adjustBeatLength(IBeatmap beatmap, double groupTime, double newBeatLength)
+ {
+ var controlPoints = beatmap.ControlPointInfo;
+ var controlPointGroup = controlPoints.GroupAt(groupTime);
+ var timingPoint = controlPointGroup.ControlPoints.OfType().Single();
+ double oldBeatLength = timingPoint.BeatLength;
+ timingPoint.BeatLength = newBeatLength;
+ TimingSectionAdjustments.SetHitObjectBPM(beatmap, timingPoint, oldBeatLength);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs
index 6b43ab83c5..42f50efdbf 100644
--- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs
+++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs
@@ -3,11 +3,13 @@
using NUnit.Framework;
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Input;
+using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Input
@@ -15,9 +17,20 @@ namespace osu.Game.Tests.Input
[HeadlessTest]
public partial class ConfineMouseTrackerTest : OsuGameTestScene
{
+ private readonly Bindable playingState = new Bindable();
+
[Resolved]
private FrameworkConfigManager frameworkConfigManager { get; set; } = null!;
+ [SetUpSteps]
+ public override void SetUpSteps()
+ {
+ base.SetUpSteps();
+
+ // a bit dodgy.
+ AddStep("bind playing state", () => ((IBindable)playingState).BindTo(((ILocalUserPlayInfo)Game).PlayingState));
+ }
+
[TestCase(WindowMode.Windowed)]
[TestCase(WindowMode.Borderless)]
public void TestDisableConfining(WindowMode windowMode)
@@ -88,7 +101,7 @@ namespace osu.Game.Tests.Input
=> AddStep($"set {mode} game-side", () => Game.LocalConfig.SetValue(OsuSetting.ConfineMouseMode, mode));
private void setLocalUserPlayingTo(bool playing)
- => AddStep($"local user {(playing ? "playing" : "not playing")}", () => Game.LocalUserPlaying.Value = playing);
+ => AddStep($"local user {(playing ? "playing" : "not playing")}", () => playingState.Value = playing ? LocalUserPlayingState.Playing : LocalUserPlayingState.NotPlaying);
private void gameSideModeIs(OsuConfineMouseMode mode)
=> AddAssert($"mode is {mode} game-side", () => Game.LocalConfig.Get(OsuSetting.ConfineMouseMode) == mode);
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
index 10e0e46f4c..1efcc8542d 100644
--- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
@@ -148,6 +148,7 @@ namespace osu.Game.Tests.NonVisual.Filtering
[TestCase("tags too", false)]
[TestCase("version", false)]
[TestCase("an auteur", true)]
+ [TestCase("unit", false)]
public void TestCriteriaMatchingTerms(string terms, bool filtered)
{
var exampleBeatmapInfo = getExampleBeatmap();
@@ -175,6 +176,7 @@ namespace osu.Game.Tests.NonVisual.Filtering
[TestCase("\"Artist\"!", true)]
[TestCase("\"The Artist\"!", false)]
[TestCase("\"the artist\"!", false)]
+ [TestCase("\"unit tests\"!", false)]
[TestCase("\"\\\"", true)] // nasty case, covers properly escaping user input in underlying regex.
public void TestCriteriaMatchingExactTerms(string terms, bool filtered)
{
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
index 9ecfa72947..f4e324d7ba 100644
--- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
@@ -501,6 +501,18 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
}
+ [Test]
+ public void TestApplySourceQueries()
+ {
+ const string query = "find me songs with source=\"unit tests\" please";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("unit tests", filterCriteria.Source.SearchTerm);
+ Assert.That(filterCriteria.Source.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
+ }
+
private class CustomFilterCriteria : IRulesetFilterCriteria
{
public string? CustomValue { get; set; }
@@ -627,6 +639,87 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min);
}
+ private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) =>
+ new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero);
+
+ private static readonly object[] ranked_date_valid_test_cases =
+ {
+ new object[] { "ranked<2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Max },
+ new object[] { "ranked<2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Max },
+ new object[] { "ranked<2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max },
+ new object[] { "ranked<2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max },
+
+ new object[] { "ranked<=2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Max },
+ new object[] { "ranked<=2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max },
+ new object[] { "ranked<=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
+ new object[] { "ranked<=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
+
+ new object[] { "ranked>2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked>2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked>2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked>2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min },
+
+ new object[] { "ranked>=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked>=2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked>=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked>=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
+
+ new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max },
+ new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
+ new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
+ };
+
+ [Test]
+ [TestCaseSource(nameof(ranked_date_valid_test_cases))]
+ public void TestValidRankedDateQueries(string query, DateTimeOffset expected, Func f)
+ {
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual(true, filterCriteria.DateRanked.HasFilter);
+ Assert.AreEqual(expected, f(filterCriteria));
+ }
+
+ private static readonly object[] ranked_date_invalid_test_cases =
+ {
+ new object[] { "ranked<0" },
+ new object[] { "ranked=99999" },
+ new object[] { "ranked>=2012-03-05-04" },
+ };
+
+ [Test]
+ [TestCaseSource(nameof(ranked_date_invalid_test_cases))]
+ public void TestInvalidRankedDateQueries(string query)
+ {
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual(false, filterCriteria.DateRanked.HasFilter);
+ }
+
+ private static readonly object[] submitted_date_test_cases =
+ {
+ new object[] { "submitted<2012", true },
+ new object[] { "submitted<2012.03", true },
+ new object[] { "submitted<2012/03/05", true },
+ new object[] { "submitted<2012-3-5", true },
+
+ new object[] { "submitted<0", false },
+ new object[] { "submitted=99999", false },
+ new object[] { "submitted>=2012-03-05-04", false },
+ new object[] { "submitted>=2012/03.05-04", false },
+ };
+
+ [Test]
+ [TestCaseSource(nameof(submitted_date_test_cases))]
+ public void TestInvalidRankedDateQueries(string query, bool expected)
+ {
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual(expected, filterCriteria.DateSubmitted.HasFilter);
+ }
+
private static readonly object[] played_query_tests =
{
new object[] { "0", DateTimeOffset.MinValue, true },
diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs
index d4b69c1be2..07d6d68e82 100644
--- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs
+++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs
@@ -96,6 +96,7 @@ namespace osu.Game.Tests.NonVisual
public override IAdjustableAudioComponent Audio { get; }
public override Playfield Playfield { get; }
+ public override PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; }
public override Container Overlays { get; }
public override Container FrameStableComponents { get; }
public override IFrameStableClock FrameStableClock { get; }
diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
index b4bbe274a5..559db16751 100644
--- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
+++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
@@ -73,9 +73,9 @@ namespace osu.Game.Tests.NonVisual.Multiplayer
AddStep("create room initially in gameplay", () =>
{
var newRoom = new Room();
- newRoom.CopyFrom(SelectedRoom.Value);
+ newRoom.CopyFrom(SelectedRoom.Value!);
- newRoom.RoomID.Value = null;
+ newRoom.RoomID = null;
MultiplayerClient.RoomSetupAction = room =>
{
room.State = MultiplayerRoomState.Playing;
diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs
index 7b0b211899..8f6325c70b 100644
--- a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs
+++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Collections.Generic;
using NUnit.Framework;
@@ -21,12 +19,12 @@ namespace osu.Game.Tests.OnlinePlay
[HeadlessTest]
public partial class TestSceneCatchUpSyncManager : OsuTestScene
{
- private GameplayClockContainer master;
- private SpectatorSyncManager syncManager;
+ private GameplayClockContainer master = null!;
+ private SpectatorSyncManager syncManager = null!;
- private Dictionary clocksById;
- private SpectatorPlayerClock player1;
- private SpectatorPlayerClock player2;
+ private Dictionary clocksById = null!;
+ private SpectatorPlayerClock player1 = null!;
+ private SpectatorPlayerClock player2 = null!;
[SetUp]
public void Setup()
diff --git a/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz b/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz
new file mode 100644
index 0000000000..38dedc35d1
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz differ
diff --git a/osu.Game.Tests/Resources/Archives/legacy-export-stability-test.olz b/osu.Game.Tests/Resources/Archives/legacy-export-stability-test.olz
new file mode 100644
index 0000000000..c6cf33acaf
Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/legacy-export-stability-test.olz differ
diff --git a/osu.Game.Tests/Resources/mania-0-01-sv.osu b/osu.Game.Tests/Resources/mania-0-01-sv.osu
new file mode 100644
index 0000000000..295a8a423a
--- /dev/null
+++ b/osu.Game.Tests/Resources/mania-0-01-sv.osu
@@ -0,0 +1,39 @@
+osu file format v14
+
+[General]
+SampleSet: Normal
+StackLeniency: 0.7
+Mode: 3
+
+[Difficulty]
+HPDrainRate:3
+CircleSize:5
+OverallDifficulty:8
+ApproachRate:8
+SliderMultiplier:3.59999990463257
+SliderTickRate:2
+
+[TimingPoints]
+24,352.941176470588,4,1,1,100,1,0
+6376,-10000,4,1,1,100,0,0
+
+[HitObjects]
+51,192,24,1,0,0:0:0:0:
+153,192,200,1,0,0:0:0:0:
+358,192,376,1,0,0:0:0:0:
+460,192,553,1,0,0:0:0:0:
+460,192,729,128,0,1435:0:0:0:0:
+358,192,906,128,0,1612:0:0:0:0:
+256,192,1082,128,0,1788:0:0:0:0:
+153,192,1259,128,0,1965:0:0:0:0:
+51,192,1435,128,0,2141:0:0:0:0:
+51,192,2318,1,12,0:0:0:0:
+153,192,2318,1,4,0:0:0:0:
+256,192,2318,1,6,0:0:0:0:
+358,192,2318,1,14,0:0:0:0:
+460,192,2318,1,0,0:0:0:0:
+51,192,2494,128,0,2582:0:0:0:0:
+153,192,2494,128,14,2582:0:0:0:0:
+256,192,2494,128,6,2582:0:0:0:0:
+358,192,2494,128,4,2582:0:0:0:0:
+460,192,2494,128,12,2582:0:0:0:0:
diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
index 747cf73baf..0f2f716a07 100644
--- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
+++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
@@ -12,6 +12,7 @@ using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
@@ -205,7 +206,9 @@ namespace osu.Game.Tests.Visual.Collections
AddStep("click first delete button", () =>
{
- InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0));
+ InputManager.MoveMouseTo(dialog
+ .ChildrenOfType().Single(i => i.Model.Value.Name == "1")
+ .ChildrenOfType().Single(), new Vector2(5, 0));
InputManager.Click(MouseButton.Left);
});
@@ -213,9 +216,11 @@ namespace osu.Game.Tests.Visual.Collections
assertCollectionCount(1);
assertCollectionName(0, "2");
- AddStep("click first delete button", () =>
+ AddStep("click second delete button", () =>
{
- InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0));
+ InputManager.MoveMouseTo(dialog
+ .ChildrenOfType().Single(i => i.Model.Value.Name == "2")
+ .ChildrenOfType().Single(), new Vector2(5, 0));
InputManager.Click(MouseButton.Left);
});
@@ -310,7 +315,7 @@ namespace osu.Game.Tests.Visual.Collections
AddStep("focus first collection", () =>
{
- InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().First());
+ InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().Single(i => i.Model.Value.Name == "1"));
InputManager.Click(MouseButton.Left);
});
@@ -333,10 +338,44 @@ namespace osu.Game.Tests.Visual.Collections
AddUntilStep("collection has new name", () => first.Name == "First");
}
+ [Test]
+ public void TestSearch()
+ {
+ BeatmapCollection first = null!;
+
+ AddStep("add two collections", () =>
+ {
+ Realm.Write(r =>
+ {
+ r.Add(new[]
+ {
+ first = new BeatmapCollection(name: "1"),
+ new BeatmapCollection(name: "2"),
+ });
+ });
+ });
+
+ assertCollectionName(0, "1");
+ assertCollectionName(1, "2");
+
+ AddStep("search for 1", () => dialog.ChildrenOfType().Single().Current.Value = "1");
+
+ assertCollectionCount(1);
+
+ AddStep("change first collection name", () => Realm.Write(_ => first.Name = "First"));
+
+ assertCollectionCount(0);
+
+ AddStep("search for first", () => dialog.ChildrenOfType().Single().Current.Value = "firs");
+
+ assertCollectionCount(1);
+ }
+
private void assertCollectionCount(int count)
- => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count() == count + 1); // +1 for placeholder
+ => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count(i => i.IsPresent) == count + 1); // +1 for placeholder
private void assertCollectionName(int index, string name)
- => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name);
+ => AddUntilStep($"item {index + 1} has correct name",
+ () => dialog.ChildrenOfType().Single().OrderedItems.ElementAt(index).ChildrenOfType().First().Text == name);
}
}
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs
index e10b3f76e6..0742ed5eb9 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs
@@ -6,10 +6,12 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Screens;
+using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Metadata;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
+using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.Metadata;
@@ -37,18 +39,18 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
var room = new Room
{
- RoomID = { Value = 1234 },
- Name = { Value = "Daily Challenge: June 4, 2024" },
+ RoomID = 1234,
+ Name = "Daily Challenge: June 4, 2024",
Playlist =
- {
+ [
new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First())
{
RequiredMods = [new APIMod(new OsuModTraceable())],
AllowedMods = [new APIMod(new OsuModDoubleTime())]
}
- },
- EndDate = { Value = DateTimeOffset.Now.AddHours(12) },
- Category = { Value = RoomCategory.DailyChallenge }
+ ],
+ EndDate = DateTimeOffset.Now.AddHours(12),
+ Category = RoomCategory.DailyChallenge
};
AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
@@ -60,18 +62,18 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
var room = new Room
{
- RoomID = { Value = 1234 },
- Name = { Value = "Daily Challenge: June 4, 2024" },
+ RoomID = 1234,
+ Name = "Daily Challenge: June 4, 2024",
Playlist =
- {
+ [
new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First())
{
RequiredMods = [new APIMod(new OsuModTraceable())],
AllowedMods = [new APIMod(new OsuModDoubleTime())]
}
- },
- EndDate = { Value = DateTimeOffset.Now.AddHours(12) },
- Category = { Value = RoomCategory.DailyChallenge }
+ ],
+ EndDate = DateTimeOffset.Now.AddHours(12),
+ Category = RoomCategory.DailyChallenge
};
AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
@@ -81,6 +83,38 @@ namespace osu.Game.Tests.Visual.DailyChallenge
AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
AddUntilStep("wait for screen", () => screen.IsCurrentScreen());
AddStep("daily challenge ended", () => metadataClient.DailyChallengeInfo.Value = null);
+ AddAssert("notification posted", () => notificationOverlay.AllNotifications.OfType().Any(n => n.Text == DailyChallengeStrings.ChallengeEndedNotification));
+ }
+
+ [Test]
+ public void TestConclusionNotificationDoesNotFireOnDisconnect()
+ {
+ var room = new Room
+ {
+ RoomID = 1234,
+ Name = "Daily Challenge: June 4, 2024",
+ Playlist =
+ [
+ new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First())
+ {
+ RequiredMods = [new APIMod(new OsuModTraceable())],
+ AllowedMods = [new APIMod(new OsuModDoubleTime())]
+ }
+ ],
+ EndDate = DateTimeOffset.Now.AddHours(12),
+ Category = RoomCategory.DailyChallenge
+ };
+
+ AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
+ AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 });
+
+ Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!;
+ AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
+ AddUntilStep("wait for screen", () => screen.IsCurrentScreen());
+ AddStep("disconnect from metadata server", () => metadataClient.Disconnect());
+ AddUntilStep("wait for disconnection", () => metadataClient.DailyChallengeInfo.Value, () => Is.Null);
+ AddAssert("no notification posted", () => notificationOverlay.AllNotifications, () => Is.Empty);
+ AddStep("reconnect to metadata server", () => metadataClient.Reconnect());
}
}
}
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs
index d53e386ad4..b9470f3be4 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs
@@ -26,11 +26,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge
private readonly Bindable room = new Bindable(new Room());
- protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new CachedModelDependencyContainer(base.CreateChildDependencies(parent))
- {
- Model = { BindTarget = room }
- };
-
[Test]
public void TestBasicAppearance()
{
@@ -98,7 +93,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
Origin = Anchor.Centre,
Children = new Drawable[]
{
- new DailyChallengeTimeRemainingRing(),
+ new DailyChallengeTimeRemainingRing(room.Value),
breakdown = new DailyChallengeScoreBreakdown(),
}
}
@@ -125,8 +120,8 @@ namespace osu.Game.Tests.Visual.DailyChallenge
AddSliderStep("update time remaining", 0f, 1f, 0f, progress =>
{
var startedTimeAgo = TimeSpan.FromHours(24) * progress;
- room.Value.StartDate.Value = DateTimeOffset.Now - startedTimeAgo;
- room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1);
+ room.Value.StartDate = DateTimeOffset.Now - startedTimeAgo;
+ room.Value.EndDate = room.Value.StartDate.Value.AddDays(1);
});
AddStep("add normal score", () =>
{
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs
index f1a2d6b5f2..d6665e24a4 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs
@@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
Add(metadataClient);
// add button to observe for daily challenge changes and perform its logic.
- Add(new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), _ => { }, 0, Key.D));
+ Add(new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D));
}
[Test]
@@ -68,19 +68,19 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
API.Perform(new CreateRoomRequest(room = new Room
{
- RoomID = { Value = roomId },
- Name = { Value = "Daily Challenge: June 4, 2024" },
+ RoomID = roomId,
+ Name = "Daily Challenge: June 4, 2024",
Playlist =
- {
+ [
new PlaylistItem(CreateAPIBeatmap(new OsuRuleset().RulesetInfo))
{
RequiredMods = [new APIMod(new OsuModTraceable())],
AllowedMods = [new APIMod(new OsuModDoubleTime())]
}
- },
- StartDate = { Value = DateTimeOffset.Now },
- EndDate = { Value = DateTimeOffset.Now.AddHours(24) },
- Category = { Value = RoomCategory.DailyChallenge }
+ ],
+ StartDate = DateTimeOffset.Now,
+ EndDate = DateTimeOffset.Now.AddHours(24),
+ Category = RoomCategory.DailyChallenge
}));
});
AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = roomId }));
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs
index 5fff6bb010..d21ca22e1b 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs
@@ -43,7 +43,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
return false;
};
});
- AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = { Value = 1 } }, new PlaylistItem(Beatmap.Value.BeatmapInfo))
+ AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = 1 }, new PlaylistItem(Beatmap.Value.BeatmapInfo))
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
@@ -86,7 +86,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
return false;
};
});
- AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = { Value = 1 } }, new PlaylistItem(Beatmap.Value.BeatmapInfo))
+ AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = 1 }, new PlaylistItem(Beatmap.Value.BeatmapInfo))
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs
index baa1eb8318..eebbd82190 100644
--- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs
+++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs
@@ -18,11 +18,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge
{
private readonly Bindable room = new Bindable(new Room());
- protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new CachedModelDependencyContainer(base.CreateChildDependencies(parent))
- {
- Model = { BindTarget = room }
- };
-
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum);
@@ -38,7 +33,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4,
},
- ring = new DailyChallengeTimeRemainingRing
+ ring = new DailyChallengeTimeRemainingRing(room.Value)
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
@@ -59,29 +54,29 @@ namespace osu.Game.Tests.Visual.DailyChallenge
AddStep("just started", () =>
{
- room.Value.StartDate.Value = DateTimeOffset.Now.AddMinutes(-1);
- room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1);
+ room.Value.StartDate = DateTimeOffset.Now.AddMinutes(-1);
+ room.Value.EndDate = room.Value.StartDate.Value.AddDays(1);
});
AddStep("midway through", () =>
{
- room.Value.StartDate.Value = DateTimeOffset.Now.AddHours(-12);
- room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1);
+ room.Value.StartDate = DateTimeOffset.Now.AddHours(-12);
+ room.Value.EndDate = room.Value.StartDate.Value.AddDays(1);
});
AddStep("nearing end", () =>
{
- room.Value.StartDate.Value = DateTimeOffset.Now.AddDays(-1).AddMinutes(8);
- room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1);
+ room.Value.StartDate = DateTimeOffset.Now.AddDays(-1).AddMinutes(8);
+ room.Value.EndDate = room.Value.StartDate.Value.AddDays(1);
});
AddStep("already ended", () =>
{
- room.Value.StartDate.Value = DateTimeOffset.Now.AddDays(-2);
- room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1);
+ room.Value.StartDate = DateTimeOffset.Now.AddDays(-2);
+ room.Value.EndDate = room.Value.StartDate.Value.AddDays(1);
});
AddSliderStep("manual progress", 0f, 1f, 0f, progress =>
{
var startedTimeAgo = TimeSpan.FromHours(24) * progress;
- room.Value.StartDate.Value = DateTimeOffset.Now - startedTimeAgo;
- room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1);
+ room.Value.StartDate = DateTimeOffset.Now - startedTimeAgo;
+ room.Value.EndDate = room.Value.StartDate.Value.AddDays(1);
});
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs
new file mode 100644
index 0000000000..5a3329bbc9
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs
@@ -0,0 +1,123 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Overlays;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Screens.Edit;
+using osu.Game.Screens.Edit.Setup;
+using osu.Game.Skinning;
+using osuTK.Graphics;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ [HeadlessTest]
+ public partial class TestSceneColoursSection : OsuManualInputManagerTestScene
+ {
+ [Test]
+ public void TestNoBeatmapSkinColours()
+ {
+ LegacyBeatmapSkin skin = null!;
+ ColoursSection coloursSection = null!;
+
+ AddStep("create beatmap skin", () => skin = new LegacyBeatmapSkin(new BeatmapInfo(), null));
+ AddStep("create colours section", () => Child = new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies =
+ [
+ (typeof(EditorBeatmap), new EditorBeatmap(new Beatmap
+ {
+ BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }
+ }, skin)),
+ (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine))
+ ],
+ Child = coloursSection = new ColoursSection
+ {
+ RelativeSizeAxes = Axes.X,
+ }
+ });
+ AddAssert("beatmap skin has no colours", () => skin.Configuration.CustomComboColours, () => Is.Empty);
+ AddAssert("section displays default combo colours",
+ () => coloursSection.ChildrenOfType().Single().Colours,
+ () => Is.EquivalentTo(new Colour4[]
+ {
+ SkinConfiguration.DefaultComboColours[1],
+ SkinConfiguration.DefaultComboColours[2],
+ SkinConfiguration.DefaultComboColours[3],
+ SkinConfiguration.DefaultComboColours[0],
+ }));
+
+ AddStep("add a colour", () => coloursSection.ChildrenOfType().Single().Colours.Add(Colour4.Aqua));
+ AddAssert("beatmap skin has colours",
+ () => skin.Configuration.CustomComboColours,
+ () => Is.EquivalentTo(new[]
+ {
+ SkinConfiguration.DefaultComboColours[1],
+ SkinConfiguration.DefaultComboColours[2],
+ SkinConfiguration.DefaultComboColours[3],
+ Color4.Aqua,
+ SkinConfiguration.DefaultComboColours[0],
+ }));
+ }
+
+ [Test]
+ public void TestExistingColours()
+ {
+ LegacyBeatmapSkin skin = null!;
+ ColoursSection coloursSection = null!;
+
+ AddStep("create beatmap skin", () =>
+ {
+ skin = new LegacyBeatmapSkin(new BeatmapInfo(), null);
+ skin.Configuration.CustomComboColours = new List
+ {
+ Color4.Azure,
+ Color4.Beige,
+ Color4.Chartreuse
+ };
+ });
+ AddStep("create colours section", () => Child = new DependencyProvidingContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ CachedDependencies =
+ [
+ (typeof(EditorBeatmap), new EditorBeatmap(new Beatmap
+ {
+ BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }
+ }, skin)),
+ (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine))
+ ],
+ Child = coloursSection = new ColoursSection
+ {
+ RelativeSizeAxes = Axes.X,
+ }
+ });
+ AddAssert("section displays combo colours",
+ () => coloursSection.ChildrenOfType().Single().Colours,
+ () => Is.EquivalentTo(new[]
+ {
+ Colour4.Beige,
+ Colour4.Chartreuse,
+ Colour4.Azure,
+ }));
+
+ AddStep("add a colour", () => coloursSection.ChildrenOfType().Single().Colours.Add(Colour4.Aqua));
+ AddAssert("beatmap skin has colours",
+ () => skin.Configuration.CustomComboColours,
+ () => Is.EquivalentTo(new[]
+ {
+ Color4.Azure,
+ Color4.Beige,
+ Color4.Aqua,
+ Color4.Chartreuse
+ }));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
index cbc9088d04..fd3431c08b 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs
@@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
+using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
@@ -82,7 +83,7 @@ namespace osu.Game.Tests.Visual.Editing
}
[Test]
- public void TestNudgeSelection()
+ public void TestNudgeSelectionTime()
{
HitCircle[] addedObjects = null!;
@@ -103,6 +104,51 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100);
}
+ [Test]
+ public void TestNudgeSelectionPosition()
+ {
+ HitCircle addedObject = null!;
+
+ AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[]
+ {
+ addedObject = new HitCircle { StartTime = 200, Position = new Vector2(100) },
+ }));
+
+ AddStep("select object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
+
+ AddStep("nudge up", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.Up);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddAssert("object position moved up", () => addedObject.Position.Y, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON));
+
+ AddStep("nudge down", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.Down);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddAssert("object position moved down", () => addedObject.Position.Y, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON));
+
+ AddStep("nudge left", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.Left);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddAssert("object position moved left", () => addedObject.Position.X, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON));
+
+ AddStep("nudge right", () =>
+ {
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.Key(Key.Right);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ });
+ AddAssert("object position moved right", () => addedObject.Position.X, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON));
+ }
+
[Test]
public void TestRotateHotkeys()
{
@@ -219,6 +265,51 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1]));
}
+ [Test]
+ public void TestMultiSelectWithDragBox()
+ {
+ var addedObjects = new[]
+ {
+ new HitCircle { StartTime = 100 },
+ new HitCircle { StartTime = 200, Position = new Vector2(100) },
+ new HitCircle { StartTime = 300, Position = new Vector2(512, 0) },
+ new HitCircle { StartTime = 400, Position = new Vector2(412, 100) },
+ };
+ AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects));
+
+ AddStep("start dragging", () =>
+ {
+ InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre);
+ InputManager.PressButton(MouseButton.Left);
+ });
+ AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopLeft - new Vector2(5)));
+ AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2));
+
+ AddStep("start dragging with control", () =>
+ {
+ InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre);
+ InputManager.PressButton(MouseButton.Left);
+ InputManager.PressKey(Key.ControlLeft);
+ });
+ AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5)));
+ AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
+ AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft));
+
+ AddAssert("4 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(4));
+
+ AddStep("start dragging without control", () =>
+ {
+ InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre);
+ InputManager.PressButton(MouseButton.Left);
+ });
+ AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5)));
+ AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2));
+ }
+
[Test]
public void TestNearestSelection()
{
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs
index 9a66e1676d..4dd27a7b6e 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs
@@ -7,12 +7,14 @@ using System;
using System.Globalization;
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup;
@@ -25,6 +27,9 @@ namespace osu.Game.Tests.Visual.Editing
private TestDesignSection designSection;
private EditorBeatmap editorBeatmap { get; set; }
+ [Cached]
+ private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
+
[SetUpSteps]
public void SetUp()
{
@@ -42,7 +47,7 @@ namespace osu.Game.Tests.Visual.Editing
{
(typeof(EditorBeatmap), editorBeatmap)
},
- Child = designSection = new TestDesignSection()
+ Child = designSection = new TestDesignSection { RelativeSizeAxes = Axes.X }
});
}
@@ -99,11 +104,11 @@ namespace osu.Game.Tests.Visual.Editing
private partial class TestDesignSection : DesignSection
{
- public new LabelledSwitchButton EnableCountdown => base.EnableCountdown;
+ public new FormCheckBox EnableCountdown => base.EnableCountdown;
public new FillFlowContainer CountdownSettings => base.CountdownSettings;
- public new LabelledEnumDropdown CountdownSpeed => base.CountdownSpeed;
- public new LabelledNumberBox CountdownOffset => base.CountdownOffset;
+ public new FormEnumDropdown CountdownSpeed => base.CountdownSpeed;
+ public new FormTextBox CountdownOffset => base.CountdownOffset;
}
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs
index d4bd77642c..62ff59c6b3 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs
@@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
+using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Storyboards;
using osu.Game.Tests.Beatmaps.IO;
using osuTK.Input;
@@ -60,7 +61,7 @@ namespace osu.Game.Tests.Visual.Editing
beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash;
});
- AddStep("click File", () => this.ChildrenOfType().First().TriggerClick());
+ AddStep("click File", () => this.ChildrenOfType().First().TriggerClick());
if (i == 11)
{
@@ -107,7 +108,7 @@ namespace osu.Game.Tests.Visual.Editing
EditorBeatmap.EndChange();
});
- AddStep("click File", () => this.ChildrenOfType().First().TriggerClick());
+ AddStep("click File", () => this.ChildrenOfType().First().TriggerClick());
AddStep("click delete", () => getDeleteMenuItem().TriggerClick());
AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null);
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs
index f2a015402a..c1a788cd22 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs
@@ -199,7 +199,7 @@ namespace osu.Game.Tests.Visual.Editing
public double FindSnappedDuration(HitObject referenceObject, float distance) => 0;
- public float FindSnappedDistance(HitObject referenceObject, float distance) => 0;
+ public float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) => 0;
}
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs
index da4f159cae..06facc546d 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs
@@ -1,11 +1,13 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Objects;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing
@@ -135,9 +137,42 @@ namespace osu.Game.Tests.Visual.Editing
pressAndCheckTime(Key.Up, 0);
}
- private void pressAndCheckTime(Key key, double expectedTime)
+ [Test]
+ public void TestSeekBetweenObjects()
{
- AddStep($"press {key}", () => InputManager.Key(key));
+ AddStep("add objects", () =>
+ {
+ EditorBeatmap.Clear();
+ EditorBeatmap.AddRange(new[]
+ {
+ new HitCircle { StartTime = 1000, },
+ new HitCircle { StartTime = 2250, },
+ new HitCircle { StartTime = 3600, },
+ });
+ });
+ AddStep("seek to 0", () => EditorClock.Seek(0));
+
+ pressAndCheckTime(Key.Right, 1000, Key.ControlLeft);
+ pressAndCheckTime(Key.Right, 2250, Key.ControlLeft);
+ pressAndCheckTime(Key.Right, 3600, Key.ControlLeft);
+ pressAndCheckTime(Key.Right, 3600, Key.ControlLeft);
+ pressAndCheckTime(Key.Left, 2250, Key.ControlLeft);
+ pressAndCheckTime(Key.Left, 1000, Key.ControlLeft);
+ pressAndCheckTime(Key.Left, 1000, Key.ControlLeft);
+ }
+
+ private void pressAndCheckTime(Key key, double expectedTime, params Key[] modifiers)
+ {
+ AddStep($"press {key} with {(modifiers.Any() ? string.Join(',', modifiers) : "no modifiers")}", () =>
+ {
+ foreach (var modifier in modifiers)
+ InputManager.PressKey(modifier);
+
+ InputManager.Key(key);
+
+ foreach (var modifier in modifiers)
+ InputManager.ReleaseKey(modifier);
+ });
AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1));
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
index f392841ac7..d7c92a64b1 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
@@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons;
using osu.Game.Screens.Edit.Components.TernaryButtons;
@@ -82,6 +83,45 @@ namespace osu.Game.Tests.Visual.Editing
});
}
+ [Test]
+ public void TestPlacementOutsideComposeScreen()
+ {
+ AddStep("clear all control points and hitobjects", () =>
+ {
+ editorBeatmap.ControlPointInfo.Clear();
+ editorBeatmap.Clear();
+ });
+
+ AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
+ AddStep("select circle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").TriggerClick());
+ AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single()));
+ AddStep("click", () => InputManager.Click(MouseButton.Left));
+ AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1);
+
+ AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 20f)));
+ AddStep("click", () => InputManager.Click(MouseButton.Left));
+ AddAssert("no circle placed", () => editorBeatmap.HitObjects.Count == 1);
+ }
+
+ [Test]
+ public void TestDragSliderOutsideComposeScreen()
+ {
+ AddStep("clear all control points and hitobjects", () =>
+ {
+ editorBeatmap.ControlPointInfo.Clear();
+ editorBeatmap.Clear();
+ });
+
+ AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
+ AddStep("select slider", () => hitObjectComposer.ChildrenOfType