diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 99906f0895..c4ba6e5143 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -21,7 +21,7 @@
]
},
"ppy.localisationanalyser.tools": {
- "version": "2023.1117.0",
+ "version": "2024.802.0",
"commands": [
"localisation"
]
diff --git a/.editorconfig b/.editorconfig
index c249e5e9b3..7aecde95ee 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -196,6 +196,9 @@ csharp_style_prefer_switch_expression = false:none
csharp_style_namespace_declarations = block_scoped:warning
+#Style - C# 12 features
+csharp_style_prefer_primary_constructors = false
+
[*.{yaml,yml}]
insert_final_newline = true
indent_style = space
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 1ea4654563..cb45447ed5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,10 +64,11 @@ jobs:
matrix:
os:
- { prettyname: Windows, fullname: windows-latest }
- - { prettyname: macOS, fullname: macos-latest }
+ # macOS runner performance has gotten unbearably slow so let's turn them off temporarily.
+ # - { prettyname: macOS, fullname: macos-latest }
- { prettyname: Linux, fullname: ubuntu-latest }
threadingMode: ['SingleThread', 'MultiThreaded']
- timeout-minutes: 60
+ timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -87,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}}
@@ -120,9 +121,7 @@ jobs:
build-only-ios:
name: Build only (iOS)
- # `macos-13` is required, because the newest Microsoft.iOS.Sdk versions require Xcode 14.3.
- # TODO: can be changed to `macos-latest` once `macos-13` becomes latest (currently in beta: https://github.com/actions/runner-images/tree/main#available-images)
- runs-on: macos-13
+ runs-on: macos-latest
timeout-minutes: 60
steps:
- name: Checkout
@@ -134,10 +133,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
- run: dotnet workload install maui-ios
-
- - name: Select Xcode 15.2
- run: sudo xcode-select -s /Applications/Xcode_15.2.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 7fd0f798cd..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
@@ -111,7 +115,7 @@ jobs:
steps:
- name: Check permissions
run: |
- ALLOWED_USERS=(smoogipoo peppy bdach)
+ ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte)
for i in "${ALLOWED_USERS[@]}"; do
if [[ "${{ github.actor }}" == "$i" ]]; then
exit 0
@@ -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/.gitignore b/.gitignore
index 11fee27f28..1fec94d82b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -265,6 +265,8 @@ __pycache__/
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
+.idea/*/.idea/projectSettingsUpdater.xml
+.idea/*/.idea/encodings.xml
# Generated files
.idea/**/contentModel.xml
diff --git a/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml
deleted file mode 100644
index 4bb9f4d2a0..0000000000
--- a/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.osu.Desktop/.idea/encodings.xml b/.idea/.idea.osu.Desktop/.idea/encodings.xml
deleted file mode 100644
index 15a15b218a..0000000000
--- a/.idea/.idea.osu.Desktop/.idea/encodings.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml
deleted file mode 100644
index 4bb9f4d2a0..0000000000
--- a/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.osu.iOS/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.iOS/.idea/projectSettingsUpdater.xml
deleted file mode 100644
index 4bb9f4d2a0..0000000000
--- a/.idea/.idea.osu.iOS/.idea/projectSettingsUpdater.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/.idea.osu/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu/.idea/projectSettingsUpdater.xml
deleted file mode 100644
index 4bb9f4d2a0..0000000000
--- a/.idea/.idea.osu/.idea/projectSettingsUpdater.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
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/CONTRIBUTING.md b/CONTRIBUTING.md
index 0fe6b6fb4d..ebe1e08074 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -55,7 +55,7 @@ When in doubt, it's probably best to start with a discussion first. We will esca
While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change.
-The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience.
+The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good first issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience.
In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive.
diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt
index 03fd21829d..3c60b28765 100644
--- a/CodeAnalysis/BannedSymbols.txt
+++ b/CodeAnalysis/BannedSymbols.txt
@@ -7,7 +7,6 @@ T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable ins
M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText.
M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900)
T:NuGet.Packaging.CollectionExtensions;Don't use internal extension methods.
-M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead.
M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection,NotificationCallbackDelegate) instead.
M:System.Guid.#ctor;Probably meaning to use Guid.NewGuid() instead. If actually wanting empty, use Guid.Empty.
M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IQueryable,NotificationCallbackDelegate) instead.
diff --git a/Directory.Build.props b/Directory.Build.props
index 2d289d0f22..5ba12b845b 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -2,7 +2,6 @@
12.0
- true
enable
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/assets/lazer-nuget.png b/assets/lazer-nuget.png
index fed2f45149..fabfcc223e 100644
Binary files a/assets/lazer-nuget.png and b/assets/lazer-nuget.png differ
diff --git a/assets/lazer.png b/assets/lazer.png
index 2ee44225bf..f564b93d6f 100644
Binary files a/assets/lazer.png and b/assets/lazer.png differ
diff --git a/osu.Android.props b/osu.Android.props
index c61977cfa3..0ebb6be7a1 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/DualStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs
index e9d26b4aa1..6a7634da01 100644
--- a/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs
+++ b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs
@@ -45,18 +45,15 @@ namespace osu.Game.Rulesets.Mania
LeftKeys = stage1LeftKeys,
RightKeys = stage1RightKeys,
SpecialKey = InputKey.V,
- SpecialAction = ManiaAction.Special1,
- NormalActionStart = ManiaAction.Key1
- }.GenerateKeyBindingsFor(singleStageVariant, out var nextNormal);
+ }.GenerateKeyBindingsFor(singleStageVariant);
var stage2Bindings = new VariantMappingGenerator
{
LeftKeys = stage2LeftKeys,
RightKeys = stage2RightKeys,
SpecialKey = InputKey.B,
- SpecialAction = ManiaAction.Special2,
- NormalActionStart = nextNormal
- }.GenerateKeyBindingsFor(singleStageVariant, out _);
+ ActionStart = (ManiaAction)singleStageVariant,
+ }.GenerateKeyBindingsFor(singleStageVariant);
return stage1Bindings.Concat(stage2Bindings);
}
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
new file mode 100644
index 0000000000..d4b61b4661
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs
@@ -0,0 +1,87 @@
+// 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.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Input.Events;
+using osu.Game.Graphics;
+using osu.Game.Rulesets.Mania.Skinning.Default;
+using osuTK;
+
+namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
+{
+ public partial class EditHoldNoteEndPiece : CompositeDrawable
+ {
+ public Action? DragStarted { get; init; }
+ public Action? Dragging { get; init; }
+ public Action? DragEnded { get; init; }
+
+ [Resolved]
+ private OsuColour colours { get; set; } = null!;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Height = DefaultNotePiece.NOTE_HEIGHT;
+
+ InternalChild = new EditNotePiece
+ {
+ RelativeSizeAxes = Axes.Both,
+ Height = 1,
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ updateState();
+ }
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ updateState();
+ return true;
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ updateState();
+ base.OnHoverLost(e);
+ }
+
+ protected override bool OnDragStart(DragStartEvent e)
+ {
+ DragStarted?.Invoke();
+ return true;
+ }
+
+ protected override void OnDrag(DragEvent e)
+ {
+ 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 || 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 8ec5213d5f..915706c044 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs
@@ -1,16 +1,16 @@
// 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 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;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
@@ -18,10 +18,19 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
public partial class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint
{
[Resolved]
- private OsuColour colours { get; set; }
+ private IEditorChangeHandler? changeHandler { get; set; }
- private EditNotePiece head;
- private EditNotePiece tail;
+ [Resolved]
+ private EditorBeatmap? editorBeatmap { get; set; }
+
+ [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)
@@ -33,21 +42,53 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
InternalChildren = new Drawable[]
{
- head = new EditNotePiece { RelativeSizeAxes = Axes.X },
- tail = new EditNotePiece { RelativeSizeAxes = Axes.X },
- new Container
+ body = new EditBodyPiece
{
RelativeSizeAxes = Axes.Both,
- Masking = true,
- BorderThickness = 1,
- BorderColour = colours.Yellow,
- Child = new Box
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ },
+ head = new EditHoldNoteEndPiece
+ {
+ RelativeSizeAxes = Axes.X,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ DragStarted = () => changeHandler?.BeginChange(),
+ Dragging = pos =>
{
- RelativeSizeAxes = Axes.Both,
- Alpha = 0,
- AlwaysPresent = true,
- }
- }
+ double endTimeBeforeDrag = HitObject.EndTime;
+ double proposedStartTime = positionSnapProvider?.FindSnappedPositionAndTime(pos).Time ?? HitObjectContainer.TimeAtScreenSpacePosition(pos);
+ double proposedEndTime = endTimeBeforeDrag;
+
+ if (proposedStartTime >= proposedEndTime)
+ return;
+
+ HitObject.StartTime = proposedStartTime;
+ HitObject.EndTime = proposedEndTime;
+ editorBeatmap?.Update(HitObject);
+ },
+ DragEnded = () => changeHandler?.EndChange(),
+ },
+ tail = new EditHoldNoteEndPiece
+ {
+ RelativeSizeAxes = Axes.X,
+ Anchor = Anchor.BottomCentre,
+ Origin = Anchor.BottomCentre,
+ DragStarted = () => changeHandler?.BeginChange(),
+ Dragging = pos =>
+ {
+ double proposedStartTime = HitObject.StartTime;
+ double proposedEndTime = positionSnapProvider?.FindSnappedPositionAndTime(pos).Time ?? HitObjectContainer.TimeAtScreenSpacePosition(pos);
+
+ if (proposedStartTime >= proposedEndTime)
+ return;
+
+ HitObject.StartTime = proposedStartTime;
+ HitObject.EndTime = proposedEndTime;
+ editorBeatmap?.Update(HitObject);
+ },
+ DragEnded = () => changeHandler?.EndChange(),
+ },
};
}
@@ -55,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/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs
index 5e0512b5dc..a68bd5d6d6 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs
@@ -15,7 +15,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
- public abstract partial class ManiaPlacementBlueprint : PlacementBlueprint
+ public abstract partial class ManiaPlacementBlueprint : HitObjectPlacementBlueprint
where T : ManiaHitObject
{
protected new T HitObject => (T)base.HitObject;
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 8d34373f82..4c4cf519ce 100644
--- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
+++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs
@@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
+using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
@@ -18,6 +19,8 @@ namespace osu.Game.Rulesets.Mania.Edit
{
public BindableBool ShowSpeedChanges { get; } = new BindableBool();
+ public double? TimelineTimeRange { get; set; }
+
public new IScrollingInfo ScrollingInfo => base.ScrollingInfo;
public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods)
@@ -38,5 +41,11 @@ namespace osu.Game.Rulesets.Mania.Edit
Origin = Anchor.Centre,
Size = Vector2.One
};
+
+ protected override void Update()
+ {
+ TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value;
+ base.Update();
+ }
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs
index 99e1ce04b1..592f8d9af7 100644
--- a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs
+++ b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs
@@ -9,7 +9,7 @@ using osu.Game.Rulesets.Mania.Edit.Blueprints;
namespace osu.Game.Rulesets.Mania.Edit
{
- public class HoldNoteCompositionTool : HitObjectCompositionTool
+ public class HoldNoteCompositionTool : CompositionTool
{
public HoldNoteCompositionTool()
: base("Hold")
@@ -18,6 +18,6 @@ namespace osu.Game.Rulesets.Mania.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
- public override PlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
+ public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
index 967cdb0e54..926a4b2736 100644
--- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs
@@ -1,11 +1,10 @@
// 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.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
+using osu.Framework.Allocation;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
@@ -14,6 +13,7 @@ using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
@@ -21,14 +21,17 @@ namespace osu.Game.Rulesets.Mania.Edit
{
public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer
{
- private DrawableManiaEditorRuleset drawableRuleset;
+ private DrawableManiaEditorRuleset drawableRuleset = null!;
+
+ [Resolved]
+ private EditorScreenWithTimeline? screenWithTimeline { get; set; }
public ManiaHitObjectComposer(Ruleset ruleset)
: base(ruleset)
{
}
- public new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield);
+ public new ManiaPlayfield Playfield => drawableRuleset.Playfield;
public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo;
@@ -43,7 +46,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid();
- protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[]
+ protected override IReadOnlyList CompositionTools => new CompositionTool[]
{
new NoteCompositionTool(),
new HoldNoteCompositionTool()
@@ -72,7 +75,7 @@ namespace osu.Game.Rulesets.Mania.Edit
if (!double.TryParse(split[0], out double time) || !int.TryParse(split[1], out int column))
continue;
- ManiaHitObject current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column);
+ ManiaHitObject? current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column);
if (current == null)
continue;
@@ -83,5 +86,13 @@ namespace osu.Game.Rulesets.Mania.Edit
remainingHitObjects = remainingHitObjects.Where(h => h != current && h.StartTime >= current.StartTime).ToList();
}
}
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (screenWithTimeline?.TimelineArea.Timeline != null)
+ 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 8fdbada04f..74e616ac3f 100644
--- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
@@ -4,6 +4,7 @@
using System;
using System.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
@@ -16,6 +17,16 @@ namespace osu.Game.Rulesets.Mania.Edit
[Resolved]
private HitObjectComposer composer { get; set; } = null!;
+ protected override void OnSelectionChanged()
+ {
+ base.OnSelectionChanged();
+
+ var selectedObjects = SelectedItems.OfType().ToArray();
+
+ SelectionBox.CanFlipX = canFlipX(selectedObjects);
+ SelectionBox.CanFlipY = canFlipY(selectedObjects);
+ }
+
public override bool HandleMovement(MoveSelectionEvent moveEvent)
{
var hitObjectBlueprint = (HitObjectSelectionBlueprint)moveEvent.Blueprint;
@@ -26,6 +37,57 @@ namespace osu.Game.Rulesets.Mania.Edit
return true;
}
+ public override bool HandleFlip(Direction direction, bool flipOverOrigin)
+ {
+ var selectedObjects = SelectedItems.OfType().ToArray();
+ var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield;
+
+ if (selectedObjects.Length == 0)
+ return false;
+
+ switch (direction)
+ {
+ case Direction.Horizontal:
+ if (!canFlipX(selectedObjects))
+ return false;
+
+ int firstColumn = flipOverOrigin ? 0 : selectedObjects.Min(ho => ho.Column);
+ int lastColumn = flipOverOrigin ? (int)EditorBeatmap.BeatmapInfo.Difficulty.CircleSize - 1 : selectedObjects.Max(ho => ho.Column);
+
+ performOnSelection(maniaObject =>
+ {
+ maniaPlayfield.Remove(maniaObject);
+ maniaObject.Column = firstColumn + (lastColumn - maniaObject.Column);
+ maniaPlayfield.Add(maniaObject);
+ });
+
+ return true;
+
+ case Direction.Vertical:
+ if (!canFlipY(selectedObjects))
+ return false;
+
+ double selectionStartTime = selectedObjects.Min(ho => ho.StartTime);
+ double selectionEndTime = selectedObjects.Max(ho => ho.GetEndTime());
+
+ performOnSelection(hitObject =>
+ {
+ hitObject.StartTime = selectionStartTime + (selectionEndTime - hitObject.GetEndTime());
+ });
+
+ return true;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(direction), direction, "Cannot flip over the supplied direction.");
+ }
+ }
+
+ private static bool canFlipX(ManiaHitObject[] selectedObjects)
+ => selectedObjects.Select(ho => ho.Column).Distinct().Count() > 1;
+
+ private static bool canFlipY(ManiaHitObject[] selectedObjects)
+ => selectedObjects.Length > 1 && selectedObjects.Min(ho => ho.StartTime) < selectedObjects.Max(ho => ho.GetEndTime());
+
private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent)
{
var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield;
@@ -41,8 +103,10 @@ namespace osu.Game.Rulesets.Mania.Edit
int minColumn = int.MaxValue;
int maxColumn = int.MinValue;
+ var selectedObjects = EditorBeatmap.SelectedHitObjects.OfType().ToArray();
+
// find min/max in an initial pass before actually performing the movement.
- foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType())
+ foreach (var obj in selectedObjects)
{
if (obj.Column < minColumn)
minColumn = obj.Column;
@@ -52,12 +116,26 @@ 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);
});
}
+
+ 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.
+ EditorBeatmap.SelectedHitObjects.Clear();
+ EditorBeatmap.SelectedHitObjects.AddRange(selectedObjects);
+ }
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs
index 08ee05ad3f..2e54d63525 100644
--- a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs
+++ b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs
@@ -10,7 +10,7 @@ using osu.Game.Rulesets.Mania.Objects;
namespace osu.Game.Rulesets.Mania.Edit
{
- public class NoteCompositionTool : HitObjectCompositionTool
+ public class NoteCompositionTool : CompositionTool
{
public NoteCompositionTool()
: base(nameof(Note))
@@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Mania.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
- public override PlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
+ public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs
index 4f983debea..a23988362a 100644
--- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs
@@ -3,20 +3,168 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Localisation;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web;
+using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup;
namespace osu.Game.Rulesets.Mania.Edit.Setup
{
- public partial class ManiaDifficultySection : DifficultySection
+ public partial class ManiaDifficultySection : SetupSection
{
+ public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
+
+ 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; }
+
+ [Resolved]
+ private IEditorChangeHandler? changeHandler { get; set; }
+
[BackgroundDependencyLoader]
private void load()
{
- CircleSizeSlider.Label = BeatmapsetsStrings.ShowStatsCsMania;
- CircleSizeSlider.Description = "The number of columns in the beatmap";
- if (CircleSizeSlider.Current is BindableNumber circleSizeFloat)
- circleSizeFloat.Precision = 1;
+ Children = new Drawable[]
+ {
+ keyCountSlider = new FormSliderBar
+ {
+ 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 FormCheckBox
+ {
+ 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 FormSliderBar
+ {
+ 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 FormSliderBar
+ {
+ 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 FormSliderBar
+ {
+ 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 FormSliderBar
+ {
+ Caption = EditorSetupStrings.TickRate,
+ HintText = EditorSetupStrings.TickRateDescription,
+ Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
+ {
+ Default = 1,
+ MinValue = 1,
+ MaxValue = 4,
+ Precision = 1,
+ },
+ TransferValueOnCommit = true,
+ TabbableContentContainer = this,
+ },
+ };
+
+ keyCountSlider.Current.BindValueChanged(updateKeyCount);
+ healthDrainSlider.Current.BindValueChanged(_ => updateValues());
+ overallDifficultySlider.Current.BindValueChanged(_ => updateValues());
+ baseVelocitySlider.Current.BindValueChanged(_ => updateValues());
+ tickRateSlider.Current.BindValueChanged(_ => updateValues());
+ }
+
+ private bool updatingKeyCount;
+
+ private void updateKeyCount(ValueChangedEvent keyCount)
+ {
+ if (updatingKeyCount) return;
+
+ updateValues();
+
+ if (editor == null) return;
+
+ updatingKeyCount = true;
+
+ editor.Reload().ContinueWith(t =>
+ {
+ if (!t.GetResultSafely())
+ {
+ Schedule(() =>
+ {
+ changeHandler!.RestoreState(-1);
+ Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value = keyCount.OldValue;
+ updatingKeyCount = false;
+ });
+ }
+ else
+ {
+ updatingKeyCount = false;
+ }
+ });
+ }
+
+ private void updateValues()
+ {
+ // for now, update these on commit rather than making BeatmapMetadata bindables.
+ // after switching database engines we can reconsider if switching to bindables is a good direction.
+ Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value;
+ Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value;
+ Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value;
+ Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value;
+ Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value;
+ Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value;
+
+ Beatmap.UpdateAllHitObjects();
+ Beatmap.SaveState();
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs
deleted file mode 100644
index d5a9a311bc..0000000000
--- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-// 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 osu.Framework.Allocation;
-using osu.Framework.Graphics;
-using osu.Game.Graphics.UserInterfaceV2;
-using osu.Game.Screens.Edit.Setup;
-
-namespace osu.Game.Rulesets.Mania.Edit.Setup
-{
- public partial class ManiaSetupSection : RulesetSetupSection
- {
- private LabelledSwitchButton specialStyle;
-
- public ManiaSetupSection()
- : base(new ManiaRuleset().RulesetInfo)
- {
- }
-
- [BackgroundDependencyLoader]
- private void load()
- {
- Children = new Drawable[]
- {
- specialStyle = new LabelledSwitchButton
- {
- Label = "Use special (N+1) style",
- 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.",
- Current = { Value = Beatmap.BeatmapInfo.SpecialStyle }
- }
- };
- }
-
- protected override void LoadComplete()
- {
- base.LoadComplete();
-
- specialStyle.Current.BindValueChanged(_ => updateBeatmap());
- }
-
- private void updateBeatmap()
- {
- Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value;
- Beatmap.SaveState();
- }
- }
-}
diff --git a/osu.Game.Rulesets.Mania/ManiaInputManager.cs b/osu.Game.Rulesets.Mania/ManiaInputManager.cs
index a41e72660b..36ccf68d76 100644
--- a/osu.Game.Rulesets.Mania/ManiaInputManager.cs
+++ b/osu.Game.Rulesets.Mania/ManiaInputManager.cs
@@ -19,16 +19,8 @@ namespace osu.Game.Rulesets.Mania
public enum ManiaAction
{
- [Description("Special 1")]
- Special1 = 1,
-
- [Description("Special 2")]
- Special2,
-
- // This offsets the start value of normal keys in-case we add more special keys
- // above at a later time, without breaking replays/configs.
[Description("Key 1")]
- Key1 = 10,
+ Key1,
[Description("Key 2")]
Key2,
diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index b5614e2b56..cdc7b0a951 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
@@ -89,79 +88,79 @@ namespace osu.Game.Rulesets.Mania
public override IEnumerable ConvertFromLegacyMods(LegacyMods mods)
{
- if (mods.HasFlagFast(LegacyMods.Nightcore))
+ if (mods.HasFlag(LegacyMods.Nightcore))
yield return new ManiaModNightcore();
- else if (mods.HasFlagFast(LegacyMods.DoubleTime))
+ else if (mods.HasFlag(LegacyMods.DoubleTime))
yield return new ManiaModDoubleTime();
- if (mods.HasFlagFast(LegacyMods.Perfect))
+ if (mods.HasFlag(LegacyMods.Perfect))
yield return new ManiaModPerfect();
- else if (mods.HasFlagFast(LegacyMods.SuddenDeath))
+ else if (mods.HasFlag(LegacyMods.SuddenDeath))
yield return new ManiaModSuddenDeath();
- if (mods.HasFlagFast(LegacyMods.Cinema))
+ if (mods.HasFlag(LegacyMods.Cinema))
yield return new ManiaModCinema();
- else if (mods.HasFlagFast(LegacyMods.Autoplay))
+ else if (mods.HasFlag(LegacyMods.Autoplay))
yield return new ManiaModAutoplay();
- if (mods.HasFlagFast(LegacyMods.Easy))
+ if (mods.HasFlag(LegacyMods.Easy))
yield return new ManiaModEasy();
- if (mods.HasFlagFast(LegacyMods.FadeIn))
+ if (mods.HasFlag(LegacyMods.FadeIn))
yield return new ManiaModFadeIn();
- if (mods.HasFlagFast(LegacyMods.Flashlight))
+ if (mods.HasFlag(LegacyMods.Flashlight))
yield return new ManiaModFlashlight();
- if (mods.HasFlagFast(LegacyMods.HalfTime))
+ if (mods.HasFlag(LegacyMods.HalfTime))
yield return new ManiaModHalfTime();
- if (mods.HasFlagFast(LegacyMods.HardRock))
+ if (mods.HasFlag(LegacyMods.HardRock))
yield return new ManiaModHardRock();
- if (mods.HasFlagFast(LegacyMods.Hidden))
+ if (mods.HasFlag(LegacyMods.Hidden))
yield return new ManiaModHidden();
- if (mods.HasFlagFast(LegacyMods.Key1))
+ if (mods.HasFlag(LegacyMods.Key1))
yield return new ManiaModKey1();
- if (mods.HasFlagFast(LegacyMods.Key2))
+ if (mods.HasFlag(LegacyMods.Key2))
yield return new ManiaModKey2();
- if (mods.HasFlagFast(LegacyMods.Key3))
+ if (mods.HasFlag(LegacyMods.Key3))
yield return new ManiaModKey3();
- if (mods.HasFlagFast(LegacyMods.Key4))
+ if (mods.HasFlag(LegacyMods.Key4))
yield return new ManiaModKey4();
- if (mods.HasFlagFast(LegacyMods.Key5))
+ if (mods.HasFlag(LegacyMods.Key5))
yield return new ManiaModKey5();
- if (mods.HasFlagFast(LegacyMods.Key6))
+ if (mods.HasFlag(LegacyMods.Key6))
yield return new ManiaModKey6();
- if (mods.HasFlagFast(LegacyMods.Key7))
+ if (mods.HasFlag(LegacyMods.Key7))
yield return new ManiaModKey7();
- if (mods.HasFlagFast(LegacyMods.Key8))
+ if (mods.HasFlag(LegacyMods.Key8))
yield return new ManiaModKey8();
- if (mods.HasFlagFast(LegacyMods.Key9))
+ if (mods.HasFlag(LegacyMods.Key9))
yield return new ManiaModKey9();
- if (mods.HasFlagFast(LegacyMods.KeyCoop))
+ if (mods.HasFlag(LegacyMods.KeyCoop))
yield return new ManiaModDualStages();
- if (mods.HasFlagFast(LegacyMods.NoFail))
+ if (mods.HasFlag(LegacyMods.NoFail))
yield return new ManiaModNoFail();
- if (mods.HasFlagFast(LegacyMods.Random))
+ if (mods.HasFlag(LegacyMods.Random))
yield return new ManiaModRandom();
- if (mods.HasFlagFast(LegacyMods.Mirror))
+ if (mods.HasFlag(LegacyMods.Mirror))
yield return new ManiaModMirror();
- if (mods.HasFlagFast(LegacyMods.ScoreV2))
+ if (mods.HasFlag(LegacyMods.ScoreV2))
yield return new ModScoreV2();
}
@@ -241,6 +240,7 @@ namespace osu.Game.Rulesets.Mania
new ManiaModEasy(),
new ManiaModNoFail(),
new MultiMod(new ManiaModHalfTime(), new ManiaModDaycore()),
+ new ManiaModNoRelease(),
};
case ModType.DifficultyIncrease:
@@ -419,9 +419,13 @@ namespace osu.Game.Rulesets.Mania
return new ManiaFilterCriteria();
}
- public override RulesetSetupSection CreateEditorSetupSection() => new ManiaSetupSection();
-
- public override DifficultySection CreateEditorDifficultySection() => new ManiaDifficultySection();
+ public override IEnumerable CreateEditorSetupSections() =>
+ [
+ new MetadataSection(),
+ new ManiaDifficultySection(),
+ new ResourcesSection(),
+ new DesignSection(),
+ ];
public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList? mods = null)
=> ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), mods);
diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs
index 046d1c5b34..f3613eff99 100644
--- a/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs
+++ b/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs
@@ -5,7 +5,7 @@ using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania
{
- public class ManiaSkinComponentLookup : GameplaySkinComponentLookup
+ public class ManiaSkinComponentLookup : SkinComponentLookup
{
///
/// Creates a new .
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
index 4e6cc4f1d6..eba0b2effe 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs
@@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public override ModType Type => ModType.Conversion;
- public override Type[] IncompatibleMods => new[] { typeof(ManiaModInvert) };
+ public override Type[] IncompatibleMods => new[] { typeof(ManiaModInvert), typeof(ManiaModNoRelease) };
public void ApplyToBeatmap(IBeatmap beatmap)
{
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs
new file mode 100644
index 0000000000..b5490aa950
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs
@@ -0,0 +1,110 @@
+// 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 System.Linq;
+using System.Threading;
+using osu.Framework.Localisation;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Mania.Objects.Drawables;
+using osu.Game.Rulesets.Mania.UI;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Rulesets.Mania.Mods
+{
+ public partial class ManiaModNoRelease : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset
+ {
+ public override string Name => "No Release";
+
+ public override string Acronym => "NR";
+
+ public override LocalisableString Description => "No more timing the end of hold notes.";
+
+ public override double ScoreMultiplier => 0.9;
+
+ public override ModType Type => ModType.DifficultyReduction;
+
+ public override Type[] IncompatibleMods => new[] { typeof(ManiaModHoldOff) };
+
+ public void ApplyToBeatmap(IBeatmap beatmap)
+ {
+ var maniaBeatmap = (ManiaBeatmap)beatmap;
+ var hitObjects = maniaBeatmap.HitObjects.Select(obj =>
+ {
+ if (obj is HoldNote hold)
+ return new NoReleaseHoldNote(hold);
+
+ return obj;
+ }).ToList();
+
+ maniaBeatmap.HitObjects = hitObjects;
+ }
+
+ public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset)
+ {
+ var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
+
+ foreach (var stage in maniaRuleset.Playfield.Stages)
+ {
+ foreach (var column in stage.Columns)
+ {
+ column.RegisterPool(10, 50);
+ }
+ }
+ }
+
+ private partial class NoReleaseDrawableHoldNoteTail : DrawableHoldNoteTail
+ {
+ protected override void CheckForResult(bool userTriggered, double timeOffset)
+ {
+ // apply perfect once the tail is reached
+ if (HoldNote.HoldStartTime != null && timeOffset >= 0)
+ ApplyResult(GetCappedResult(HitResult.Perfect));
+ else
+ base.CheckForResult(userTriggered, timeOffset);
+ }
+ }
+
+ private class NoReleaseTailNote : TailNote
+ {
+ }
+
+ private class NoReleaseHoldNote : HoldNote
+ {
+ public NoReleaseHoldNote(HoldNote hold)
+ {
+ StartTime = hold.StartTime;
+ Duration = hold.Duration;
+ Column = hold.Column;
+ NodeSamples = hold.NodeSamples;
+ }
+
+ protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
+ {
+ AddNested(Head = new HeadNote
+ {
+ StartTime = StartTime,
+ Column = Column,
+ Samples = GetNodeSamples(0),
+ });
+
+ AddNested(Tail = new NoReleaseTailNote
+ {
+ StartTime = EndTime,
+ Column = Column,
+ Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1),
+ });
+
+ AddNested(Body = new HoldNoteBody
+ {
+ StartTime = StartTime,
+ Column = Column
+ });
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
index 2b55e81788..9c56f0473c 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
@@ -268,11 +268,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
ApplyMaxResult();
else
MissForcefully();
- }
- // Make sure that the hold note is fully judged by giving the body a judgement.
- if (Tail.AllJudged && !Body.AllJudged)
- Body.TriggerResult(Tail.IsHit);
+ // Make sure that the hold note is fully judged by giving the body a judgement.
+ if (!Body.AllJudged)
+ Body.TriggerResult(Tail.IsHit);
+
+ // Important that this is always called when a result is applied.
+ endHold();
+ }
}
public override void MissForcefully()
diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs
index 3f930a310b..98060dd226 100644
--- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs
@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Threading;
using osu.Game.Audio;
using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
@@ -72,18 +73,18 @@ namespace osu.Game.Rulesets.Mania.Objects
///
/// The head note of the hold.
///
- public HeadNote Head { get; private set; }
+ public HeadNote Head { get; protected set; }
///
/// The tail note of the hold.
///
- public TailNote Tail { get; private set; }
+ public TailNote Tail { get; protected set; }
///
/// The body of the hold.
/// This is an invisible and silent object that tracks the holding state of the .
///
- public HoldNoteBody Body { get; private set; }
+ public HoldNoteBody Body { get; protected set; }
public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset;
@@ -91,6 +92,10 @@ namespace osu.Game.Rulesets.Mania.Objects
{
base.CreateNestedHitObjects(cancellationToken);
+ // Generally node samples will be populated by ManiaBeatmapConverter, but in a case like the editor they may not be.
+ // Ensure they are set to a sane default here.
+ NodeSamples ??= CreateDefaultNodeSamples(this);
+
AddNested(Head = new HeadNote
{
StartTime = StartTime,
@@ -102,7 +107,7 @@ namespace osu.Game.Rulesets.Mania.Objects
{
StartTime = EndTime,
Column = Column,
- Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1),
+ Samples = GetNodeSamples(NodeSamples.Count - 1),
});
AddNested(Body = new HoldNoteBody
@@ -116,7 +121,20 @@ namespace osu.Game.Rulesets.Mania.Objects
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
- public IList GetNodeSamples(int nodeIndex) =>
- nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples;
+ public IList GetNodeSamples(int nodeIndex) => nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples;
+
+ ///
+ /// Create the default note samples for a hold note, based off their main sample.
+ ///
+ ///
+ /// By default, osu!mania beatmaps in only play samples at the start of the hold note.
+ ///
+ /// The object to use as a basis for the head sample.
+ /// Defaults for assigning to .
+ public static List> CreateDefaultNodeSamples(HitObject obj) => new List>
+ {
+ obj.Samples,
+ new List(),
+ };
}
}
diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs
index dd3208bd89..5d4cebca30 100644
--- a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs
+++ b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs
@@ -17,28 +17,9 @@ namespace osu.Game.Rulesets.Mania.Replays
public new ManiaBeatmap Beatmap => (ManiaBeatmap)base.Beatmap;
- private readonly ManiaAction[] columnActions;
-
public ManiaAutoGenerator(ManiaBeatmap beatmap)
: base(beatmap)
{
- columnActions = new ManiaAction[Beatmap.TotalColumns];
-
- var normalAction = ManiaAction.Key1;
- var specialAction = ManiaAction.Special1;
- int totalCounter = 0;
-
- foreach (var stage in Beatmap.Stages)
- {
- for (int i = 0; i < stage.Columns; i++)
- {
- if (stage.IsSpecialColumn(i))
- columnActions[totalCounter] = specialAction++;
- else
- columnActions[totalCounter] = normalAction++;
- totalCounter++;
- }
- }
}
protected override void GenerateFrames()
@@ -57,11 +38,11 @@ namespace osu.Game.Rulesets.Mania.Replays
switch (point)
{
case HitPoint:
- actions.Add(columnActions[point.Column]);
+ actions.Add(ManiaAction.Key1 + point.Column);
break;
case ReleasePoint:
- actions.Remove(columnActions[point.Column]);
+ actions.Remove(ManiaAction.Key1 + point.Column);
break;
}
}
diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs
index 29249ba474..f80c442025 100644
--- a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs
+++ b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs
@@ -1,11 +1,9 @@
// 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 System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Replays.Legacy;
-using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
@@ -27,118 +25,27 @@ namespace osu.Game.Rulesets.Mania.Replays
public void FromLegacy(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null)
{
- var maniaBeatmap = (ManiaBeatmap)beatmap;
-
- var normalAction = ManiaAction.Key1;
- var specialAction = ManiaAction.Special1;
-
+ var action = ManiaAction.Key1;
int activeColumns = (int)(legacyFrame.MouseX ?? 0);
- int counter = 0;
while (activeColumns > 0)
{
- bool isSpecial = isColumnAtIndexSpecial(maniaBeatmap, counter);
-
if ((activeColumns & 1) > 0)
- Actions.Add(isSpecial ? specialAction : normalAction);
+ Actions.Add(action);
- if (isSpecial)
- specialAction++;
- else
- normalAction++;
-
- counter++;
+ action++;
activeColumns >>= 1;
}
}
public LegacyReplayFrame ToLegacy(IBeatmap beatmap)
{
- var maniaBeatmap = (ManiaBeatmap)beatmap;
-
int keys = 0;
foreach (var action in Actions)
- {
- switch (action)
- {
- case ManiaAction.Special1:
- keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 0);
- break;
-
- case ManiaAction.Special2:
- keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 1);
- break;
-
- default:
- // the index in lazer, which doesn't include special keys.
- int nonSpecialKeyIndex = action - ManiaAction.Key1;
-
- // the index inclusive of special keys.
- int overallIndex = 0;
-
- // iterate to find the index including special keys.
- for (; overallIndex < maniaBeatmap.TotalColumns; overallIndex++)
- {
- // skip over special columns.
- if (isColumnAtIndexSpecial(maniaBeatmap, overallIndex))
- continue;
- // found a non-special column to use.
- if (nonSpecialKeyIndex == 0)
- break;
- // found a non-special column but not ours.
- nonSpecialKeyIndex--;
- }
-
- keys |= 1 << overallIndex;
- break;
- }
- }
+ keys |= 1 << (int)action;
return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None);
}
-
- ///
- /// Find the overall index (across all stages) for a specified special key.
- ///
- /// The beatmap.
- /// The special key offset (0 is S1).
- /// The overall index for the special column.
- private int getSpecialColumnIndex(ManiaBeatmap maniaBeatmap, int specialOffset)
- {
- for (int i = 0; i < maniaBeatmap.TotalColumns; i++)
- {
- if (isColumnAtIndexSpecial(maniaBeatmap, i))
- {
- if (specialOffset == 0)
- return i;
-
- specialOffset--;
- }
- }
-
- throw new ArgumentException("Special key index is too high.", nameof(specialOffset));
- }
-
- ///
- /// Check whether the column at an overall index (across all stages) is a special column.
- ///
- /// The beatmap.
- /// The overall index to check.
- private bool isColumnAtIndexSpecial(ManiaBeatmap beatmap, int index)
- {
- foreach (var stage in beatmap.Stages)
- {
- if (index >= stage.Columns)
- {
- index -= stage.Columns;
- continue;
- }
-
- return stage.IsSpecialColumn(index);
- }
-
- throw new ArgumentException("Column index is too high.", nameof(index));
- }
}
}
diff --git a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs
index 44ffeb5ec2..c642da6dc4 100644
--- a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs
+++ b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs
@@ -34,8 +34,6 @@ namespace osu.Game.Rulesets.Mania
LeftKeys = leftKeys,
RightKeys = rightKeys,
SpecialKey = InputKey.Space,
- SpecialAction = ManiaAction.Special1,
- NormalActionStart = ManiaAction.Key1,
- }.GenerateKeyBindingsFor(variant, out _);
+ }.GenerateKeyBindingsFor(variant);
}
}
diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonManiaComboCounter.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonManiaComboCounter.cs
new file mode 100644
index 0000000000..6626e5f1c7
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonManiaComboCounter.cs
@@ -0,0 +1,52 @@
+// 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.Bindables;
+using osu.Framework.Graphics;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Screens.Play.HUD;
+
+namespace osu.Game.Rulesets.Mania.Skinning.Argon
+{
+ public partial class ArgonManiaComboCounter : ArgonComboCounter
+ {
+ protected override bool DisplayXSymbol => false;
+
+ [Resolved]
+ private IScrollingInfo scrollingInfo { get; set; } = null!;
+
+ private IBindable direction = null!;
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ // the logic of flipping the position of the combo counter w.r.t. the direction does not work with "Closest" anchor,
+ // because it always forces the anchor to be top or bottom based on scrolling direction.
+ UsesFixedAnchor = true;
+
+ direction = scrollingInfo.Direction.GetBoundCopy();
+ direction.BindValueChanged(_ => updateAnchor());
+
+ // two schedules are required so that updateAnchor is executed in the next frame,
+ // which is when the combo counter receives its Y position by the default layout in ArgonManiaSkinTransformer.
+ Schedule(() => Schedule(updateAnchor));
+ }
+
+ private void updateAnchor()
+ {
+ // if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction
+ if (Anchor.HasFlag(Anchor.y1))
+ return;
+
+ Anchor &= ~(Anchor.y0 | Anchor.y2);
+ Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0;
+
+ // change the sign of the Y coordinate in line with the scrolling direction.
+ // i.e. if the user changes direction from down to up, the anchor is changed from top to bottom, and the Y is flipped from positive to negative here.
+ Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs
index 7f6540e7b5..afccb2e568 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs
@@ -2,8 +2,10 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
+using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Scoring;
@@ -26,7 +28,34 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
switch (lookup)
{
- case GameplaySkinComponentLookup resultComponent:
+ case GlobalSkinnableContainerLookup containerLookup:
+ // Only handle per ruleset defaults here.
+ if (containerLookup.Ruleset == null)
+ return base.GetDrawableComponent(lookup);
+
+ switch (containerLookup.Lookup)
+ {
+ case GlobalSkinnableContainers.MainHUDComponents:
+ return new DefaultSkinComponentsContainer(container =>
+ {
+ var combo = container.ChildrenOfType().FirstOrDefault();
+
+ if (combo != null)
+ {
+ combo.ShowLabel.Value = false;
+ combo.Anchor = Anchor.TopCentre;
+ combo.Origin = Anchor.Centre;
+ combo.Y = 200;
+ }
+ })
+ {
+ new ArgonManiaComboCounter(),
+ };
+ }
+
+ return null;
+
+ case SkinComponentLookup resultComponent:
// This should eventually be moved to a skin setting, when supported.
if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great)
return Drawable.Empty();
diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
index a8200e0144..6de0752671 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs
@@ -65,11 +65,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
if (tmp is IFramedAnimation tmpAnimation && tmpAnimation.FrameCount > 0)
frameLength = Math.Max(1000 / 60.0, 170.0 / tmpAnimation.FrameCount);
- light = skin.GetAnimation(lightImage, true, true, frameLength: frameLength).With(d =>
+ light = skin.GetAnimation(lightImage, true, true, frameLength: frameLength)?.With(d =>
{
- if (d == null)
- return;
-
d.Origin = Anchor.Centre;
d.Blending = BlendingParameters.Additive;
d.Scale = new Vector2(lightScale);
@@ -91,11 +88,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
direction.BindTo(scrollingInfo.Direction);
isHitting.BindTo(holdNote.IsHitting);
- bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30).With(d =>
+ bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30)?.With(d =>
{
- if (d == null)
- return;
-
if (d is TextureAnimation animation)
animation.IsPlaying = false;
@@ -140,10 +134,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private void onIsHittingChanged(ValueChangedEvent isHitting)
{
if (bodySprite is TextureAnimation bodyAnimation)
- {
- bodyAnimation.GotoFrame(0);
bodyAnimation.IsPlaying = isHitting.NewValue;
- }
if (lightContainer == null)
return;
@@ -219,6 +210,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
base.Update();
+ if (!isHitting.Value)
+ (bodySprite as TextureAnimation)?.GotoFrame(0);
+
if (holdNote.Body.HasHoldBreak)
missFadeTime.Value = holdNote.Body.Result.TimeAbsolute;
@@ -245,7 +239,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
// i dunno this looks about right??
// the guard against zero draw height is intended for zero-length hold notes. yes, such cases have been spotted in the wild.
if (sprite.DrawHeight > 0)
- bodySprite.Scale = new Vector2(1, scaleDirection * 32800 / sprite.DrawHeight);
+ bodySprite.Scale = new Vector2(1, scaleDirection * MathF.Max(1, 32800 / sprite.DrawHeight));
}
break;
diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs
index 1ec218644c..95b00e32ea 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs
@@ -43,11 +43,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
if (tmp is IFramedAnimation tmpAnimation && tmpAnimation.FrameCount > 0)
frameLength = Math.Max(1000 / 60.0, 170.0 / tmpAnimation.FrameCount);
- explosion = skin.GetAnimation(imageName, true, false, frameLength: frameLength).With(d =>
+ explosion = skin.GetAnimation(imageName, true, false, frameLength: frameLength)?.With(d =>
{
- if (d == null)
- return;
-
d.Origin = Anchor.Centre;
d.Blending = BlendingParameters.Additive;
d.Scale = new Vector2(explosionScale);
diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaComboCounter.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaComboCounter.cs
new file mode 100644
index 0000000000..889e6326f7
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaComboCounter.cs
@@ -0,0 +1,194 @@
+// 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 System.Globalization;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Skinning;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Rulesets.Mania.Skinning.Legacy
+{
+ public partial class LegacyManiaComboCounter : CompositeDrawable, ISerialisableDrawable
+ {
+ public bool UsesFixedAnchor { get; set; }
+
+ public Bindable Current { get; } = new BindableInt { MinValue = 0 };
+
+ ///
+ /// Value shown at the current moment.
+ ///
+ public virtual int DisplayedCount
+ {
+ get => displayedCount;
+ private set
+ {
+ if (displayedCount.Equals(value))
+ return;
+
+ displayedCountText.FadeTo(value == 0 ? 0 : 1);
+ displayedCountText.Text = value.ToString(CultureInfo.InvariantCulture);
+ counterContainer.Size = displayedCountText.Size;
+
+ displayedCount = value;
+ }
+ }
+
+ private int displayedCount;
+
+ private int previousValue;
+
+ private const double fade_out_duration = 100;
+ private const double rolling_duration = 20;
+
+ private Container counterContainer = null!;
+ private LegacySpriteText popOutCountText = null!;
+ private LegacySpriteText displayedCountText = null!;
+
+ [BackgroundDependencyLoader]
+ private void load(ISkinSource skin, ScoreProcessor scoreProcessor)
+ {
+ AutoSizeAxes = Axes.Both;
+
+ InternalChildren = new[]
+ {
+ counterContainer = new Container
+ {
+ AlwaysPresent = true,
+ Children = new[]
+ {
+ popOutCountText = new LegacySpriteText(LegacyFont.Combo)
+ {
+ Alpha = 0,
+ Blending = BlendingParameters.Additive,
+ BypassAutoSizeAxes = Axes.Both,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Colour = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ComboBreakColour)?.Value ?? Color4.Red,
+ },
+ displayedCountText = new LegacySpriteText(LegacyFont.Combo)
+ {
+ Alpha = 0,
+ AlwaysPresent = true,
+ BypassAutoSizeAxes = Axes.Both,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ },
+ }
+ }
+ };
+
+ Current.BindTo(scoreProcessor.Combo);
+ }
+
+ [Resolved]
+ private IScrollingInfo scrollingInfo { get; set; } = null!;
+
+ private IBindable direction = null!;
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ displayedCountText.Text = popOutCountText.Text = Current.Value.ToString(CultureInfo.InvariantCulture);
+
+ Current.BindValueChanged(combo => updateCount(combo.NewValue == 0), true);
+
+ counterContainer.Size = displayedCountText.Size;
+
+ direction = scrollingInfo.Direction.GetBoundCopy();
+ direction.BindValueChanged(_ => updateAnchor());
+
+ // two schedules are required so that updateAnchor is executed in the next frame,
+ // which is when the combo counter receives its Y position by the default layout in LegacyManiaSkinTransformer.
+ Schedule(() => Schedule(updateAnchor));
+ }
+
+ private void updateAnchor()
+ {
+ // if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction
+ if (Anchor.HasFlag(Anchor.y1))
+ return;
+
+ Anchor &= ~(Anchor.y0 | Anchor.y2);
+ Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0;
+
+ // change the sign of the Y coordinate in line with the scrolling direction.
+ // i.e. if the user changes direction from down to up, the anchor is changed from top to bottom, and the Y is flipped from positive to negative here.
+ Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1);
+ }
+
+ private void updateCount(bool rolling)
+ {
+ int prev = previousValue;
+ previousValue = Current.Value;
+
+ if (!IsLoaded)
+ return;
+
+ if (!rolling)
+ {
+ FinishTransforms(false, nameof(DisplayedCount));
+
+ if (prev + 1 == Current.Value)
+ onCountIncrement();
+ else
+ onCountChange();
+ }
+ else
+ onCountRolling();
+ }
+
+ private void onCountIncrement()
+ {
+ popOutCountText.Hide();
+
+ DisplayedCount = Current.Value;
+ displayedCountText.ScaleTo(new Vector2(1f, 1.4f))
+ .ScaleTo(new Vector2(1f), 300, Easing.Out)
+ .FadeIn(120);
+ }
+
+ private void onCountChange()
+ {
+ popOutCountText.Hide();
+
+ if (Current.Value == 0)
+ displayedCountText.FadeOut();
+
+ DisplayedCount = Current.Value;
+
+ displayedCountText.ScaleTo(1f);
+ }
+
+ private void onCountRolling()
+ {
+ if (DisplayedCount > 0)
+ {
+ popOutCountText.Text = DisplayedCount.ToString(CultureInfo.InvariantCulture);
+ popOutCountText.FadeTo(0.8f).FadeOut(200)
+ .ScaleTo(1f).ScaleTo(4f, 200);
+
+ displayedCountText.FadeTo(0.5f, 300);
+ }
+
+ // Hides displayed count if was increasing from 0 to 1 but didn't finish
+ if (DisplayedCount == 0 && Current.Value == 0)
+ displayedCountText.FadeOut(fade_out_duration);
+
+ this.TransformTo(nameof(DisplayedCount), Current.Value, getProportionalDuration(DisplayedCount, Current.Value));
+ }
+
+ private double getProportionalDuration(int currentValue, int newValue)
+ {
+ double difference = currentValue > newValue ? currentValue - newValue : newValue - currentValue;
+ return difference * rolling_duration;
+ }
+ }
+}
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/Skinning/Legacy/LegacyStageForeground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs
index 1a47fe5076..680198c1a6 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs
@@ -28,13 +28,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
string bottomImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.BottomStageImage)?.Value
?? "mania-stage-bottom";
- sprite = skin.GetAnimation(bottomImage, true, true)?.With(d =>
- {
- if (d == null)
- return;
-
- d.Scale = new Vector2(1.6f);
- });
+ sprite = skin.GetAnimation(bottomImage, true, true)?.With(d => d.Scale = new Vector2(1.6f));
if (sprite != null)
InternalChild = sprite;
diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs
index 73c521b2ed..cb42b2b62a 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs
@@ -5,9 +5,11 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
+using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
@@ -78,7 +80,37 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
switch (lookup)
{
- case GameplaySkinComponentLookup resultComponent:
+ case GlobalSkinnableContainerLookup containerLookup:
+ // Modifications for global components.
+ if (containerLookup.Ruleset == null)
+ return base.GetDrawableComponent(lookup);
+
+ // we don't have enough assets to display these components (this is especially the case on a "beatmap" skin).
+ if (!IsProvidingLegacyResources)
+ return null;
+
+ switch (containerLookup.Lookup)
+ {
+ case GlobalSkinnableContainers.MainHUDComponents:
+ return new DefaultSkinComponentsContainer(container =>
+ {
+ var combo = container.ChildrenOfType().FirstOrDefault();
+
+ if (combo != null)
+ {
+ combo.Anchor = Anchor.TopCentre;
+ combo.Origin = Anchor.Centre;
+ combo.Y = this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ComboPosition)?.Value ?? 0;
+ }
+ })
+ {
+ new LegacyManiaComboCounter(),
+ };
+ }
+
+ return null;
+
+ case SkinComponentLookup resultComponent:
return getResult(resultComponent.Component);
case ManiaSkinComponentLookup maniaComponent:
diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs
index 6cd55bb099..c05a8f2a29 100644
--- a/osu.Game.Rulesets.Mania/UI/Column.cs
+++ b/osu.Game.Rulesets.Mania/UI/Column.cs
@@ -93,8 +93,7 @@ namespace osu.Game.Rulesets.Mania.UI
// For input purposes, the background is added at the highest depth, but is then proxied back below all other elements externally
// (see `Stage.columnBackgrounds`).
BackgroundContainer,
- TopLevelContainer,
- new ColumnTouchInputArea(this)
+ TopLevelContainer
};
var background = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground())
@@ -181,38 +180,5 @@ namespace osu.Game.Rulesets.Mania.UI
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
// This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border
=> DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos));
-
- public partial class ColumnTouchInputArea : Drawable
- {
- private readonly Column column;
-
- [Resolved(canBeNull: true)]
- private ManiaInputManager maniaInputManager { get; set; }
-
- private KeyBindingContainer keyBindingContainer;
-
- public ColumnTouchInputArea(Column column)
- {
- RelativeSizeAxes = Axes.Both;
-
- this.column = column;
- }
-
- protected override void LoadComplete()
- {
- keyBindingContainer = maniaInputManager?.KeyBindingContainer;
- }
-
- protected override bool OnTouchDown(TouchDownEvent e)
- {
- keyBindingContainer?.TriggerPressed(column.Action.Value);
- return true;
- }
-
- protected override void OnTouchUp(TouchUpEvent e)
- {
- keyBindingContainer?.TriggerReleased(column.Action.Value);
- }
- }
}
}
diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
index 1593e8e76f..5614a13a48 100644
--- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
+++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
@@ -3,15 +3,12 @@
#nullable disable
-using System;
-using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Skinning;
using osu.Game.Skinning;
-using osuTK;
namespace osu.Game.Rulesets.Mania.UI
{
@@ -31,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;
@@ -62,12 +65,6 @@ namespace osu.Game.Rulesets.Mania.UI
onSkinChanged();
}
- protected override void LoadComplete()
- {
- base.LoadComplete();
- updateMobileSizing();
- }
-
private void onSkinChanged()
{
for (int i = 0; i < stageDefinition.Columns; i++)
@@ -92,8 +89,6 @@ namespace osu.Game.Rulesets.Mania.UI
columns[i].Width = width.Value;
}
-
- updateMobileSizing();
}
///
@@ -106,31 +101,6 @@ namespace osu.Game.Rulesets.Mania.UI
Content[column] = columns[column].Child = content;
}
- private void updateMobileSizing()
- {
- if (!IsLoaded || !RuntimeInfo.IsMobile)
- return;
-
- // GridContainer+CellContainer containing this stage (gets split up for dual stages).
- Vector2? containingCell = this.FindClosestParent()?.Parent?.DrawSize;
-
- // Will be null in tests.
- if (containingCell == null)
- return;
-
- float aspectRatio = containingCell.Value.X / containingCell.Value.Y;
-
- // 2.83 is a mostly arbitrary scale-up (170 / 60, based on original implementation for argon)
- float mobileAdjust = 2.83f * Math.Min(1, 7f / stageDefinition.Columns);
- // 1.92 is a "reference" mobile screen aspect ratio for phones.
- // We should scale it back for cases like tablets which aren't so extreme.
- mobileAdjust *= aspectRatio / 1.92f;
-
- // Best effort until we have better mobile support.
- for (int i = 0; i < stageDefinition.Columns; i++)
- columns[i].Width *= mobileAdjust;
- }
-
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs
index 896dfb2b23..9f25a44e21 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs
@@ -5,22 +5,12 @@
using osu.Framework.Graphics;
using osu.Game.Rulesets.Judgements;
-using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.UI
{
public partial class DrawableManiaJudgement : DrawableJudgement
{
- public DrawableManiaJudgement(JudgementResult result, DrawableHitObject judgedObject)
- : base(result, judgedObject)
- {
- }
-
- public DrawableManiaJudgement()
- {
- }
-
protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result);
private partial class DefaultManiaJudgementPiece : DefaultJudgementPiece
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
index 275b1311de..aed53e157a 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
@@ -8,9 +8,10 @@ using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Extensions.ObjectExtensions;
-using osu.Framework.Graphics;
using osu.Framework.Input;
+using osu.Framework.Platform;
using osu.Framework.Threading;
+using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Input.Handlers;
@@ -31,6 +32,7 @@ using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.UI
{
+ [Cached]
public partial class DrawableManiaRuleset : DrawableScrollingRuleset
{
///
@@ -43,7 +45,7 @@ namespace osu.Game.Rulesets.Mania.UI
///
public const double MAX_TIME_RANGE = 11485;
- protected new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield;
+ public new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield;
public new ManiaBeatmap Beatmap => (ManiaBeatmap)base.Beatmap;
@@ -55,13 +57,18 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly Bindable configDirection = new Bindable();
private readonly BindableInt configScrollSpeed = new BindableInt();
- private double smoothTimeRange;
+
+ private double currentTimeRange;
+ protected double TargetTimeRange;
// Stores the current speed adjustment active in gameplay.
private readonly Track speedAdjustmentTrack = new TrackVirtual(0);
private ISkinSource currentSkin = null!;
+ [Resolved]
+ private GameHost gameHost { get; set; } = null!;
+
public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null)
: base(ruleset, beatmap, mods)
{
@@ -100,9 +107,11 @@ namespace osu.Game.Rulesets.Mania.UI
configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true);
Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed);
- configScrollSpeed.BindValueChanged(speed => this.TransformTo(nameof(smoothTimeRange), ComputeScrollTime(speed.NewValue), 200, Easing.OutQuint));
+ configScrollSpeed.BindValueChanged(speed => TargetTimeRange = ComputeScrollTime(speed.NewValue));
- TimeRange.Value = smoothTimeRange = ComputeScrollTime(configScrollSpeed.Value);
+ TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value);
+
+ KeyBindingInputManager.Add(new ManiaTouchInputArea());
}
protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount;
@@ -141,7 +150,9 @@ namespace osu.Game.Rulesets.Mania.UI
// This scaling factor preserves the scroll speed as the scroll length varies from changes to the hit position.
float scale = lengthToHitPosition / length_to_default_hit_position;
- TimeRange.Value = smoothTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale;
+ // we're intentionally using the game host's update clock here to decouple the time range tween from the gameplay clock (which can be arbitrarily paused, or even rewinding)
+ currentTimeRange = Interpolation.DampContinuously(currentTimeRange, TargetTimeRange, 50, gameHost.UpdateThread.Clock.ElapsedFrameTime);
+ TimeRange.Value = currentTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale;
}
///
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
index b3420c49f3..1f388144bd 100644
--- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
+++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
@@ -66,13 +66,12 @@ namespace osu.Game.Rulesets.Mania.UI
Content = new[] { new Drawable[stageDefinitions.Count] }
});
- var normalColumnAction = ManiaAction.Key1;
- var specialColumnAction = ManiaAction.Special1;
+ var columnAction = ManiaAction.Key1;
int firstColumnIndex = 0;
for (int i = 0; i < stageDefinitions.Count; i++)
{
- var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref normalColumnAction, ref specialColumnAction);
+ var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref columnAction);
playfieldGrid.Content[0][i] = newStage;
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs
new file mode 100644
index 0000000000..8c4a71cf24
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs
@@ -0,0 +1,199 @@
+// 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 osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
+using osu.Game.Configuration;
+using osuTK;
+
+namespace osu.Game.Rulesets.Mania.UI
+{
+ ///
+ /// An overlay that captures and displays osu!mania mouse and touch input.
+ ///
+ public partial class ManiaTouchInputArea : VisibilityContainer
+ {
+ // visibility state affects our child. we always want to handle input.
+ public override bool PropagatePositionalInputSubTree => true;
+ public override bool PropagateNonPositionalInputSubTree => true;
+
+ [SettingSource("Spacing", "The spacing between receptors.")]
+ public BindableFloat Spacing { get; } = new BindableFloat(10)
+ {
+ Precision = 1,
+ MinValue = 0,
+ MaxValue = 100,
+ };
+
+ [SettingSource("Opacity", "The receptor opacity.")]
+ public BindableFloat Opacity { get; } = new BindableFloat(1)
+ {
+ Precision = 0.1f,
+ MinValue = 0,
+ MaxValue = 1
+ };
+
+ [Resolved]
+ private DrawableManiaRuleset drawableRuleset { get; set; } = null!;
+
+ private GridContainer gridContainer = null!;
+
+ public ManiaTouchInputArea()
+ {
+ Anchor = Anchor.BottomCentre;
+ Origin = Anchor.BottomCentre;
+
+ RelativeSizeAxes = Axes.Both;
+ Height = 0.5f;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ List receptorGridContent = new List();
+ List receptorGridDimensions = new List();
+
+ bool first = true;
+
+ foreach (var stage in drawableRuleset.Playfield.Stages)
+ {
+ foreach (var column in stage.Columns)
+ {
+ if (!first)
+ {
+ receptorGridContent.Add(new Gutter { Spacing = { BindTarget = Spacing } });
+ receptorGridDimensions.Add(new Dimension(GridSizeMode.AutoSize));
+ }
+
+ receptorGridContent.Add(new ColumnInputReceptor { Action = { BindTarget = column.Action } });
+ receptorGridDimensions.Add(new Dimension());
+
+ first = false;
+ }
+ }
+
+ InternalChild = gridContainer = new GridContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ AlwaysPresent = true,
+ Content = new[] { receptorGridContent.ToArray() },
+ ColumnDimensions = receptorGridDimensions.ToArray()
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ Opacity.BindValueChanged(o => Alpha = o.NewValue, true);
+ }
+
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ // Hide whenever the keyboard is used.
+ Hide();
+ return false;
+ }
+
+ protected override bool OnTouchDown(TouchDownEvent e)
+ {
+ Show();
+ return true;
+ }
+
+ protected override void PopIn()
+ {
+ gridContainer.FadeIn(500, Easing.OutQuint);
+ }
+
+ protected override void PopOut()
+ {
+ gridContainer.FadeOut(300);
+ }
+
+ public partial class ColumnInputReceptor : CompositeDrawable
+ {
+ public readonly IBindable Action = new Bindable();
+
+ private readonly Box highlightOverlay;
+
+ [Resolved]
+ private ManiaInputManager? inputManager { get; set; }
+
+ private bool isPressed;
+
+ public ColumnInputReceptor()
+ {
+ RelativeSizeAxes = Axes.Both;
+
+ InternalChildren = new Drawable[]
+ {
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Masking = true,
+ CornerRadius = 10,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Alpha = 0.15f,
+ },
+ highlightOverlay = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Alpha = 0,
+ Blending = BlendingParameters.Additive,
+ }
+ }
+ }
+ };
+ }
+
+ protected override bool OnTouchDown(TouchDownEvent e)
+ {
+ updateButton(true);
+ return false; // handled by parent container to show overlay.
+ }
+
+ protected override void OnTouchUp(TouchUpEvent e)
+ {
+ updateButton(false);
+ }
+
+ private void updateButton(bool press)
+ {
+ if (press == isPressed)
+ return;
+
+ isPressed = press;
+
+ if (press)
+ {
+ inputManager?.KeyBindingContainer.TriggerPressed(Action.Value);
+ highlightOverlay.FadeTo(0.1f, 80, Easing.OutQuint);
+ }
+ else
+ {
+ inputManager?.KeyBindingContainer.TriggerReleased(Action.Value);
+ highlightOverlay.FadeTo(0, 400, Easing.OutQuint);
+ }
+ }
+ }
+
+ private partial class Gutter : Drawable
+ {
+ public readonly IBindable Spacing = new Bindable();
+
+ public Gutter()
+ {
+ Spacing.BindValueChanged(s => Size = new Vector2(s.NewValue));
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs
index a4a09c9a82..86f2243561 100644
--- a/osu.Game.Rulesets.Mania/UI/Stage.cs
+++ b/osu.Game.Rulesets.Mania/UI/Stage.cs
@@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Mania.UI
private ISkinSource currentSkin = null!;
- public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction)
+ public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction columnStartAction)
{
this.firstColumnIndex = firstColumnIndex;
Definition = definition;
@@ -138,7 +138,7 @@ namespace osu.Game.Rulesets.Mania.UI
{
RelativeSizeAxes = Axes.Both,
Width = 1,
- Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ }
+ Action = { Value = columnStartAction++ }
};
topLevelContainer.Add(column.TopLevelContainer.CreateProxy());
diff --git a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs
index 2742ee087b..2195c9e1b9 100644
--- a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs
+++ b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs
@@ -26,37 +26,30 @@ namespace osu.Game.Rulesets.Mania
public InputKey SpecialKey;
///
- /// The at which the normal columns should begin.
+ /// The at which the columns should begin.
///
- public ManiaAction NormalActionStart;
-
- ///
- /// The for the special column.
- ///
- public ManiaAction SpecialAction;
+ public ManiaAction ActionStart;
///
/// Generates a list of s for a specific number of columns.
///
/// The number of columns that need to be bound.
- /// The next to use for normal columns.
/// The keybindings.
- public IEnumerable GenerateKeyBindingsFor(int columns, out ManiaAction nextNormalAction)
+ public IEnumerable GenerateKeyBindingsFor(int columns)
{
- ManiaAction currentNormalAction = NormalActionStart;
+ ManiaAction currentAction = ActionStart;
var bindings = new List();
for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++)
- bindings.Add(new KeyBinding(LeftKeys[i], currentNormalAction++));
+ bindings.Add(new KeyBinding(LeftKeys[i], currentAction++));
if (columns % 2 == 1)
- bindings.Add(new KeyBinding(SpecialKey, SpecialAction));
+ bindings.Add(new KeyBinding(SpecialKey, currentAction++));
for (int i = 0; i < columns / 2; i++)
- bindings.Add(new KeyBinding(RightKeys[i], currentNormalAction++));
+ bindings.Add(new KeyBinding(RightKeys[i], currentAction++));
- nextNormalAction = currentNormalAction;
return bindings;
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs
index a49afd82f3..a105d860bf 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs
@@ -14,6 +14,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
public partial class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHitCircle((HitCircle)hitObject);
- protected override PlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint();
+ protected override HitObjectPlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
index dfe950c01e..fd711e543c 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
@@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
moveMouseToHitObject(1);
- AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
+ AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection"));
mergeSelection();
@@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
moveMouseToHitObject(1);
- AddAssert("merge option not available", () => selectionHandler.ContextMenuItems?.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection"));
+ AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection"));
mergeSelection();
AddAssert("circles not merged", () => circle1 is not null && circle2 is not null
&& EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2));
@@ -222,7 +222,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
moveMouseToHitObject(1);
- AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
+ AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection"));
mergeSelection();
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs
index d14e593587..fb109ba6f9 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs
@@ -1,13 +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 System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
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;
using osuTK;
using osuTK.Input;
@@ -20,26 +25,26 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestGridToggles()
{
- AddStep("enable distance snap grid", () => InputManager.Key(Key.T));
+ AddStep("enable distance snap grid", () => InputManager.Key(Key.Y));
AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any());
- rectangularGridActive(false);
+ gridActive(false);
- AddStep("enable rectangular grid", () => InputManager.Key(Key.Y));
+ AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
AddUntilStep("distance snap grid still visible", () => this.ChildrenOfType().Any());
- rectangularGridActive(true);
+ gridActive(true);
- AddStep("disable distance snap grid", () => InputManager.Key(Key.T));
+ AddStep("disable distance snap grid", () => InputManager.Key(Key.Y));
AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any());
AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
- rectangularGridActive(true);
+ gridActive(true);
- AddStep("disable rectangular grid", () => InputManager.Key(Key.Y));
+ AddStep("disable rectangular grid", () => InputManager.Key(Key.T));
AddUntilStep("distance snap grid still hidden", () => !this.ChildrenOfType().Any());
- rectangularGridActive(false);
+ gridActive(false);
}
[Test]
@@ -52,38 +57,124 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any());
AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft));
AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any());
+
+ AddStep("enable distance snap grid", () => InputManager.Key(Key.Y));
+ AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any());
+ AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft));
+ AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any());
+ AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft));
+ AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any());
+ }
+
+ [Test]
+ public void TestDistanceSnapAdjustDoesNotHideTheGridIfStartingEnabled()
+ {
+ double distanceSnap = double.PositiveInfinity;
+
+ AddStep("enable distance snap grid", () => InputManager.Key(Key.Y));
+
+ AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
+ AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any());
+ AddStep("store distance snap", () => distanceSnap = this.ChildrenOfType().First().DistanceSpacingMultiplier.Value);
+
+ AddStep("increase distance", () =>
+ {
+ InputManager.PressKey(Key.AltLeft);
+ InputManager.PressKey(Key.ControlLeft);
+ InputManager.ScrollVerticalBy(1);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ InputManager.ReleaseKey(Key.AltLeft);
+ });
+
+ AddUntilStep("distance snap increased", () => this.ChildrenOfType().First().DistanceSpacingMultiplier.Value, () => Is.GreaterThan(distanceSnap));
+ AddUntilStep("distance snap grid still visible", () => this.ChildrenOfType().Any());
+ }
+
+ [Test]
+ public void TestDistanceSnapAdjustShowsGridMomentarilyIfStartingDisabled()
+ {
+ double distanceSnap = double.PositiveInfinity;
+
+ AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
+ AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any());
+ AddStep("store distance snap", () => distanceSnap = this.ChildrenOfType().First().DistanceSpacingMultiplier.Value);
+
+ AddStep("start increasing distance", () =>
+ {
+ InputManager.PressKey(Key.AltLeft);
+ InputManager.PressKey(Key.ControlLeft);
+ });
+
+ AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any());
+
+ AddStep("finish increasing distance", () =>
+ {
+ InputManager.ScrollVerticalBy(1);
+ InputManager.ReleaseKey(Key.ControlLeft);
+ InputManager.ReleaseKey(Key.AltLeft);
+ });
+
+ AddUntilStep("distance snap increased", () => this.ChildrenOfType().First().DistanceSpacingMultiplier.Value, () => Is.GreaterThan(distanceSnap));
+ AddUntilStep("distance snap hidden in the end", () => !this.ChildrenOfType().Any());
}
[Test]
public void TestGridSnapMomentaryToggle()
{
- rectangularGridActive(false);
+ gridActive(false);
AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft));
- rectangularGridActive(true);
+ gridActive(true);
AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
- rectangularGridActive(false);
+ gridActive(false);
}
- private void rectangularGridActive(bool active)
+ 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 (1, 1)", () =>
+ AddStep("move cursor to spacing + (1, 1)", () =>
{
- var composer = Editor.ChildrenOfType().Single();
- InputManager.MoveMouseTo(composer.ToScreenSpace(new Vector2(1, 1)));
+ var composer = Editor.ChildrenOfType().Single();
+ InputManager.MoveMouseTo(composer.ToScreenSpace(uniqueSnappingPosition(composer) + new Vector2(1, 1)));
});
if (active)
- AddAssert("placement blueprint at (0, 0)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(0, 0)));
+ {
+ AddAssert("placement blueprint at spacing + (0, 0)", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ return Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position,
+ uniqueSnappingPosition(composer));
+ });
+ }
else
- AddAssert("placement blueprint at (1, 1)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(1, 1)));
+ {
+ AddAssert("placement blueprint at spacing + (1, 1)", () =>
+ {
+ var composer = Editor.ChildrenOfType().Single();
+ return Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position,
+ uniqueSnappingPosition(composer) + new Vector2(1, 1));
+ });
+ }
+ }
+
+ private Vector2 uniqueSnappingPosition(PositionSnapGrid grid)
+ {
+ 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),
+ CircularPositionSnapGrid circular => circular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(circular.Spacing.Value, 0), -45),
+ _ => Vector2.Zero
+ };
}
[Test]
public void TestGridSizeToggling()
{
AddStep("enable rectangular grid", () => InputManager.Key(Key.Y));
- AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any());
+ AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any());
gridSizeIs(4);
nextGridSizeIs(8);
@@ -99,7 +190,99 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}
private void gridSizeIs(int size)
- => AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing == new Vector2(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/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
index 0ca30e00bc..93eb76aba6 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
@@ -15,6 +15,7 @@ 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
{
@@ -30,23 +31,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
});
- [Test]
- public void TestAddOverlappingControlPoints()
- {
- createVisualiser(true);
-
- addControlPointStep(new Vector2(200));
- addControlPointStep(new Vector2(300));
- addControlPointStep(new Vector2(300));
- addControlPointStep(new Vector2(500, 300));
-
- AddAssert("last connection displayed", () =>
- {
- var lastConnection = visualiser.Connections.Last(c => c.ControlPoint.Position == new Vector2(300));
- return lastConnection.DrawWidth > 50;
- });
- }
-
[Test]
public void TestPerfectCurveTooManyPoints()
{
@@ -195,21 +179,76 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}
[Test]
- public void TestStackingUpdatesConnectionPosition()
+ public void TestChangingControlPointTypeViaTab()
{
createVisualiser(true);
- Vector2 connectionPosition;
- addControlPointStep(connectionPosition = new Vector2(300));
- addControlPointStep(new Vector2(600));
+ addControlPointStep(new Vector2(200), PathType.LINEAR);
+ addControlPointStep(new Vector2(300));
+ addControlPointStep(new Vector2(500, 300));
+ addControlPointStep(new Vector2(700, 200));
+ addControlPointStep(new Vector2(500, 100));
- // Apply a big number in stacking so the person running the test can clearly see if it fails
- AddStep("apply stacking", () => slider.StackHeightBindable.Value += 10);
+ AddStep("select first control point", () => visualiser.Pieces[0].IsSelected.Value = true);
+ AddStep("press tab", () => InputManager.Key(Key.Tab));
+ assertControlPointPathType(0, PathType.BEZIER);
- AddAssert($"Connection at {connectionPosition} changed",
- () => visualiser.Connections[0].Position,
- () => !Is.EqualTo(connectionPosition)
- );
+ AddStep("press shift-tab", () =>
+ {
+ InputManager.PressKey(Key.LShift);
+ InputManager.Key(Key.Tab);
+ InputManager.ReleaseKey(Key.LShift);
+ });
+ assertControlPointPathType(0, PathType.LINEAR);
+
+ AddStep("press shift-tab", () =>
+ {
+ InputManager.PressKey(Key.LShift);
+ InputManager.Key(Key.Tab);
+ InputManager.ReleaseKey(Key.LShift);
+ });
+ assertControlPointPathType(0, PathType.BSpline(4));
+
+ AddStep("press shift-tab", () =>
+ {
+ InputManager.PressKey(Key.LShift);
+ InputManager.Key(Key.Tab);
+ InputManager.ReleaseKey(Key.LShift);
+ });
+ assertControlPointPathType(0, PathType.PERFECT_CURVE);
+ assertControlPointPathType(2, PathType.BSpline(4));
+
+ AddStep("select third last control point", () =>
+ {
+ visualiser.Pieces[0].IsSelected.Value = false;
+ visualiser.Pieces[2].IsSelected.Value = true;
+ });
+
+ AddStep("press shift-tab", () =>
+ {
+ InputManager.PressKey(Key.LShift);
+ InputManager.Key(Key.Tab);
+ InputManager.ReleaseKey(Key.LShift);
+ });
+ assertControlPointPathType(2, PathType.PERFECT_CURVE);
+
+ AddRepeatStep("press tab", () => InputManager.Key(Key.Tab), 2);
+ assertControlPointPathType(0, PathType.BEZIER);
+ assertControlPointPathType(2, null);
+
+ AddStep("select first and third control points", () =>
+ {
+ visualiser.Pieces[0].IsSelected.Value = true;
+ visualiser.Pieces[2].IsSelected.Value = true;
+ });
+ AddStep("press alt-1", () =>
+ {
+ InputManager.PressKey(Key.AltLeft);
+ InputManager.Key(Key.Number1);
+ InputManager.ReleaseKey(Key.AltLeft);
+ });
+ assertControlPointPathType(0, PathType.LINEAR);
+ assertControlPointPathType(2, PathType.LINEAR);
}
private void addAssertPointPositionChanged(Vector2[] points, int index)
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs
index 30e0dbbf2e..d14bd1fc87 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs
@@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
() => EditorBeatmap.HitObjects.OfType().ElementAt(1).Position,
() => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200)));
- AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(1).TriggerClick());
+ AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(2).TriggerClick());
AddAssert("first object rotated 90deg around selection centre",
() => EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
AddAssert("second object rotated 90deg around selection centre",
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderChangeStates.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderChangeStates.cs
new file mode 100644
index 0000000000..0b8f2f7417
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderChangeStates.cs
@@ -0,0 +1,47 @@
+// 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.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ public partial class TestSceneSliderChangeStates : TestSceneOsuEditor
+ {
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
+
+ [TestCase(SplineType.Catmull)]
+ [TestCase(SplineType.BSpline)]
+ [TestCase(SplineType.Linear)]
+ [TestCase(SplineType.PerfectCurve)]
+ public void TestSliderRetainsCurveTypes(SplineType splineType)
+ {
+ Slider? slider = null;
+ PathType pathType = new PathType(splineType);
+
+ AddStep("add slider", () => EditorBeatmap.Add(slider = new Slider
+ {
+ StartTime = 500,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(Vector2.Zero, pathType),
+ new PathControlPoint(new Vector2(200, 0), pathType),
+ })
+ }));
+ AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType));
+ AddStep("remove object", () => EditorBeatmap.Remove(slider));
+ AddAssert("slider removed", () => EditorBeatmap.HitObjects.Count == 0);
+ addUndoSteps();
+ AddAssert("slider not removed", () => EditorBeatmap.HitObjects.Count == 1);
+ AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType));
+ }
+
+ private void addUndoSteps() => AddStep("undo", () => Editor.Undo());
+ }
+}
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 bbded55732..5831cc0a8a 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
@@ -2,13 +2,17 @@
// See the LICENCE file in the repository root for full licence text.
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;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Tests.Visual;
@@ -57,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertLength(200);
assertControlPointCount(2);
- assertControlPointType(0, PathType.LINEAR);
+ assertFinalControlPointType(0, PathType.LINEAR);
}
[Test]
@@ -71,7 +75,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(2);
- assertControlPointType(0, PathType.LINEAR);
+ assertFinalControlPointType(0, PathType.LINEAR);
}
[Test]
@@ -89,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
assertControlPointPosition(1, new Vector2(100, 0));
- assertControlPointType(0, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(0, PathType.PERFECT_CURVE);
}
[Test]
@@ -111,7 +115,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertControlPointCount(4);
assertControlPointPosition(1, new Vector2(100, 0));
assertControlPointPosition(2, new Vector2(100, 100));
- assertControlPointType(0, PathType.BEZIER);
+ assertFinalControlPointType(0, PathType.BEZIER);
}
[Test]
@@ -130,8 +134,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
assertControlPointPosition(1, new Vector2(100, 0));
- assertControlPointType(0, PathType.LINEAR);
- assertControlPointType(1, PathType.LINEAR);
+ assertFinalControlPointType(0, PathType.LINEAR);
+ assertFinalControlPointType(1, PathType.LINEAR);
}
[Test]
@@ -149,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(2);
- assertControlPointType(0, PathType.LINEAR);
+ assertFinalControlPointType(0, PathType.LINEAR);
assertLength(100);
}
@@ -171,7 +175,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
- assertControlPointType(0, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(0, PathType.PERFECT_CURVE);
}
[Test]
@@ -195,7 +199,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(4);
- assertControlPointType(0, PathType.BEZIER);
+ assertFinalControlPointType(0, PathType.BEZIER);
}
[Test]
@@ -215,8 +219,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertControlPointCount(3);
assertControlPointPosition(1, new Vector2(100, 0));
assertControlPointPosition(2, new Vector2(100));
- assertControlPointType(0, PathType.LINEAR);
- assertControlPointType(1, PathType.LINEAR);
+ assertFinalControlPointType(0, PathType.LINEAR);
+ assertFinalControlPointType(1, PathType.LINEAR);
}
[Test]
@@ -239,8 +243,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertControlPointCount(4);
assertControlPointPosition(1, new Vector2(100, 0));
assertControlPointPosition(2, new Vector2(100));
- assertControlPointType(0, PathType.LINEAR);
- assertControlPointType(1, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(0, PathType.LINEAR);
+ assertFinalControlPointType(1, PathType.PERFECT_CURVE);
}
[Test]
@@ -268,8 +272,54 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertControlPointPosition(2, new Vector2(100));
assertControlPointPosition(3, new Vector2(200, 100));
assertControlPointPosition(4, new Vector2(200));
- assertControlPointType(0, PathType.PERFECT_CURVE);
- assertControlPointType(2, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(0, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(2, PathType.PERFECT_CURVE);
+ }
+
+ [Test]
+ public void TestManualPathTypeControlViaKeyboard()
+ {
+ addMovementStep(new Vector2(200));
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(new Vector2(300, 200));
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(new Vector2(300));
+
+ assertControlPointTypeDuringPlacement(0, PathType.PERFECT_CURVE);
+
+ AddRepeatStep("press tab", () => InputManager.Key(Key.Tab), 2);
+ assertControlPointTypeDuringPlacement(0, PathType.LINEAR);
+
+ AddStep("press shift-tab", () =>
+ {
+ InputManager.PressKey(Key.ShiftLeft);
+ InputManager.Key(Key.Tab);
+ InputManager.ReleaseKey(Key.ShiftLeft);
+ });
+ assertControlPointTypeDuringPlacement(0, PathType.BSpline(4));
+
+ AddStep("press alt-2", () =>
+ {
+ InputManager.PressKey(Key.AltLeft);
+ InputManager.Key(Key.Number2);
+ InputManager.ReleaseKey(Key.AltLeft);
+ });
+ assertControlPointTypeDuringPlacement(0, PathType.BEZIER);
+
+ AddStep("start new segment via S", () => InputManager.Key(Key.S));
+ assertControlPointTypeDuringPlacement(2, PathType.LINEAR);
+
+ addMovementStep(new Vector2(400, 300));
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(new Vector2(400));
+ addClickStep(MouseButton.Right);
+
+ assertPlaced(true);
+ assertFinalControlPointType(0, PathType.BEZIER);
+ assertFinalControlPointType(2, PathType.PERFECT_CURVE);
}
[Test]
@@ -293,7 +343,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
addClickStep(MouseButton.Right);
assertPlaced(true);
- assertControlPointType(0, PathType.BEZIER);
+ assertFinalControlPointType(0, PathType.BEZIER);
}
[Test]
@@ -312,11 +362,11 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertLength(808, tolerance: 10);
assertControlPointCount(5);
- assertControlPointType(0, PathType.BSpline(4));
- assertControlPointType(1, null);
- assertControlPointType(2, null);
- assertControlPointType(3, null);
- assertControlPointType(4, null);
+ assertFinalControlPointType(0, PathType.BSpline(4));
+ assertFinalControlPointType(1, null);
+ assertFinalControlPointType(2, null);
+ assertFinalControlPointType(3, null);
+ assertFinalControlPointType(4, null);
}
[Test]
@@ -337,10 +387,33 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertLength(600, tolerance: 10);
assertControlPointCount(4);
- assertControlPointType(0, PathType.BSpline(4));
- assertControlPointType(1, PathType.BSpline(4));
- assertControlPointType(2, PathType.BSpline(4));
- assertControlPointType(3, null);
+ assertFinalControlPointType(0, PathType.BSpline(4));
+ assertFinalControlPointType(1, PathType.BSpline(4));
+ assertFinalControlPointType(2, PathType.BSpline(4));
+ 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]
@@ -359,7 +432,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
- assertControlPointType(0, PathType.BEZIER);
+ assertFinalControlPointType(0, PathType.BEZIER);
}
[Test]
@@ -379,7 +452,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
- assertControlPointType(0, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(0, PathType.PERFECT_CURVE);
}
[Test]
@@ -400,7 +473,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
- assertControlPointType(0, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(0, PathType.PERFECT_CURVE);
}
[Test]
@@ -421,7 +494,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
- assertControlPointType(0, PathType.BEZIER);
+ assertFinalControlPointType(0, PathType.BEZIER);
}
[Test]
@@ -438,11 +511,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertPlaced(true);
assertControlPointCount(3);
- assertControlPointType(0, PathType.PERFECT_CURVE);
+ assertFinalControlPointType(0, PathType.PERFECT_CURVE);
}
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));
@@ -454,7 +529,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void assertControlPointCount(int expected) => AddAssert($"has {expected} control points", () => getSlider()!.Path.ControlPoints.Count, () => Is.EqualTo(expected));
- private void assertControlPointType(int index, PathType? type) => AddAssert($"control point {index} is {type?.ToString() ?? "inherit"}", () => getSlider()!.Path.ControlPoints[index].Type, () => Is.EqualTo(type));
+ private void assertControlPointTypeDuringPlacement(int index, PathType? type) => AddAssert($"control point {index} is {type?.ToString() ?? "inherit"}",
+ () => this.ChildrenOfType>().ElementAt(index).ControlPoint.Type, () => Is.EqualTo(type));
+
+ private void assertFinalControlPointType(int index, PathType? type) => AddAssert($"control point {index} is {type?.ToString() ?? "inherit"}", () => getSlider()!.Path.ControlPoints[index].Type, () => Is.EqualTo(type));
private void assertControlPointPosition(int index, Vector2 position) =>
AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, getSlider()!.Path.ControlPoints[index].Position, 1));
@@ -462,6 +540,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private Slider? getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null;
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject);
- protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint();
+ protected override HitObjectPlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs
index d4d99e1019..f0f969b15b 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.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 NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
@@ -22,9 +20,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene
{
- private Slider slider;
- private DrawableSlider drawableObject;
- private TestSliderBlueprint blueprint;
+ private Slider slider = null!;
+ private DrawableSlider drawableObject = null!;
+ private TestSliderBlueprint blueprint = null!;
[SetUp]
public void Setup() => Schedule(() =>
@@ -163,6 +161,44 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
checkControlPointSelected(1, false);
}
+ [Test]
+ public void TestAdjustLength()
+ {
+ AddStep("move mouse to drag marker", () =>
+ {
+ Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0);
+ InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
+ });
+ AddStep("start drag", () => InputManager.PressButton(MouseButton.Left));
+ AddStep("move mouse to control point 1", () =>
+ {
+ Vector2 position = slider.Position + slider.Path.ControlPoints[1].Position + new Vector2(60, 0);
+ InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
+ });
+ AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left));
+ AddAssert("expected distance halved",
+ () => Precision.AlmostEquals(slider.Path.Distance, 172.2, 0.1));
+
+ AddStep("move mouse to drag marker", () =>
+ {
+ Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0);
+ InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
+ });
+ AddStep("start drag", () => InputManager.PressButton(MouseButton.Left));
+ AddStep("move mouse beyond last control point", () =>
+ {
+ Vector2 position = slider.Position + slider.Path.ControlPoints[2].Position + new Vector2(100, 0);
+ InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
+ });
+ AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left));
+ AddAssert("expected distance is calculated distance",
+ () => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1));
+
+ moveMouseToControlPoint(1);
+ AddAssert("expected distance is unchanged",
+ () => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1));
+ }
+
private void moveHitObject()
{
AddStep("move hitobject", () =>
@@ -180,6 +216,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("tail positioned correctly",
() => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre));
+
+ AddAssert("end drag marker positioned correctly",
+ () => Precision.AlmostEquals(blueprint.TailOverlay.EndDragMarker!.ToScreenSpace(blueprint.TailOverlay.EndDragMarker.OriginPosition), drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre, 2));
}
private void moveMouseToControlPoint(int index)
@@ -192,14 +231,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}
private void checkControlPointSelected(int index, bool selected)
- => AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected);
+ => AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser!.Pieces[index].IsSelected.Value == selected);
private partial class TestSliderBlueprint : SliderSelectionBlueprint
{
public new SliderBodyPiece BodyPiece => base.BodyPiece;
public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
- public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser;
+ public new PathControlPointVisualiser? ControlPointVisualiser => base.ControlPointVisualiser;
public TestSliderBlueprint(Slider slider)
: base(slider)
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/TestSceneSpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs
index 0e8673319e..d7b5cc73be 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs
@@ -15,6 +15,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSpinner((Spinner)hitObject);
- protected override PlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint();
+ protected override HitObjectPlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint();
}
}
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/Editor/TestSliderScaling.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs
index 021fdba225..52a170b84e 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs
@@ -3,9 +3,12 @@
#nullable disable
+using System;
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Graphics.Primitives;
using osu.Framework.Testing;
+using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
@@ -71,4 +74,120 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void moveMouse(Vector2 pos) =>
AddStep($"move mouse to {pos}", () => InputManager.MoveMouseTo(playfield.ToScreenSpace(pos)));
}
+
+ [TestFixture]
+ public class TestSliderNearLinearScaling
+ {
+ private readonly Random rng = new Random(1337);
+
+ [Test]
+ public void TestScalingSliderFlat()
+ {
+ SliderPath sliderPathPerfect = new SliderPath(
+ [
+ new PathControlPoint(new Vector2(0), PathType.PERFECT_CURVE),
+ new PathControlPoint(new Vector2(50, 25)),
+ new PathControlPoint(new Vector2(25, 100)),
+ ]);
+
+ SliderPath sliderPathBezier = new SliderPath(
+ [
+ new PathControlPoint(new Vector2(0), PathType.BEZIER),
+ new PathControlPoint(new Vector2(50, 25)),
+ new PathControlPoint(new Vector2(25, 100)),
+ ]);
+
+ scaleSlider(sliderPathPerfect, new Vector2(0.000001f, 1));
+ scaleSlider(sliderPathBezier, new Vector2(0.000001f, 1));
+
+ for (int i = 0; i < 100; i++)
+ {
+ Assert.True(Precision.AlmostEquals(sliderPathPerfect.PositionAt(i / 100.0f), sliderPathBezier.PositionAt(i / 100.0f)));
+ }
+ }
+
+ [Test]
+ public void TestPerfectCurveMatchesTheoretical()
+ {
+ for (int i = 0; i < 20000; i++)
+ {
+ //Only test points that are in the screen's bounds
+ float p1X = 640.0f * (float)rng.NextDouble();
+ float p2X = 640.0f * (float)rng.NextDouble();
+
+ float p1Y = 480.0f * (float)rng.NextDouble();
+ float p2Y = 480.0f * (float)rng.NextDouble();
+ SliderPath sliderPathPerfect = new SliderPath(
+ [
+ new PathControlPoint(new Vector2(0, 0), PathType.PERFECT_CURVE),
+ new PathControlPoint(new Vector2(p1X, p1Y)),
+ new PathControlPoint(new Vector2(p2X, p2Y)),
+ ]);
+
+ assertMatchesPerfectCircle(sliderPathPerfect);
+
+ scaleSlider(sliderPathPerfect, new Vector2(0.00001f, 1));
+
+ assertMatchesPerfectCircle(sliderPathPerfect);
+ }
+ }
+
+ private void assertMatchesPerfectCircle(SliderPath path)
+ {
+ if (path.ControlPoints.Count != 3)
+ return;
+
+ //Replication of PathApproximator.CircularArcToPiecewiseLinear
+ CircularArcProperties circularArcProperties = new CircularArcProperties(path.ControlPoints.Select(x => x.Position).ToArray());
+
+ if (!circularArcProperties.IsValid)
+ return;
+
+ //Addresses cases where circularArcProperties.ThetaRange>0.5
+ //Occurs in code in PathControlPointVisualiser.ensureValidPathType
+ RectangleF boundingBox = PathApproximator.CircularArcBoundingBox(path.ControlPoints.Select(x => x.Position).ToArray());
+ if (boundingBox.Width >= 640 || boundingBox.Height >= 480)
+ return;
+
+ int subpoints = (2f * circularArcProperties.Radius <= 0.1f) ? 2 : Math.Max(2, (int)Math.Ceiling(circularArcProperties.ThetaRange / (2.0 * Math.Acos(1f - (0.1f / circularArcProperties.Radius)))));
+
+ //ignore cases where subpoints is int.MaxValue, result will be garbage
+ //as well, having this many subpoints will cause an out of memory error, so can't happen during normal useage
+ if (subpoints == int.MaxValue)
+ return;
+
+ for (int i = 0; i < Math.Min(subpoints, 100); i++)
+ {
+ float progress = (float)rng.NextDouble();
+
+ //To avoid errors from interpolating points, ensure we check only positions that would be subpoints.
+ progress = (float)Math.Ceiling(progress * (subpoints - 1)) / (subpoints - 1);
+
+ //Special case - if few subpoints, ensure checking every single one rather than randomly
+ if (subpoints < 100)
+ progress = i / (float)(subpoints - 1);
+
+ //edge points cause issue with interpolation, so ignore the last two points and first
+ if (progress == 0.0f || progress >= (subpoints - 2) / (float)(subpoints - 1))
+ continue;
+
+ double theta = circularArcProperties.ThetaStart + (circularArcProperties.Direction * progress * circularArcProperties.ThetaRange);
+ Vector2 vector = new Vector2((float)Math.Cos(theta), (float)Math.Sin(theta)) * circularArcProperties.Radius;
+
+ Assert.True(Precision.AlmostEquals(circularArcProperties.Centre + vector, path.PositionAt(progress), 0.01f),
+ "A perfect circle with points " + string.Join(", ", path.ControlPoints.Select(x => x.Position)) + " and radius" + circularArcProperties.Radius + "from SliderPath does not almost equal a theoretical perfect circle with " + subpoints + " subpoints"
+ + ": " + (circularArcProperties.Centre + vector) + " - " + path.PositionAt(progress)
+ + " = " + (circularArcProperties.Centre + vector - path.PositionAt(progress))
+ );
+ }
+ }
+
+ private void scaleSlider(SliderPath path, Vector2 scale)
+ {
+ for (int i = 0; i < path.ControlPoints.Count; i++)
+ {
+ path.ControlPoints[i].Position *= scale;
+ }
+ }
+ }
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs
index 88c81c7a39..7375617aa8 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs
@@ -133,7 +133,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Autoplay = false,
Beatmap = new Beatmap
{
- Breaks = new List
+ Breaks =
{
new BreakPeriod(500, 2000),
},
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..0b3496ba68
--- /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,
+ Beatmap = 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 9dfa76fc8e..d3996ebc3b 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
StartTime = 5000,
}
},
- Breaks = new List
+ Breaks =
{
new BreakPeriod(2000, 4000),
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
index 402c680b46..bd2b205ac8 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs
@@ -132,7 +132,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
Autoplay = false,
Beatmap = new Beatmap
{
- Breaks = new List
+ Breaks =
{
new BreakPeriod(500, 2000),
},
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/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs
index 4db66fde4b..17f365f820 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs
@@ -88,6 +88,21 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("trail is disjoint", () => this.ChildrenOfType().Single().DisjointTrail, () => Is.True);
}
+ [Test]
+ public void TestClickExpand()
+ {
+ createTest(() => new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Scale = new Vector2(10),
+ Child = new CursorTrail(),
+ });
+
+ AddStep("expand", () => this.ChildrenOfType().Single().NewPartScale = new Vector2(3));
+ AddWaitStep("let the cursor trail draw a bit", 5);
+ AddStep("contract", () => this.ChildrenOfType().Single().NewPartScale = Vector2.One);
+ }
+
private void createTest(Func createContent) => AddStep("create trail", () =>
{
Clear();
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs
index 5f5596cbb3..a239f671af 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs
@@ -108,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Tests
private partial class TestDrawableOsuJudgement : DrawableOsuJudgement
{
public new SkinnableSprite Lighting => base.Lighting;
- public new SkinnableDrawable JudgementBody => base.JudgementBody;
+ public new SkinnableDrawable? JudgementBody => base.JudgementBody;
}
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs
index e6696032ae..98113a6513 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs
@@ -161,9 +161,9 @@ namespace osu.Game.Rulesets.Osu.Tests
pressed = value;
if (value)
- OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton));
+ OnPressed(new KeyBindingPressEvent(GetContainingInputManager()!.CurrentState, OsuAction.LeftButton));
else
- OnReleased(new KeyBindingReleaseEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton));
+ OnReleased(new KeyBindingReleaseEvent(GetContainingInputManager()!.CurrentState, OsuAction.LeftButton));
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs
index 71174e3295..5cac9843b8 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs
@@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Osu.Tests
private void scheduleHit() => AddStep("schedule action", () =>
{
double delay = hitCircle.StartTime - hitCircle.HitWindows.WindowFor(HitResult.Great) - Time.Current;
- Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton)), delay);
+ Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager()!.CurrentState, OsuAction.LeftButton)), delay);
});
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs
new file mode 100644
index 0000000000..184938ceda
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs
@@ -0,0 +1,133 @@
+// 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 System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Replays;
+using osu.Game.Rulesets.Osu.Configuration;
+using osu.Game.Rulesets.Osu.Replays;
+using osu.Game.Rulesets.Osu.UI;
+using osu.Game.Rulesets.Replays;
+using osu.Game.Tests.Visual;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests
+{
+ public partial class TestSceneOsuAnalysisContainer : OsuTestScene
+ {
+ private TestReplayAnalysisOverlay analysisContainer = null!;
+ private ReplayAnalysisSettings settings = null!;
+
+ [Cached]
+ private OsuRulesetConfigManager config = new OsuRulesetConfigManager(null, new OsuRuleset().RulesetInfo);
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("create analysis container", () =>
+ {
+ Children = new Drawable[]
+ {
+ new OsuPlayfieldAdjustmentContainer
+ {
+ Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay()),
+ },
+ settings = new ReplayAnalysisSettings(config),
+ };
+
+ settings.ShowClickMarkers.Value = false;
+ settings.ShowAimMarkers.Value = false;
+ settings.ShowCursorPath.Value = false;
+ });
+ }
+
+ [Test]
+ public void TestEverythingOn()
+ {
+ AddStep("enable everything", () =>
+ {
+ settings.ShowClickMarkers.Value = true;
+ settings.ShowAimMarkers.Value = true;
+ settings.ShowCursorPath.Value = true;
+ });
+ }
+
+ [Test]
+ public void TestHitMarkers()
+ {
+ AddStep("enable hit markers", () => settings.ShowClickMarkers.Value = true);
+ AddUntilStep("hit markers visible", () => analysisContainer.HitMarkersVisible);
+ AddStep("disable hit markers", () => settings.ShowClickMarkers.Value = false);
+ AddUntilStep("hit markers not visible", () => !analysisContainer.HitMarkersVisible);
+ }
+
+ [Test]
+ public void TestAimMarker()
+ {
+ AddStep("enable aim markers", () => settings.ShowAimMarkers.Value = true);
+ AddUntilStep("aim markers visible", () => analysisContainer.AimMarkersVisible);
+ AddStep("disable aim markers", () => settings.ShowAimMarkers.Value = false);
+ AddUntilStep("aim markers not visible", () => !analysisContainer.AimMarkersVisible);
+ }
+
+ [Test]
+ public void TestAimLines()
+ {
+ AddStep("enable aim lines", () => settings.ShowCursorPath.Value = true);
+ AddUntilStep("aim lines visible", () => analysisContainer.AimLinesVisible);
+ AddStep("disable aim lines", () => settings.ShowCursorPath.Value = false);
+ AddUntilStep("aim lines not visible", () => !analysisContainer.AimLinesVisible);
+ }
+
+ private Replay fabricateReplay()
+ {
+ var frames = new List();
+ var random = new Random();
+ int posX = 250;
+ int posY = 250;
+
+ var actions = new HashSet();
+
+ for (int i = 0; i < 1000; i++)
+ {
+ posX = Math.Clamp(posX + random.Next(-20, 21), -100, 600);
+ posY = Math.Clamp(posY + random.Next(-20, 21), -100, 600);
+
+ if (random.NextDouble() > (actions.Count == 0 ? 0.9 : 0.95))
+ {
+ actions.Add(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton);
+ }
+ else if (random.NextDouble() > 0.7)
+ {
+ actions.Remove(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton);
+ }
+
+ frames.Add(new OsuReplayFrame
+ {
+ Time = Time.Current + i * 15,
+ Position = new Vector2(posX, posY),
+ Actions = actions.ToList(),
+ });
+ }
+
+ return new Replay { Frames = frames };
+ }
+
+ private partial class TestReplayAnalysisOverlay : ReplayAnalysisOverlay
+ {
+ public TestReplayAnalysisOverlay(Replay replay)
+ : base(replay)
+ {
+ }
+
+ public bool HitMarkersVisible => ClickMarkers?.Alpha > 0 && ClickMarkers.Entries.Any();
+ public bool AimMarkersVisible => FrameMarkers?.Alpha > 0 && FrameMarkers.Entries.Any();
+ public bool AimLinesVisible => CursorPath?.Alpha > 0 && CursorPath.Vertices.Count > 1;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs
index 5bf7c0326a..bf0ab8efa0 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs
@@ -19,6 +19,7 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
+using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Osu.UI.Cursor;
using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual;
@@ -578,6 +579,24 @@ namespace osu.Game.Rulesets.Osu.Tests
assertKeyCounter(1, 1);
}
+ [Test]
+ public void TestTouchJudgedCircle()
+ {
+ addHitCircleAt(TouchSource.Touch1);
+ addHitCircleAt(TouchSource.Touch2);
+
+ beginTouch(TouchSource.Touch1);
+ endTouch(TouchSource.Touch1);
+
+ // Hold the second touch (this becomes the primary touch).
+ beginTouch(TouchSource.Touch2);
+
+ // Touch again on the first circle.
+ // Because it's been judged, the cursor should not move here.
+ beginTouch(TouchSource.Touch1);
+ checkPosition(TouchSource.Touch2);
+ }
+
private void addHitCircleAt(TouchSource source)
{
AddStep($"Add circle at {source}", () =>
@@ -590,6 +609,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
Clock = new FramedClock(new ManualClock()),
Position = mainContent.ToLocalSpace(getSanePositionForSource(source)),
+ CheckHittable = (_, _, _) => ClickAction.Hit
});
});
}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs
index 4600db8174..b18c77e8ee 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs
@@ -156,6 +156,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
slider = (DrawableSlider)createSlider(repeats: 1);
Add(slider);
+ slider.HitObject.NodeSamples.Clear();
});
AddStep("change samples", () => slider.HitObject.Samples = new[]
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/Beatmaps/OsuBeatmapProcessor.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs
index 9cc0a8c414..0e77553177 100644
--- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs
+++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
@@ -41,22 +42,27 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
{
base.PostProcess();
- var osuBeatmap = (Beatmap)Beatmap;
+ ApplyStacking(Beatmap);
+ }
- if (osuBeatmap.HitObjects.Count > 0)
+ internal static void ApplyStacking(IBeatmap beatmap)
+ {
+ var hitObjects = beatmap.HitObjects as List ?? beatmap.HitObjects.OfType().ToList();
+
+ if (hitObjects.Count > 0)
{
// Reset stacking
- foreach (var h in osuBeatmap.HitObjects)
+ foreach (var h in hitObjects)
h.StackHeight = 0;
- if (Beatmap.BeatmapInfo.BeatmapVersion >= 6)
- applyStacking(osuBeatmap, 0, osuBeatmap.HitObjects.Count - 1);
+ if (beatmap.BeatmapInfo.BeatmapVersion >= 6)
+ applyStacking(beatmap.BeatmapInfo, hitObjects, 0, hitObjects.Count - 1);
else
- applyStackingOld(osuBeatmap);
+ applyStackingOld(beatmap.BeatmapInfo, hitObjects);
}
}
- private void applyStacking(Beatmap beatmap, int startIndex, int endIndex)
+ private static void applyStacking(BeatmapInfo beatmapInfo, List hitObjects, int startIndex, int endIndex)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(startIndex, endIndex);
ArgumentOutOfRangeException.ThrowIfNegative(startIndex);
@@ -64,24 +70,24 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
int extendedEndIndex = endIndex;
- if (endIndex < beatmap.HitObjects.Count - 1)
+ if (endIndex < hitObjects.Count - 1)
{
// Extend the end index to include objects they are stacked on
for (int i = endIndex; i >= startIndex; i--)
{
int stackBaseIndex = i;
- for (int n = stackBaseIndex + 1; n < beatmap.HitObjects.Count; n++)
+ for (int n = stackBaseIndex + 1; n < hitObjects.Count; n++)
{
- OsuHitObject stackBaseObject = beatmap.HitObjects[stackBaseIndex];
+ OsuHitObject stackBaseObject = hitObjects[stackBaseIndex];
if (stackBaseObject is Spinner) break;
- OsuHitObject objectN = beatmap.HitObjects[n];
+ OsuHitObject objectN = hitObjects[n];
if (objectN is Spinner)
continue;
double endTime = stackBaseObject.GetEndTime();
- double stackThreshold = objectN.TimePreempt * beatmap.BeatmapInfo.StackLeniency;
+ double stackThreshold = objectN.TimePreempt * beatmapInfo.StackLeniency;
if (objectN.StartTime - endTime > stackThreshold)
// We are no longer within stacking range of the next object.
@@ -100,7 +106,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
if (stackBaseIndex > extendedEndIndex)
{
extendedEndIndex = stackBaseIndex;
- if (extendedEndIndex == beatmap.HitObjects.Count - 1)
+ if (extendedEndIndex == hitObjects.Count - 1)
break;
}
}
@@ -123,10 +129,10 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
* 2 and 1 will be ignored in the i loop because they already have a stack value.
*/
- OsuHitObject objectI = beatmap.HitObjects[i];
+ OsuHitObject objectI = hitObjects[i];
if (objectI.StackHeight != 0 || objectI is Spinner) continue;
- double stackThreshold = objectI.TimePreempt * beatmap.BeatmapInfo.StackLeniency;
+ double stackThreshold = objectI.TimePreempt * beatmapInfo.StackLeniency;
/* If this object is a hitcircle, then we enter this "special" case.
* It either ends with a stack of hitcircles only, or a stack of hitcircles that are underneath a slider.
@@ -136,7 +142,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
{
while (--n >= 0)
{
- OsuHitObject objectN = beatmap.HitObjects[n];
+ OsuHitObject objectN = hitObjects[n];
if (objectN is Spinner) continue;
double endTime = objectN.GetEndTime();
@@ -164,7 +170,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
for (int j = n + 1; j <= i; j++)
{
// For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above).
- OsuHitObject objectJ = beatmap.HitObjects[j];
+ OsuHitObject objectJ = hitObjects[j];
if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < stack_distance)
objectJ.StackHeight -= offset;
}
@@ -191,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
*/
while (--n >= startIndex)
{
- OsuHitObject objectN = beatmap.HitObjects[n];
+ OsuHitObject objectN = hitObjects[n];
if (objectN is Spinner) continue;
if (objectI.StartTime - objectN.StartTime > stackThreshold)
@@ -208,11 +214,11 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
}
}
- private void applyStackingOld(Beatmap beatmap)
+ private static void applyStackingOld(BeatmapInfo beatmapInfo, List hitObjects)
{
- for (int i = 0; i < beatmap.HitObjects.Count; i++)
+ for (int i = 0; i < hitObjects.Count; i++)
{
- OsuHitObject currHitObject = beatmap.HitObjects[i];
+ OsuHitObject currHitObject = hitObjects[i];
if (currHitObject.StackHeight != 0 && !(currHitObject is Slider))
continue;
@@ -220,11 +226,11 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
double startTime = currHitObject.GetEndTime();
int sliderStack = 0;
- for (int j = i + 1; j < beatmap.HitObjects.Count; j++)
+ for (int j = i + 1; j < hitObjects.Count; j++)
{
- double stackThreshold = beatmap.HitObjects[i].TimePreempt * beatmap.BeatmapInfo.StackLeniency;
+ double stackThreshold = hitObjects[i].TimePreempt * beatmapInfo.StackLeniency;
- if (beatmap.HitObjects[j].StartTime - stackThreshold > startTime)
+ if (hitObjects[j].StartTime - stackThreshold > startTime)
break;
// The start position of the hitobject, or the position at the end of the path if the hitobject is a slider
@@ -239,17 +245,17 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
// Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
// if we use `EndTime` here it would result in unexpected stacking.
- if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, currHitObject.Position) < stack_distance)
+ if (Vector2Extensions.Distance(hitObjects[j].Position, currHitObject.Position) < stack_distance)
{
currHitObject.StackHeight++;
- startTime = beatmap.HitObjects[j].StartTime;
+ startTime = hitObjects[j].StartTime;
}
- else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, position2) < stack_distance)
+ else if (Vector2Extensions.Distance(hitObjects[j].Position, position2) < stack_distance)
{
// Case for sliders - bump notes down and right, rather than up and left.
sliderStack++;
- beatmap.HitObjects[j].StackHeight -= sliderStack;
- startTime = beatmap.HitObjects[j].StartTime;
+ hitObjects[j].StackHeight -= sliderStack;
+ startTime = hitObjects[j].StartTime;
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs
index 2056a50eda..580c7e6bd8 100644
--- a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs
+++ b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.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 osu.Game.Configuration;
using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.UI;
@@ -11,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Configuration
{
public class OsuRulesetConfigManager : RulesetConfigManager
{
- public OsuRulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null)
+ public OsuRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null)
: base(settings, ruleset, variant)
{
}
@@ -24,6 +22,12 @@ namespace osu.Game.Rulesets.Osu.Configuration
SetDefault(OsuRulesetSetting.ShowCursorTrail, true);
SetDefault(OsuRulesetSetting.ShowCursorRipples, false);
SetDefault(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None);
+
+ SetDefault(OsuRulesetSetting.ReplayClickMarkersEnabled, false);
+ SetDefault(OsuRulesetSetting.ReplayFrameMarkersEnabled, false);
+ SetDefault(OsuRulesetSetting.ReplayCursorPathEnabled, false);
+ SetDefault(OsuRulesetSetting.ReplayCursorHideEnabled, false);
+ SetDefault(OsuRulesetSetting.ReplayAnalysisDisplayLength, 800);
}
}
@@ -34,5 +38,12 @@ namespace osu.Game.Rulesets.Osu.Configuration
ShowCursorTrail,
ShowCursorRipples,
PlayfieldBorderStyle,
+
+ // Replay
+ ReplayClickMarkersEnabled,
+ ReplayFrameMarkersEnabled,
+ ReplayCursorPathEnabled,
+ ReplayCursorHideEnabled,
+ ReplayAnalysisDisplayLength,
}
}
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/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs
index ed7c60ccf6..e4cbdcd3a3 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs
@@ -17,7 +17,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private const double overlap_multiplier = 1;
- public static double EvaluateDensityOf(DifficultyHitObject current, bool applyDistanceNerf = true)
+ private const double slider_body_length_multiplier = 1.3;
+
+ public static double EvaluateDensityOf(DifficultyHitObject current, bool applyDistanceNerf = true, bool applySliderbodyDensity = true, double angleNerfMultiplier = 1.0)
{
var currObj = (OsuDifficultyHitObject)current;
@@ -38,7 +40,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false);
// Small distances means objects may be cheesed, so it doesn't matter whether they are arranged confusingly.
- if (applyDistanceNerf) loopDifficulty *= (logistic((loopObj.MinimumJumpDistance - 80) / 10) + 0.2) / 1.2;
+ if (applyDistanceNerf) loopDifficulty *= (logistic((loopObj.LazyJumpDistance - 80) / 10) + 0.2) / 1.2;
+
+ // Additional buff for long sliderbodies. OVERBUFFED ON PURPOSE
+ if (applySliderbodyDensity && loopObj.BaseObject is Slider slider)
+ {
+ // In radiuses, with minimal of 1
+ double sliderBodyLength = Math.Max(1, slider.Velocity * slider.SpanDuration / slider.Radius);
+
+ // Bandaid to fix abuze
+ sliderBodyLength = Math.Min(sliderBodyLength, 1 + slider.LazyTravelDistance / 8);
+
+ // The maximum is 3x buff
+ double sliderBodyBuff = Math.Log10(sliderBodyLength);
+
+ // Limit the max buff to prevent abuse with very long sliders.
+ // With explicit coverage of cases like one very long slider on the map, or just very few objects visible before/after.
+ double maxBuff = 0.5;
+ if (i > 0) maxBuff += 1;
+ if (i < readingObjects.Count - 1) maxBuff += 1;
+
+ loopDifficulty *= 1 + slider_body_length_multiplier * Math.Min(sliderBodyBuff, maxBuff);
+ }
// Reduce density bonus for this object if they're too apart in time
// Nerf starts on 1500ms and reaches maximum (*=0) on 3000ms
@@ -63,12 +86,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
density += loopDifficulty;
// Angles nerf
- double currAngleNerf = (loopObj.AnglePredictability / 2) + 0.5;
+ // Why it's /2 + 0.5?
+ // Because there was a bug initially that made angle predictability to be from 0.5 to 1
+ // And removing this bug caused balance to be destroyed
+ double angleNerf = (loopObj.AnglePredictability / 2) + 0.5;
- // Apply the nerf only when it's repeated
- double angleNerf = currAngleNerf;
-
- densityAnglesNerf += angleNerf * loopDifficulty;
+ densityAnglesNerf += angleNerf * loopDifficulty * angleNerfMultiplier;
prevObj0 = loopObj;
}
@@ -109,6 +132,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var sortedDifficulties = overlapDifficulties.OrderByDescending(d => d.Difficulty).ToList();
+ // Nerf overlap values of easier notes that are in the same place as hard notes
for (int i = 0; i < sortedDifficulties.Count; i++)
{
var harderObject = sortedDifficulties[i];
@@ -136,6 +160,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
const double threshold = 0.6;
double weight = 1.0;
+ // Sum the overlap values to get difficulty
foreach (var diffObject in sortedDifficulties.Where(d => d.Difficulty > threshold).OrderByDescending(d => d.Difficulty))
{
// Add weighted difficulty
@@ -150,7 +175,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (current.BaseObject is Spinner || current.Index == 0)
return 0;
- double difficulty = Math.Pow(4 * Math.Log(Math.Max(1, ((OsuDifficultyHitObject)current).Density)), 2.5);
+ double difficulty = Math.Pow(4 * Math.Log(Math.Max(1, EvaluateDensityOf(current, true, true))), 2.5);
double overlapBonus = EvaluateOverlapDifficultyOf(current) * difficulty;
difficulty += overlapBonus;
@@ -160,14 +185,51 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
public static double EvaluateAimingDensityFactorOf(DifficultyHitObject current)
{
- double difficulty = ((OsuDifficultyHitObject)current).Density;
+ double difficulty = EvaluateDensityOf(current, true, false, 0.5);
- return Math.Max(0, Math.Pow(difficulty, 1.5) - 1);
+ return Math.Max(0, Math.Pow(difficulty, 1.37) - 1);
+ }
+
+ // Returns value from 0 to 1, where 0 is very predictable and 1 is very unpredictable
+ public static double EvaluateInpredictabilityOf(DifficultyHitObject current)
+ {
+ if (current.BaseObject is Spinner || current.Index == 0 || current.Previous(0).BaseObject is Spinner)
+ return 0;
+
+ var osuCurrObj = (OsuDifficultyHitObject)current;
+ var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
+
+ double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime;
+ double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime;
+
+ double velocityChangeFactor = 0;
+
+ // https://www.desmos.com/calculator/kqxmqc8pkg
+ if (currVelocity > 0 || prevVelocity > 0)
+ {
+ double velocityChange = Math.Max(0,
+ Math.Min(
+ Math.Abs(prevVelocity - currVelocity) - 0.5 * Math.Min(currVelocity, prevVelocity),
+ Math.Max(((OsuHitObject)osuCurrObj.BaseObject).Radius / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Min(currVelocity, prevVelocity))
+ )); // Stealed from xexxar
+ velocityChangeFactor = velocityChange / Math.Max(currVelocity, prevVelocity); // maxiumum is 0.4
+ velocityChangeFactor /= 0.4;
+ }
+
+ // Rhythm difference punishment for velocity and angle bonuses
+ double rhythmSimilarity = 1 - getRhythmDifference(osuCurrObj.StrainTime, osuLastObj.StrainTime);
+
+ // Make differentiation going from 1/4 to 1/2 and bigger difference
+ // To 1/3 to 1/2 and smaller difference
+ rhythmSimilarity = Math.Clamp(rhythmSimilarity, 0.5, 0.75);
+ rhythmSimilarity = 4 * (rhythmSimilarity - 0.5);
+
+ return velocityChangeFactor * rhythmSimilarity;
}
private static double getTimeNerfFactor(double deltaTime) => Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1);
private static double getRhythmDifference(double t1, double t2) => 1 - Math.Min(t1, t2) / Math.Max(t1, t2);
- private static double logistic(double x) => 1 / (1 + Math.Exp(-x));
+ private static double logistic(double x) => 1.0 / (1 + Math.Exp(-x));
// Finds the overlapness of the last object for which StartTime lower than target
private static double boundBinarySearch(List arr, double target)
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
index 05939bb3ab..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,88 +26,200 @@ 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;
while (rhythmStart < historicalNoteCount - 2 && current.StartTime - current.Previous(rhythmStart).StartTime < history_time_max)
rhythmStart++;
+ 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);
- OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(i);
- OsuDifficultyHitObject lastObj = (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 (current.Previous(i - 1).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 (current.Previous(i).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 2df383aaa8..c5a9675b3d 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;
- 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.85;
///
/// Evaluates the difficulty of tapping the current object, based on:
@@ -30,36 +32,35 @@ 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);
- // derive speedBonus for calculation
- double speedBonus = 1.0;
+ // speedBonus will be 0.0 for BPM < 200
+ double speedBonus = 0.0;
- if (strainTime < min_speed_bonus)
- speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
+ // Add additional scaling bonus for streams/bursts higher than 200bpm
+ 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 = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance);
+ double distance = travelDistance + osuCurrObj.MinimumJumpDistance;
- return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) * doubletapness / strainTime;
+ // Cap distance at single_spacing_threshold
+ distance = Math.Min(distance, single_spacing_threshold);
+
+ // 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 = (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 2a2a0e1dc2..12f4540924 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
@@ -44,12 +44,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("flashlight_difficulty")]
public double FlashlightDifficulty { get; set; }
- ///
- /// The difficulty corresponding to the flashlight skill with HD (used in capping cognition performance).
- ///
- [JsonProperty("hidden_flashlight_difficulty")]
- public double HiddenFlashlightDifficulty { get; set; }
-
///
/// Describes how much of is contributed to by hitcircles or sliders.
/// A value closer to 1.0 indicates most of is contributed by hitcircles.
@@ -58,6 +52,18 @@ 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; }
+
+ [JsonProperty("low_ar_difficult_strain_count")]
+ public double LowArDifficultStrainCount { get; set; }
+
+ [JsonProperty("hidden_difficult_strain_count")]
+ public double HiddenDifficultStrainCount { get; set; }
+
///
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
///
@@ -106,11 +112,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
-
- if (ShouldSerializeFlashlightDifficulty())
- yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
-
+ 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);
}
@@ -125,8 +131,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 1cc31cf2e4..4922002ec7 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -22,10 +22,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuDifficultyCalculator : DifficultyCalculator
{
- public const double DIFFICULTY_MULTIPLIER = 0.0668;
+ public const double DIFFICULTY_MULTIPLIER = 0.0675;
public const double SUM_POWER = 1.1;
public const double FL_SUM_POWER = 1.5;
- public override int Version => 20220902;
+ public override int Version => 20241007;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
@@ -39,21 +39,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double aimRating = Math.Sqrt(skills[0].DifficultyValue()) * DIFFICULTY_MULTIPLIER;
double aimRatingNoSliders = Math.Sqrt(skills[1].DifficultyValue()) * DIFFICULTY_MULTIPLIER;
- double speedRating = Math.Sqrt(skills[2].DifficultyValue()) * DIFFICULTY_MULTIPLIER;
- double speedNotes = ((Speed)skills[2]).RelevantNoteCount();
- double hiddenFlashlightRating = Math.Sqrt(skills[3].DifficultyValue()) * DIFFICULTY_MULTIPLIER;
+ double speedRating = Math.Sqrt(skills.OfType().First().DifficultyValue()) * DIFFICULTY_MULTIPLIER;
+ double speedNotes = skills.OfType().First().RelevantNoteCount();
- double readingLowARRating = Math.Sqrt(skills[4].DifficultyValue()) * DIFFICULTY_MULTIPLIER;
+ double flashlightRating = Math.Sqrt(skills.OfType().First().DifficultyValue()) * DIFFICULTY_MULTIPLIER;
+ double readingLowARRating = Math.Sqrt(skills.OfType().First().DifficultyValue()) * DIFFICULTY_MULTIPLIER;
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
- double flashlightRating = 0;
- double baseFlashlightPerformance = 0.0;
- if (mods.Any(h => h is OsuModFlashlight))
- {
- flashlightRating = Math.Sqrt(skills[5].DifficultyValue()) * DIFFICULTY_MULTIPLIER;
- baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
- }
+ double aimDifficultyStrainCount = skills[0].CountTopWeightedStrains();
+ double speedDifficultyStrainCount = skills.OfType().First().CountTopWeightedStrains();
+ double lowArDifficultyStrainCount = skills.OfType().First().CountTopWeightedStrains();
if (mods.Any(m => m is OsuModTouchDevice))
{
@@ -66,34 +62,36 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
aimRating *= 0.9;
speedRating = 0.0;
+ readingLowARRating *= 0.95;
flashlightRating *= 0.7;
readingLowARRating *= 0.95;
}
- double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
- double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
+ double aimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
+ double speedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
// Cognition
- double baseReadingLowARPerformance = ReadingLowAR.DifficultyToPerformance(readingLowARRating);
- double baseReadingARPerformance = baseReadingLowARPerformance;
+ double readingLowARPerformance = ReadingLowAR.DifficultyToPerformance(readingLowARRating);
+ double readingARPerformance = readingLowARPerformance;
- double baseFlashlightARPerformance = Math.Pow(Math.Pow(baseFlashlightPerformance, FL_SUM_POWER) + Math.Pow(baseReadingARPerformance, FL_SUM_POWER), 1.0 / FL_SUM_POWER);
+ double potentialFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
+ double flashlightPerformance = mods.Any(h => h is OsuModFlashlight) ? potentialFlashlightPerformance : 0;
+
+ double flashlightARPerformance = Math.Pow(Math.Pow(flashlightPerformance, FL_SUM_POWER) + Math.Pow(readingARPerformance, FL_SUM_POWER), 1.0 / FL_SUM_POWER);
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate;
- int maxCombo = beatmap.GetMaxCombo();
int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner);
- double cognitionPerformance = baseFlashlightARPerformance;
- double mechanicalPerformance = Math.Pow(Math.Pow(baseAimPerformance, SUM_POWER) + Math.Pow(baseSpeedPerformance, SUM_POWER), 1.0 / SUM_POWER);
+ double cognitionPerformance = flashlightARPerformance;
+ double mechanicalPerformance = Math.Pow(Math.Pow(aimPerformance, SUM_POWER) + Math.Pow(speedPerformance, SUM_POWER), 1.0 / SUM_POWER);
- // Limit cognition by full memorisation difficulty
- double maxHiddenFlashlightPerformance = OsuPerformanceCalculator.ComputePerfectFlashlightValue(hiddenFlashlightRating, hitCirclesCount + sliderCount);
- cognitionPerformance = OsuPerformanceCalculator.AdjustCognitionPerformance(cognitionPerformance, mechanicalPerformance, maxHiddenFlashlightPerformance);
+ // Limit cognition by full memorisation difficulty, what is assumed to be mechanicalPerformance + flashlightPerformance
+ cognitionPerformance = OsuPerformanceCalculator.AdjustCognitionPerformance(cognitionPerformance, mechanicalPerformance, potentialFlashlightPerformance);
double basePerformance = mechanicalPerformance + cognitionPerformance;
@@ -115,15 +113,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedNoteCount = speedNotes,
ReadingDifficultyLowAR = readingLowARRating,
FlashlightDifficulty = flashlightRating,
- HiddenFlashlightDifficulty = hiddenFlashlightRating,
SliderFactor = sliderFactor,
+ AimDifficultStrainCount = aimDifficultyStrainCount,
+ SpeedDifficultStrainCount = speedDifficultyStrainCount,
+ LowArDifficultStrainCount = lowArDifficultyStrainCount,
ApproachRate = IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, 1800, 1200, 450),
OverallDifficulty = (80 - hitWindowGreat) / 6,
DrainRate = drainRate,
- MaxCombo = maxCombo,
+ MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount,
SliderCount = sliderCount,
- SpinnerCount = spinnerCount,
+ SpinnerCount = spinnerCount
};
return attributes;
@@ -151,13 +151,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
new Aim(mods, true),
new Aim(mods, false),
new Speed(mods),
- new HiddenFlashlight(mods),
+ new Flashlight(mods),
new ReadingLowAR(mods),
};
- if (mods.Any(h => h is OsuModFlashlight))
- skills.Add(new Flashlight(mods));
-
return skills.ToArray();
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
index 5616ae72e4..d3867837d4 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
@@ -18,8 +18,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("accuracy")]
public double Accuracy { get; set; }
- [JsonProperty("cognition")]
- public double Cognition { get; set; }
+ [JsonProperty("flashlight")]
+ public double Flashlight { get; set; }
+
+ [JsonProperty("reading")]
+ public double Reading { get; set; }
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
@@ -32,7 +35,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return new PerformanceDisplayAttribute(nameof(Aim), "Aim", Aim);
yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed);
yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy);
- yield return new PerformanceDisplayAttribute(nameof(Cognition), "Cognition", Cognition);
+ yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading);
+ yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight);
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index b708d35db6..697ab9310d 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.14; // 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.114; // 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,16 +49,48 @@ 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;
- double power = OsuDifficultyCalculator.SUM_POWER;
if (score.Mods.Any(m => m is OsuModNoFail))
multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount);
@@ -63,33 +110,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty
effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits);
}
+ double power = OsuDifficultyCalculator.SUM_POWER;
+
double aimValue = computeAimValue(score, osuAttributes);
double speedValue = computeSpeedValue(score, osuAttributes);
double mechanicalValue = Math.Pow(Math.Pow(aimValue, power) + Math.Pow(speedValue, power), 1.0 / power);
// Cognition
- // Get HDFL value for capping reading performance
- // In theory stuff like AR13, AR13 +HD and AR-INF +HD should use this values
- // While AR-INF without HD shoud use normal flashlight values
- // Because in first case you're clicking air, while in AR-INF case you're see the notes
- // But implementing it is pretty annoying, so I left it "as is"
- double potentialHiddenFlashlightValue = computeFlashlightValue(score, osuAttributes, true);
-
double lowARValue = computeReadingLowARValue(score, osuAttributes);
double readingARValue = lowARValue;
- double flashlightValue = 0;
- if (score.Mods.Any(h => h is OsuModFlashlight))
- flashlightValue = computeFlashlightValue(score, osuAttributes);
+ double flashlightValue = computeFlashlightValue(score, osuAttributes);
// Reduce AR reading bonus if FL is present
double flPower = OsuDifficultyCalculator.FL_SUM_POWER;
- double flashlightARValue = Math.Pow(Math.Pow(flashlightValue, flPower) + Math.Pow(readingARValue, flPower), 1.0 / flPower);
+ double flashlightARValue = score.Mods.Any(h => h is OsuModFlashlight) ?
+ Math.Pow(Math.Pow(flashlightValue, flPower) + Math.Pow(readingARValue, flPower), 1.0 / flPower) : readingARValue;
double cognitionValue = flashlightARValue;
- cognitionValue = AdjustCognitionPerformance(cognitionValue, mechanicalValue, potentialHiddenFlashlightValue);
+ cognitionValue = AdjustCognitionPerformance(cognitionValue, mechanicalValue, flashlightValue);
double accuracyValue = computeAccuracyValue(score, osuAttributes);
@@ -98,12 +139,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty
(Math.Pow(Math.Pow(mechanicalValue, power) + Math.Pow(accuracyValue, power), 1.0 / power)
+ cognitionValue) * multiplier;
+ // Fancy stuff for better visual display of FL pp
+
+ // Calculate reading difficulty as there was no FL in the first place
+ double visualCognitionValue = AdjustCognitionPerformance(readingARValue, mechanicalValue, flashlightValue);
+
+ double visualFlashlightValue = cognitionValue - visualCognitionValue;
+
return new OsuPerformanceAttributes
{
Aim = aimValue,
Speed = speedValue,
Accuracy = accuracyValue,
- Cognition = cognitionValue,
+ Flashlight = visualFlashlightValue,
+ Reading = visualCognitionValue,
EffectiveMissCount = effectiveMissCount,
Total = totalValue
};
@@ -118,11 +167,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double lengthBonus = CalculateDefaultLengthBonus(totalHits);
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)
@@ -146,8 +192,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;
}
@@ -168,11 +228,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double lengthBonus = CalculateDefaultLengthBonus(totalHits);
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)
@@ -199,7 +256,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);
@@ -216,6 +273,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
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);
else
@@ -227,7 +287,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// Lots of arbitrary values from testing.
// Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
- double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83;
+ double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.92;
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3));
@@ -250,14 +310,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// This one is goes from 0.0 on delta=0 to 1.0 somewhere around delta=3.4
double deltaBonus = (1 - Math.Pow(0.95, Math.Pow(ARODDelta, 4)));
+ // Nerf delta bonus on OD lower than 10 and 9
+ if (attributes.OverallDifficulty < 10)
+ deltaBonus *= Math.Pow(attributes.OverallDifficulty / 10, 2);
+ if (attributes.OverallDifficulty < 9)
+ deltaBonus *= Math.Pow(attributes.OverallDifficulty / 9, 4);
+
accuracyValue *= 1 + visualBonus * (1 + 2 * deltaBonus);
return accuracyValue;
}
- private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes attributes, bool alwaysUseHD = false)
+ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
- double flashlightValue = Math.Pow(alwaysUseHD ? attributes.HiddenFlashlightDifficulty : attributes.FlashlightDifficulty, 2.0) * 25.0;
+ double flashlightValue = Flashlight.DifficultyToPerformance(attributes.FlashlightDifficulty);
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)
@@ -277,30 +343,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return flashlightValue;
}
- public static double ComputePerfectFlashlightValue(double flashlightDifficulty, int objectsCount)
- {
- double flashlightValue = Flashlight.DifficultyToPerformance(flashlightDifficulty);
-
- flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, objectsCount / 200.0) +
- (objectsCount > 200 ? 0.2 * Math.Min(1.0, (objectsCount - 200) / 200.0) : 0.0);
-
- return flashlightValue;
- }
-
private double computeReadingLowARValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
- double rawReading = attributes.ReadingDifficultyLowAR;
-
- if (score.Mods.Any(m => m is OsuModTouchDevice))
- rawReading = Math.Pow(rawReading, 0.8);
-
- double readingValue = ReadingLowAR.DifficultyToPerformance(rawReading);
+ double readingValue = ReadingLowAR.DifficultyToPerformance(attributes.ReadingDifficultyLowAR);
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)
- readingValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875));
-
- readingValue *= getComboScalingFactor(attributes);
+ readingValue *= calculateMissPenalty(effectiveMissCount, attributes.LowArDifficultStrainCount);
// Scale the reading value with accuracy _harshly_. Additional note: it would have it's own curve in Statistical Accuracy rework.
readingValue *= accuracy * accuracy;
@@ -309,31 +358,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return readingValue;
}
- private double calculateEffectiveMissCount(OsuDifficultyAttributes attributes)
+
+ // Limits reading difficulty by the difficulty of full-memorisation (assumed to be mechanicalPerformance + flashlightPerformance + 25)
+ // Desmos graph assuming that x = cognitionPerformance, while y = mechanicalPerformance + flaslightPerformance
+ // https://www.desmos.com/3d/vjygrxtkqs
+ public static double AdjustCognitionPerformance(double cognitionPerformance, double mechanicalPerformance, double flashlightPerformance)
{
- // 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);
- }
- 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;
-
- // Adjusts cognition performance accounting for full-memory
- public static double AdjustCognitionPerformance(double cognitionPerformance, double mechanicalPerformance, double flaslightPerformance)
- {
- // Assuming that less than 25 mechanical pp is not worthy for memory
- double capPerformance = mechanicalPerformance + flaslightPerformance + 25;
+ // Assuming that less than 25 pp is not worthy for memory
+ double capPerformance = mechanicalPerformance + flashlightPerformance + 25;
double ratio = cognitionPerformance / capPerformance;
if (ratio > 50) return capPerformance;
@@ -342,7 +374,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return ratio * capPerformance;
}
+ // 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 static double softmin(double a, double b, double power = Math.E) => a * b / Math.Log(Math.Pow(power, a) + Math.Pow(power, b), power);
+
private static double logistic(double x) => 1 / (1 + Math.Exp(-x));
+
+ 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 2b17451829..249202bf1d 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
@@ -22,7 +22,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;
@@ -86,16 +89,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
///
public double? Angle { get; private set; }
+ ///
+ /// Signed version of the Angle.
+ /// Potentially should be used for more accurate angle bonuses
+ /// Ranges from -PI to PI
+ ///
+ public double? AngleSigned { get; private set; }
+
///
/// Retrieves the full hit window for a Great .
///
public double HitWindowGreat { get; private set; }
- ///
- /// Density of the object for given preempt. Saved for optimization, density calculation is expensive.
- ///
- public double Density { get; private set; }
-
///
/// Predictabiliy of the angle. Gives high values only in exceptionally repetitive patterns.
///
@@ -130,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
Preempt = BaseObject.TimePreempt / clockRate;
// 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)
{
@@ -146,8 +151,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
AnglePredictability = CalculateAnglePredictability();
(ReadingObjects, OverlapValues) = getReadingObjects();
-
- Density = ReadingEvaluator.EvaluateDensityOf(this);
}
private (IList, IDictionary) getReadingObjects()
@@ -165,7 +168,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
var readingObjects = new List(visibleObjects.Count);
OverlapValues = new Dictionary();
- //foreach (var loopObj in visibleObjects)
for (int loopIndex = 0; loopIndex < visibleObjects.Count; loopIndex++)
{
var loopObj = visibleObjects[loopIndex];
@@ -320,7 +322,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
var visibleObjects = new List();
- for (int i = 0; i < current.Count; i++)
+ for (int i = 0; i < current.Index; i++)
{
OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i);
@@ -389,6 +391,25 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
angleDifference -= prevAngleAdjust;
+ // Explicit nerf for same pattern repeating
+ OsuDifficultyHitObject? prevObj3 = (OsuDifficultyHitObject?)Previous(3);
+ OsuDifficultyHitObject? prevObj4 = (OsuDifficultyHitObject?)Previous(4);
+ OsuDifficultyHitObject? prevObj5 = (OsuDifficultyHitObject?)Previous(5);
+
+ // 3-3 repeat
+ double similarity3_1 = getGeneralSimilarity(this, prevObj2);
+ double similarity3_2 = getGeneralSimilarity(prevObj0, prevObj3);
+ double similarity3_3 = getGeneralSimilarity(prevObj1, prevObj4);
+
+ double similarity3_total = similarity3_1 * similarity3_2 * similarity3_3;
+
+ // 4-4 repeat
+ double similarity4_1 = getGeneralSimilarity(this, prevObj3);
+ double similarity4_2 = getGeneralSimilarity(prevObj0, prevObj4);
+ double similarity4_3 = getGeneralSimilarity(prevObj1, prevObj5);
+
+ double similarity4_total = similarity4_1 * similarity4_2 * similarity4_3;
+
// Bandaid to fix Rubik's Cube +EZ
double wideness = 0;
if (Angle!.Value > Math.PI * 0.5)
@@ -405,12 +426,36 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// Angle difference more than 15 degrees gets no penalty
double adjustedAngleDifference = Math.Min(Math.PI / 12, angleDifference);
- return rhythmFactor * Math.Cos(Math.Min(Math.PI / 2, 6 * adjustedAngleDifference));
+ double predictability = Math.Cos(Math.Min(Math.PI / 2, 6 * adjustedAngleDifference)) * rhythmFactor;
+
+ // Punish for big pattern similarity
+ return 1 - (1 - predictability) * (1 - Math.Max(similarity3_total, similarity4_total));
+ }
+
+ private double getGeneralSimilarity(OsuDifficultyHitObject? o1, OsuDifficultyHitObject? o2)
+ {
+ if (o1 == null || o2 == null)
+ return 1;
+
+ if (o1.AngleSigned == null || o2.AngleSigned == null)
+ return o1.AngleSigned == o2.AngleSigned ? 1 : 0;
+
+
+ double timeSimilarity = 1 - getTimeDifference(o1.StrainTime, o2.StrainTime);
+
+ double angleDelta = Math.Abs((double)o1.AngleSigned - (double)o2.AngleSigned);
+ angleDelta = Math.Clamp(angleDelta - 0.1, 0, 0.15);
+ double angleSimilarity = 1 - angleDelta / 0.15;
+
+ double distanceDelta = Math.Abs(o1.LazyJumpDistance - o2.LazyJumpDistance) / NORMALISED_RADIUS;
+ double distanceSimilarity = 1 / Math.Max(1, distanceDelta);
+
+ return timeSimilarity * angleSimilarity * distanceSimilarity;
}
public double OpacityAt(double time, bool hidden)
{
- var baseObject = BaseObject; // Optimization
+ var baseObject = BaseObject;
if (time > baseObject.StartTime)
{
@@ -439,6 +484,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)
@@ -446,7 +509,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
@@ -470,8 +533,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.
@@ -509,7 +572,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
float dot = Vector2.Dot(v1, v2);
float det = v1.X * v2.Y - v1.Y * v2.X;
- Angle = Math.Abs(Math.Atan2(det, dot));
+ AngleSigned = Math.Atan2(det, dot);
+ Angle = Math.Abs(AngleSigned.Value);
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
index 3f6b22bbb1..e99efdf50e 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
@@ -1,7 +1,6 @@
// 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.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
@@ -21,21 +20,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private readonly bool withSliders;
- private double currentStrain;
+ protected double CurrentStrain;
+ protected double SkillMultiplier => 25.5;
- private double skillMultiplier => 23.55;
- private double strainDecayBase => 0.15;
-
- private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
-
- protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime);
+ protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => CurrentStrain * StrainDecay(time - current.Previous(0).StartTime);
protected override double StrainValueAt(DifficultyHitObject current)
{
- currentStrain *= strainDecay(current.DeltaTime);
- currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier;
+ CurrentStrain *= StrainDecay(current.DeltaTime);
+ CurrentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * SkillMultiplier;
- return currentStrain;
+ return CurrentStrain;
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs
index 9fafeacb9c..affecbc3bf 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs
@@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
public class Flashlight : StrainSkill
{
private readonly bool hasHiddenMod;
- protected virtual bool HasHiddenMod => hasHiddenMod;
public Flashlight(Mod[] mods)
: base(mods)
@@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
hasHiddenMod = mods.Any(m => m is OsuModHidden);
}
- private double skillMultiplier => 0.052;
+ private double skillMultiplier => 0.053;
private double strainDecayBase => 0.15;
private double currentStrain;
@@ -37,23 +36,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current)
{
currentStrain *= strainDecay(current.DeltaTime);
- currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, HasHiddenMod) * skillMultiplier;
+ currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier;
return currentStrain;
}
- public override double DifficultyValue() => GetCurrentStrainPeaks().Sum() * OsuStrainSkill.DEFAULT_DIFFICULTY_MULTIPLIER;
+ public override double DifficultyValue() => GetCurrentStrainPeaks().Sum();
- public static double DifficultyToPerformance(double difficulty) => Math.Pow(difficulty, 2) * 25.0;
- }
-
- public class HiddenFlashlight : Flashlight
- {
- protected override bool HasHiddenMod => true;
-
- public HiddenFlashlight(Mod[] mods)
- : base(mods)
- {
- }
+ public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2);
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs
index 93c21d33ad..367f3bee18 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs
@@ -12,12 +12,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
public abstract class OsuStrainSkill : StrainSkill
{
- ///
- /// The default multiplier applied by to the final difficulty value after all other calculations.
- /// May be overridden via .
- ///
- public const double DEFAULT_DIFFICULTY_MULTIPLIER = 1.06;
-
///
/// The number of sections with the highest strains, which the peak strain reductions will apply to.
/// This is done in order to decrease their impact on the overall difficulty of the map for this skill.
@@ -29,10 +23,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
///
protected virtual double ReducedStrainBaseline => 0.75;
- ///
- /// The final multiplier to be applied to after all other calculations.
- ///
- protected virtual double DifficultyMultiplier => DEFAULT_DIFFICULTY_MULTIPLIER;
+ protected virtual double StrainDecayBase => 0.15;
+
+ protected double StrainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000);
protected OsuStrainSkill(Mod[] mods)
: base(mods)
@@ -65,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
weight *= DecayWeight;
}
- return difficulty * DifficultyMultiplier;
+ return difficulty;
}
///
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs
index 4835d5d817..3a60f5843c 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs
@@ -9,14 +9,13 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
+using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
-
- public class ReadingLowAR : Skill
+ public class ReadingLowAR : StrainSkill
{
- private readonly List difficulties = new List();
- private double skillMultiplier => 1.26;
+ private double skillMultiplier => 1.22;
private double aimComponentMultiplier => 0.4;
public ReadingLowAR(Mod[] mods)
@@ -39,18 +38,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
double totalDensityDifficulty = (currentDensityAimStrain + densityReadingDifficulty) * skillMultiplier;
- difficulties.Add(totalDensityDifficulty);
+ ObjectStrains.Add(totalDensityDifficulty);
+
+ if (current.Index == 0)
+ CurrentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength;
+
+ while (current.StartTime > CurrentSectionEnd)
+ {
+ StrainPeaks.Add(CurrentSectionPeak);
+ CurrentSectionPeak = 0;
+ CurrentSectionEnd += SectionLength;
+ }
+
+ CurrentSectionPeak = Math.Max(totalDensityDifficulty, CurrentSectionPeak);
}
private double reducedNoteCount => 5;
private double reducedNoteBaseline => 0.7;
public override double DifficultyValue()
{
- double difficulty = 0;
-
// Sections with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
- var peaks = difficulties.Where(p => p > 0);
+ var peaks = ObjectStrains.Where(p => p > 0);
List values = peaks.OrderByDescending(d => d).ToList();
@@ -62,6 +71,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
values = values.OrderByDescending(d => d).ToList();
+ double difficulty = 0;
+
// Difficulty is the weighted sum of the highest strains from every section.
// We're sorting from highest to lowest strain.
for (int i = 0; i < values.Count; i++)
@@ -74,5 +85,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
public static double DifficultyToPerformance(double difficulty) => Math.Max(
Math.Max(Math.Pow(difficulty, 1.5) * 20, Math.Pow(difficulty, 2) * 17.0),
Math.Max(Math.Pow(difficulty, 3) * 10.5, Math.Pow(difficulty, 4) * 6.00));
+
+ protected override double StrainValueAt(DifficultyHitObject current)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected override double CalculateInitialStrain(double time, DifficultyHitObject current)
+ {
+ throw new NotImplementedException();
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
index 40aac013ab..baccf8766d 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
@@ -16,51 +15,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
///
public class Speed : OsuStrainSkill
{
- private double skillMultiplier => 1375;
- private double strainDecayBase => 0.3;
+ protected double SkillMultiplier => 1.42;
+ protected override double StrainDecayBase => 0.3;
- private double currentStrain;
- private double currentRhythm;
+ protected double CurrentStrain;
+ protected double CurrentRhythm;
protected override int ReducedSectionCount => 5;
- protected override double DifficultyMultiplier => 1.04;
-
- private readonly List objectStrains = new List();
public Speed(Mod[] mods)
: base(mods)
{
}
- private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
-
- protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime);
+ protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (CurrentStrain * CurrentRhythm) * StrainDecay(time - current.Previous(0).StartTime);
protected override double StrainValueAt(DifficultyHitObject current)
{
- currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime);
- currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
+ OsuDifficultyHitObject currODHO = (OsuDifficultyHitObject)current;
- currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
+ CurrentStrain *= StrainDecay(currODHO.StrainTime);
+ CurrentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * SkillMultiplier;
- double totalStrain = currentStrain * currentRhythm;
-
- objectStrains.Add(totalStrain);
+ CurrentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
+ double totalStrain = CurrentStrain * CurrentRhythm;
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/HitCircles/Components/HitCircleOverlapMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs
index fe335a048d..8ed9d0476a 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs
@@ -7,7 +7,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Shapes;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Rulesets.Objects.Types;
@@ -16,7 +15,6 @@ using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Screens.Edit;
using osu.Game.Skinning;
using osuTK;
-using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
{
@@ -48,13 +46,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
- new Circle
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- RelativeSizeAxes = Axes.Both,
- Colour = Color4.White,
- },
ring = new RingPiece
{
BorderThickness = 4,
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs
index 20ad99baa2..78a0e36dc2 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs
@@ -9,7 +9,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
{
- public partial class HitCirclePlacementBlueprint : PlacementBlueprint
+ public partial class HitCirclePlacementBlueprint : HitObjectPlacementBlueprint
{
public new HitCircle HitObject => (HitCircle)base.HitObject;
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs
index 0608f8c929..fd2bbe9916 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs
@@ -1,8 +1,11 @@
// 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.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
+using osu.Game.Configuration;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
@@ -16,6 +19,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
protected readonly HitCirclePiece CirclePiece;
private readonly HitCircleOverlapMarker marker;
+ private readonly Bindable showHitMarkers = new Bindable();
public HitCircleSelectionBlueprint(HitCircle circle)
: base(circle)
@@ -27,12 +31,32 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
};
}
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ config.BindWith(OsuSetting.EditorShowHitMarkers, showHitMarkers);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ showHitMarkers.BindValueChanged(_ =>
+ {
+ if (!showHitMarkers.Value)
+ DrawableObject.RestoreHitAnimations();
+ });
+ }
+
protected override void Update()
{
base.Update();
CirclePiece.UpdateFrom(HitObject);
marker.UpdateFrom(HitObject);
+
+ if (showHitMarkers.Value)
+ DrawableObject.SuppressHitAnimations();
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.HitArea.ReceivePositionalInputAt(screenSpacePos);
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnection.cs
similarity index 51%
rename from osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs
rename to osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnection.cs
index 9b3d8fc7a7..5706ed4baf 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnection.cs
@@ -4,10 +4,7 @@
#nullable disable
using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Lines;
-using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
@@ -15,36 +12,21 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
///
- /// A visualisation of the line between two s.
+ /// A visualisation of the lines between s.
///
- /// The type of which this visualises.
- public partial class PathControlPointConnectionPiece : CompositeDrawable where T : OsuHitObject, IHasPath
+ /// The type of which this visualises.
+ public partial class PathControlPointConnection : SmoothPath where T : OsuHitObject, IHasPath
{
- public readonly PathControlPoint ControlPoint;
-
- private readonly Path path;
private readonly T hitObject;
- public int ControlPointIndex { get; set; }
private IBindable hitObjectPosition;
private IBindable pathVersion;
private IBindable stackHeight;
- public PathControlPointConnectionPiece(T hitObject, int controlPointIndex)
+ public PathControlPointConnection(T hitObject)
{
this.hitObject = hitObject;
- ControlPointIndex = controlPointIndex;
-
- Origin = Anchor.Centre;
- AutoSizeAxes = Axes.Both;
-
- ControlPoint = hitObject.Path.ControlPoints[controlPointIndex];
-
- InternalChild = path = new SmoothPath
- {
- Anchor = Anchor.Centre,
- PathRadius = 1
- };
+ PathRadius = 1;
}
protected override void LoadComplete()
@@ -68,18 +50,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
///
private void updateConnectingPath()
{
- Position = hitObject.StackedPosition + ControlPoint.Position;
+ Position = hitObject.StackedPosition;
- path.ClearVertices();
+ ClearVertices();
- int nextIndex = ControlPointIndex + 1;
- if (nextIndex == 0 || nextIndex >= hitObject.Path.ControlPoints.Count)
- return;
+ foreach (var controlPoint in hitObject.Path.ControlPoints)
+ AddVertex(controlPoint.Position);
- path.AddVertex(Vector2.Zero);
- path.AddVertex(hitObject.Path.ControlPoints[nextIndex].Position - ControlPoint.Position);
-
- path.OriginPosition = path.PositionInBoundingBox(Vector2.Zero);
+ OriginPosition = PositionInBoundingBox(Vector2.Zero);
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
index c6e05d3ca3..3337e99215 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
@@ -8,9 +8,9 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
@@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public readonly PathControlPoint ControlPoint;
private readonly T hitObject;
- private readonly Container marker;
+ private readonly FastCircle circle;
private readonly Drawable markerRing;
[Resolved]
@@ -60,38 +60,22 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
Origin = Anchor.Centre;
AutoSizeAxes = Axes.Both;
- InternalChildren = new Drawable[]
+ InternalChildren = new[]
{
- marker = new Container
+ circle = new FastCircle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- AutoSizeAxes = Axes.Both,
- Children = new[]
- {
- new Circle
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(20),
- },
- markerRing = new CircularContainer
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(28),
- Masking = true,
- BorderThickness = 2,
- BorderColour = Color4.White,
- Alpha = 0,
- Child = new Box
- {
- RelativeSizeAxes = Axes.Both,
- Alpha = 0,
- AlwaysPresent = true
- }
- }
- }
+ Size = new Vector2(20),
+ },
+ markerRing = new CircularProgress
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(28),
+ Alpha = 0,
+ InnerRadius = 0.1f,
+ Progress = 1
}
};
}
@@ -115,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
// The connecting path is excluded from positional input
- public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => marker.ReceivePositionalInputAt(screenSpacePos);
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => circle.ReceivePositionalInputAt(screenSpacePos);
protected override bool OnHover(HoverEvent e)
{
@@ -209,8 +193,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (IsHovered || IsSelected.Value)
colour = colour.Lighten(1);
- marker.Colour = colour;
- marker.Scale = new Vector2(hitObject.Scale);
+ Colour = colour;
+ Scale = new Vector2(hitObject.Scale);
}
private Color4 getColourFromNodeType()
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 b2d1709531..f114516300 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
@@ -34,10 +34,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public partial class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu
where T : OsuHitObject, IHasPath
{
- public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside of the playfield.
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside the playfield.
internal readonly Container> Pieces;
- internal readonly Container> Connections;
private readonly IBindableList controlPoints = new BindableList();
private readonly T hitObject;
@@ -63,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
InternalChildren = new Drawable[]
{
- Connections = new Container> { RelativeSizeAxes = Axes.Both },
+ new PathControlPointConnection(hitObject),
Pieces = new Container> { RelativeSizeAxes = Axes.Both }
};
}
@@ -78,6 +77,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
controlPoints.BindTo(hitObject.Path.ControlPoints);
}
+ // Generally all the control points are within the visible area all the time.
+ public override bool UpdateSubTreeMasking() => true;
+
///
/// Handles correction of invalid path types.
///
@@ -105,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;
@@ -176,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)
{
@@ -185,17 +187,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
case NotifyCollectionChangedAction.Add:
Debug.Assert(e.NewItems != null);
- // If inserting in the path (not appending),
- // update indices of existing connections after insert location
- if (e.NewStartingIndex < Pieces.Count)
- {
- foreach (var connection in Connections)
- {
- if (connection.ControlPointIndex >= e.NewStartingIndex)
- connection.ControlPointIndex += e.NewItems.Count;
- }
- }
-
for (int i = 0; i < e.NewItems.Count; i++)
{
var point = (PathControlPoint)e.NewItems[i];
@@ -205,12 +196,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (allowSelection)
d.RequestSelection = selectionRequested;
+ d.ControlPoint.Changed += controlPointChanged;
d.DragStarted = DragStarted;
d.DragInProgress = DragInProgress;
d.DragEnded = DragEnded;
}));
-
- Connections.Add(new PathControlPointConnectionPiece(hitObject, e.NewStartingIndex + i));
}
break;
@@ -220,27 +210,18 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
foreach (var point in e.OldItems.Cast())
{
+ point.Changed -= controlPointChanged;
+
foreach (var piece in Pieces.Where(p => p.ControlPoint == point).ToArray())
piece.RemoveAndDisposeImmediately();
- foreach (var connection in Connections.Where(c => c.ControlPoint == point).ToArray())
- connection.RemoveAndDisposeImmediately();
- }
-
- // If removing before the end of the path,
- // update indices of connections after remove location
- if (e.OldStartingIndex < Pieces.Count)
- {
- foreach (var connection in Connections)
- {
- if (connection.ControlPointIndex >= e.OldStartingIndex)
- connection.ControlPointIndex -= e.OldItems.Count;
- }
}
break;
}
}
+ private void controlPointChanged() => updateCurveMenuItems();
+
protected override bool OnClick(ClickEvent e)
{
if (Pieces.Any(piece => piece.IsHovered))
@@ -269,6 +250,94 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
}
+ // ReSharper disable once StaticMemberInGenericType
+ private static readonly PathType?[] path_types =
+ [
+ PathType.LINEAR,
+ PathType.BEZIER,
+ PathType.PERFECT_CURVE,
+ PathType.BSpline(4),
+ null,
+ ];
+
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ if (e.Repeat)
+ return false;
+
+ switch (e.Key)
+ {
+ case Key.Tab:
+ {
+ var selectedPieces = Pieces.Where(p => p.IsSelected.Value).ToArray();
+ if (selectedPieces.Length != 1)
+ return false;
+
+ PathControlPointPiece selectedPiece = selectedPieces.Single();
+ PathControlPoint selectedPoint = selectedPiece.ControlPoint;
+
+ PathType?[] validTypes = path_types;
+
+ if (selectedPoint == controlPoints[0])
+ validTypes = validTypes.Where(t => t != null).ToArray();
+
+ int currentTypeIndex = Array.IndexOf(validTypes, selectedPoint.Type);
+
+ if (currentTypeIndex < 0 && e.ShiftPressed)
+ currentTypeIndex = 0;
+
+ changeHandler?.BeginChange();
+
+ do
+ {
+ currentTypeIndex = (validTypes.Length + currentTypeIndex + (e.ShiftPressed ? -1 : 1)) % validTypes.Length;
+
+ updatePathTypeOfSelectedPieces(validTypes[currentTypeIndex]);
+ } while (selectedPoint.Type != validTypes[currentTypeIndex]);
+
+ changeHandler?.EndChange();
+
+ return true;
+ }
+
+ case Key.Number1:
+ case Key.Number2:
+ case Key.Number3:
+ case Key.Number4:
+ case Key.Number5:
+ {
+ if (!e.AltPressed)
+ return false;
+
+ // If no pieces are selected, we can't change the path type.
+ if (Pieces.All(p => !p.IsSelected.Value))
+ return false;
+
+ PathType? type = path_types[e.Key - Key.Number1];
+
+ // The first control point can never be inherit type
+ if (Pieces[0].IsSelected.Value && type == null)
+ return false;
+
+ updatePathTypeOfSelectedPieces(type);
+ return true;
+ }
+
+ default:
+ return false;
+ }
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+ foreach (var p in Pieces)
+ p.ControlPoint.Changed -= controlPointChanged;
+
+ if (draggedControlPointIndex >= 0)
+ DragEnded();
+ }
+
private void selectionRequested(PathControlPointPiece piece, MouseButtonEvent e)
{
if (e.Button == MouseButton.Left && inputManager.CurrentState.Keyboard.ControlPressed)
@@ -278,30 +347,45 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
///
- /// Attempts to set the given control point piece to the given path type.
- /// If that would fail, try to change the path such that it instead succeeds
+ /// Attempts to set all selected control point pieces to the given path type.
+ /// If that fails, try to change the path such that it instead succeeds
/// in a UX-friendly way.
///
- /// The control point piece that we want to change the path type of.
/// The path type we want to assign to the given control point piece.
- private void updatePathType(PathControlPointPiece piece, PathType? type)
+ private void updatePathTypeOfSelectedPieces(PathType? type)
{
- var pointsInSegment = hitObject.Path.PointsInSegment(piece.ControlPoint);
- int indexInSegment = pointsInSegment.IndexOf(piece.ControlPoint);
+ changeHandler?.BeginChange();
- if (type?.Type == SplineType.PerfectCurve)
+ double originalDistance = hitObject.Path.Distance;
+
+ foreach (var p in Pieces.Where(p => p.IsSelected.Value))
{
- // Can't always create a circular arc out of 4 or more points,
- // so we split the segment into one 3-point circular arc segment
- // and one segment of the previous type.
- int thirdPointIndex = indexInSegment + 2;
+ List pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint);
+ int indexInSegment = pointsInSegment.IndexOf(p.ControlPoint);
- if (pointsInSegment.Count > thirdPointIndex + 1)
- pointsInSegment[thirdPointIndex].Type = pointsInSegment[0].Type;
+ if (type?.Type == SplineType.PerfectCurve)
+ {
+ // Can't always create a circular arc out of 4 or more points,
+ // so we split the segment into one 3-point circular arc segment
+ // and one segment of the previous type.
+ int thirdPointIndex = indexInSegment + 2;
+
+ if (pointsInSegment.Count > thirdPointIndex + 1)
+ pointsInSegment[thirdPointIndex].Type = pointsInSegment[0].Type;
+ }
+
+ hitObject.Path.ExpectedDistance.Value = null;
+ p.ControlPoint.Type = type;
}
- hitObject.Path.ExpectedDistance.Value = null;
- piece.ControlPoint.Type = type;
+ EnsureValidPathTypes();
+
+ if (hitObject.Path.Distance < originalDistance)
+ hitObject.SnapTo(distanceSnapProvider);
+ else
+ hitObject.Path.ExpectedDistance.Value = originalDistance;
+
+ changeHandler?.EndChange();
}
[Resolved(CanBeNull = true)]
@@ -311,9 +395,11 @@ 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