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 fc6e231c4b..cb45447ed5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,7 +88,7 @@ jobs:
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
- name: Upload Test Results
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml
index c2eeff20df..4297a88e89 100644
--- a/.github/workflows/diffcalc.yml
+++ b/.github/workflows/diffcalc.yml
@@ -103,26 +103,11 @@ permissions:
env:
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
+defaults:
+ run:
+ shell: bash -euo pipefail {0}
+
jobs:
- master-environment:
- name: Save master environment
- runs-on: ubuntu-latest
- outputs:
- HEAD: ${{ steps.get-head.outputs.HEAD }}
- steps:
- - name: Checkout osu
- uses: actions/checkout@v4
- with:
- ref: master
- sparse-checkout: |
- README.md
-
- - name: Get HEAD ref
- id: get-head
- run: |
- ref=$(git log -1 --format='%H')
- echo "HEAD=https://github.com/${{ github.repository }}/commit/${ref}" >> "${GITHUB_OUTPUT}"
-
check-permissions:
name: Check permissions
runs-on: ubuntu-latest
@@ -138,9 +123,23 @@ 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: [ master-environment, check-permissions ]
+ needs: check-permissions
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
steps:
@@ -153,249 +152,34 @@ 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: [ master-environment, 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 master environment
- run: |
- sed -i "s;^OSU_A=.*$;OSU_A=${{ needs.master-environment.outputs.HEAD }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
-
- - 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}"
- echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
-
- - name: Restore cache
- id: restore-cache
- uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
- with:
- path: ${{ steps.query.outputs.DATA_PKG }}
- key: ${{ steps.query.outputs.DATA_NAME }}
-
- - name: Download
- if: steps.restore-cache.outputs.cache-hit != 'true'
- run: |
- wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
-
- - name: Extract
- run: |
- tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
- 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}"
- echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
-
- - name: Restore cache
- id: restore-cache
- uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
- with:
- path: ${{ steps.query.outputs.DATA_PKG }}
- key: ${{ steps.query.outputs.DATA_NAME }}
-
- - name: Download
- if: steps.restore-cache.outputs.cache-hit != 'true'
- run: |
- wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
-
- - name: Extract
- run: |
- tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
- 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 --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 "${{ 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 --volumes
-
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: 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 }}
@@ -404,7 +188,7 @@ jobs:
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/osu.Desktop/NVAPI.cs b/osu.Desktop/NVAPI.cs
index 0b09613ba0..fd372cddc5 100644
--- a/osu.Desktop/NVAPI.cs
+++ b/osu.Desktop/NVAPI.cs
@@ -141,12 +141,12 @@ namespace osu.Desktop
// Make sure that this is a laptop.
IntPtr[] gpus = new IntPtr[64];
- if (checkError(EnumPhysicalGPUs(gpus, out int gpuCount)))
+ if (checkError(EnumPhysicalGPUs(gpus, out int gpuCount), nameof(EnumPhysicalGPUs)))
return false;
for (int i = 0; i < gpuCount; i++)
{
- if (checkError(GetSystemType(gpus[i], out var type)))
+ if (checkError(GetSystemType(gpus[i], out var type), nameof(GetSystemType)))
return false;
if (type == NvSystemType.LAPTOP)
@@ -182,7 +182,7 @@ namespace osu.Desktop
bool success = setSetting(NvSettingID.OGL_THREAD_CONTROL_ID, (uint)value);
- Logger.Log(success ? $"Threaded optimizations set to \"{value}\"!" : "Threaded optimizations set failed!");
+ Logger.Log(success ? $"[NVAPI] Threaded optimizations set to \"{value}\"!" : "[NVAPI] Threaded optimizations set failed!");
}
}
@@ -205,7 +205,7 @@ namespace osu.Desktop
uint numApps = profile.NumOfApps;
- if (checkError(EnumApplications(sessionHandle, profileHandle, 0, ref numApps, applications)))
+ if (checkError(EnumApplications(sessionHandle, profileHandle, 0, ref numApps, applications), nameof(EnumApplications)))
return false;
for (uint i = 0; i < numApps; i++)
@@ -236,10 +236,10 @@ namespace osu.Desktop
isApplicationSpecific = true;
- if (checkError(FindApplicationByName(sessionHandle, osu_filename, out profileHandle, ref application)))
+ if (checkError(FindApplicationByName(sessionHandle, osu_filename, out profileHandle, ref application), nameof(FindApplicationByName)))
{
isApplicationSpecific = false;
- if (checkError(GetCurrentGlobalProfile(sessionHandle, out profileHandle)))
+ if (checkError(GetCurrentGlobalProfile(sessionHandle, out profileHandle), nameof(GetCurrentGlobalProfile)))
return false;
}
@@ -258,12 +258,10 @@ namespace osu.Desktop
Version = NvProfile.Stride,
IsPredefined = 0,
ProfileName = PROFILE_NAME,
- GPUSupport = new uint[32]
+ GpuSupport = NvDrsGpuSupport.Geforce
};
- newProfile.GPUSupport[0] = 1;
-
- if (checkError(CreateProfile(sessionHandle, ref newProfile, out profileHandle)))
+ if (checkError(CreateProfile(sessionHandle, ref newProfile, out profileHandle), nameof(CreateProfile)))
return false;
return true;
@@ -284,7 +282,7 @@ namespace osu.Desktop
SettingID = settingId
};
- if (checkError(GetSetting(sessionHandle, profileHandle, settingId, ref setting)))
+ if (checkError(GetSetting(sessionHandle, profileHandle, settingId, ref setting), nameof(GetSetting)))
return false;
return true;
@@ -313,7 +311,7 @@ namespace osu.Desktop
};
// Set the thread state
- if (checkError(SetSetting(sessionHandle, profileHandle, ref newSetting)))
+ if (checkError(SetSetting(sessionHandle, profileHandle, ref newSetting), nameof(SetSetting)))
return false;
// Get the profile (needed to check app count)
@@ -321,7 +319,7 @@ namespace osu.Desktop
{
Version = NvProfile.Stride
};
- if (checkError(GetProfileInfo(sessionHandle, profileHandle, ref profile)))
+ if (checkError(GetProfileInfo(sessionHandle, profileHandle, ref profile), nameof(GetProfileInfo)))
return false;
if (!containsApplication(profileHandle, profile, out application))
@@ -332,12 +330,12 @@ namespace osu.Desktop
application.AppName = osu_filename;
application.UserFriendlyName = APPLICATION_NAME;
- if (checkError(CreateApplication(sessionHandle, profileHandle, ref application)))
+ if (checkError(CreateApplication(sessionHandle, profileHandle, ref application), nameof(CreateApplication)))
return false;
}
// Save!
- return !checkError(SaveSettings(sessionHandle));
+ return !checkError(SaveSettings(sessionHandle), nameof(SaveSettings));
}
///
@@ -346,20 +344,25 @@ namespace osu.Desktop
/// If the operation succeeded.
private static bool createSession()
{
- if (checkError(CreateSession(out sessionHandle)))
+ if (checkError(CreateSession(out sessionHandle), nameof(CreateSession)))
return false;
// Load settings into session
- if (checkError(LoadSettings(sessionHandle)))
+ if (checkError(LoadSettings(sessionHandle), nameof(LoadSettings)))
return false;
return true;
}
- private static bool checkError(NvStatus status)
+ private static bool checkError(NvStatus status, string caller)
{
Status = status;
- return status != NvStatus.OK;
+
+ bool hasError = status != NvStatus.OK;
+ if (hasError)
+ Logger.Log($"[NVAPI] {caller} call failed with status code {status}");
+
+ return hasError;
}
static NVAPI()
@@ -458,9 +461,7 @@ namespace osu.Desktop
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = NVAPI.UNICODE_STRING_MAX)]
public string ProfileName;
- [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
- public uint[] GPUSupport;
-
+ public NvDrsGpuSupport GpuSupport;
public uint IsPredefined;
public uint NumOfApps;
public uint NumOfSettings;
@@ -606,6 +607,7 @@ namespace osu.Desktop
SYNC_NOT_ACTIVE = -194, // The requested action cannot be performed without Sync being enabled.
SYNC_MASTER_NOT_FOUND = -195, // The requested action cannot be performed without Sync Master being enabled.
INVALID_SYNC_TOPOLOGY = -196, // Invalid displays passed in the NV_GSYNC_DISPLAY pointer.
+
ECID_SIGN_ALGO_UNSUPPORTED = -197, // The specified signing algorithm is not supported. Either an incorrect value was entered or the current installed driver/hardware does not support the input value.
ECID_KEY_VERIFICATION_FAILED = -198, // The encrypted public key verification has failed.
FIRMWARE_OUT_OF_DATE = -199, // The device's firmware is out of date.
@@ -744,4 +746,12 @@ namespace osu.Desktop
OGL_THREAD_CONTROL_NUM_VALUES = 2,
OGL_THREAD_CONTROL_DEFAULT = 0
}
+
+ [Flags]
+ internal enum NvDrsGpuSupport : uint
+ {
+ Geforce = 1 << 0,
+ Quadro = 1 << 1,
+ Nvs = 1 << 2
+ }
}
diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index 904f5edf2b..2cdbaddf72 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -26,7 +26,7 @@
-
+
diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
index a24fcaad8d..bb4261ea13 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
@@ -5,6 +5,7 @@ using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
@@ -73,7 +74,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
// 0.0 +--------+-+---------------> 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/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs
index 758c8dd347..71618a4bc3 100644
--- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs
+++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs
@@ -54,7 +54,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
},
columnBackgrounds = new ColumnFlow(stageDefinition)
{
- RelativeSizeAxes = Axes.Y
+ RelativeSizeAxes = Axes.Y,
+ Masking = false,
},
new HitTargetInsetContainer
{
@@ -126,8 +127,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
},
new Container
{
+ X = isLastColumn ? -0.16f : 0,
Anchor = Anchor.TopRight,
- Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.Y,
Width = rightLineWidth,
Scale = new Vector2(0.740f, 1),
diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
index f444448797..5614a13a48 100644
--- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
+++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs
@@ -28,6 +28,12 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly FillFlowContainer> columns;
private readonly StageDefinition stageDefinition;
+ public new bool Masking
+ {
+ get => base.Masking;
+ set => base.Masking = value;
+ }
+
public ColumnFlow(StageDefinition stageDefinition)
{
this.stageDefinition = stageDefinition;
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs
index 3c8358b4cd..0e36c1dc45 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs
@@ -8,6 +8,7 @@ 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;
@@ -24,38 +25,57 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
[Test]
- public void TestTouchInputAfterTouchingComposeArea()
+ public void TestTouchInputPlaceHitCircleDirectly()
{
AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle")));
- // this input is just for interacting with compose area
- AddStep("tap playfield", () => tap(this.ChildrenOfType().Single()));
-
- AddStep("move current time", () => InputManager.Key(Key.Right));
-
- AddStep("tap to place circle", () => tap(this.ChildrenOfType().Single().ToScreenSpace(new Vector2(10, 10))));
+ 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(10f).Within(0.01f));
- Assert.That(circle.Position.Y, Is.EqualTo(10f).Within(0.01f));
+ Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f));
+ Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f));
});
return true;
});
+ }
- AddStep("tap slider", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "Slider")));
+ [Test]
+ public void TestTouchInputPlaceCircleAfterTouchingComposeArea()
+ {
+ AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle")));
- // this input is just for interacting with compose area
AddStep("tap playfield", () => tap(this.ChildrenOfType().Single()));
+ AddAssert("circle placed", () => EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate) is HitCircle);
- AddStep("move current time", () => InputManager.Key(Key.Right));
+ 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", () =>
{
@@ -76,12 +96,55 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
}
- private void tap(Drawable drawable) => tap(drawable.ScreenSpaceDrawQuad.Centre);
+ [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)
{
- InputManager.BeginTouch(new Touch(TouchSource.Touch1, 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/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
index 3d1939acac..9816f6d0a4 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
@@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@@ -33,6 +34,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
+ const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
+ const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
+
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime;
@@ -77,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
- if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2.
+ if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2.
acuteAngleBonus = 0;
else
{
acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
- * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
+ * Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4
- * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter).
+ * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter.
}
// Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
@@ -104,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
- double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity));
+ double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
index d10d2c5c05..d503dd2bcc 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
@@ -5,6 +5,7 @@ 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;
@@ -120,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
islandCount.Count++;
// repeated island (ex: triplet -> triplet)
- double power = logistic(island.Delta, 2.75, 0.24, 14);
+ 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);
@@ -172,8 +173,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
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 static double logistic(double x, double maxValue, double multiplier, double offset) => (maxValue / (1 + Math.Pow(Math.E, offset - (multiplier * x))));
-
private class Island : IEquatable
{
private readonly double deltaDifferenceEpsilon;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
index c220352ee0..a5f6468f17 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
@@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@@ -10,8 +11,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class SpeedEvaluator
{
- private const double single_spacing_threshold = 125; // 1.25 circles distance between centers
- private const double min_speed_bonus = 75; // ~200BPM
+ private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
+ private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.94;
@@ -43,8 +44,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double speedBonus = 0.0;
// Add additional scaling bonus for streams/bursts higher than 200bpm
- if (strainTime < min_speed_bonus)
- speedBonus = 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
+ if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus)
+ speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2);
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.MinimumJumpDistance;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index acf01b2a83..575e03051c 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -48,8 +48,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
- double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountDifficultStrains();
- double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountDifficultStrains();
+ double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountTopWeightedStrains();
+ double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountTopWeightedStrains();
if (mods.Any(m => m is OsuModTouchDevice))
{
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index 43ae95c75e..31b00dba2b 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
@@ -59,6 +59,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit);
countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss);
+ effectiveMissCount = countMiss;
if (osuAttributes.SliderCount > 0)
{
@@ -179,7 +180,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
// 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.Min(countSliderEndsDropped + countSliderTickMiss, estimateDifficultSliders);
+ estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, estimateDifficultSliders);
}
double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
index 3eaf500ad7..5e4c5c1ee9 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
@@ -20,6 +20,8 @@ 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.
+ 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;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
index b3d530e3af..faf91e4652 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
@@ -34,7 +34,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
currentStrain *= strainDecay(current.DeltaTime);
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier;
- ObjectStrains.Add(currentStrain);
return currentStrain;
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs
index 96180c0aa1..559a871df1 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs
@@ -23,7 +23,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
///
protected virtual double ReducedStrainBaseline => 0.75;
- protected List ObjectStrains = new List();
protected double Difficulty;
protected OsuStrainSkill(Mod[] mods)
@@ -60,20 +59,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return Difficulty;
}
- ///
- /// Returns the number of strains weighted against the top strain.
- /// The result is scaled by clock rate as it affects the total number of strains.
- ///
- public double CountDifficultStrains()
- {
- if (Difficulty == 0)
- return 0.0;
-
- double consistentTopStrain = Difficulty / 10; // What would the top strain be if all strain values were identical
- // Use a weighted sum of all strains. Constants are arbitrary and give nice values
- return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88))));
- }
-
public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0;
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
index e5aa25c1eb..d2c4bbb618 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
@@ -40,7 +40,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
double totalStrain = currentStrain * currentRhythm;
- ObjectStrains.Add(totalStrain);
return totalStrain;
}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs
index b13663cb44..163b42bcfd 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs
@@ -2,6 +2,7 @@
// 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;
@@ -31,12 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
public override void EndPlacement(bool commit)
{
if (!commit && PlacementActive != PlacementState.Finished)
- {
- gridToolboxGroup.StartPosition.Value = originalOrigin;
- gridToolboxGroup.Spacing.Value = originalSpacing;
- if (!gridToolboxGroup.GridLinesRotation.Disabled)
- gridToolboxGroup.GridLinesRotation.Value = originalRotation;
- }
+ resetGridState();
base.EndPlacement(commit);
@@ -103,6 +99,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
public override void UpdateTimeAndPosition(SnapResult result)
{
+ if (State.Value == Visibility.Hidden)
+ return;
+
var pos = ToLocalSpace(result.ScreenSpacePosition);
if (PlacementActive != PlacementState.Active)
@@ -122,5 +121,19 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
}
}
}
+
+ protected override void PopOut()
+ {
+ base.PopOut();
+ resetGridState();
+ }
+
+ private void resetGridState()
+ {
+ gridToolboxGroup.StartPosition.Value = originalOrigin;
+ gridToolboxGroup.Spacing.Value = originalSpacing;
+ if (!gridToolboxGroup.GridLinesRotation.Disabled)
+ gridToolboxGroup.GridLinesRotation.Value = originalRotation;
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
index cb57c8e6e0..4f2f6516a8 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
@@ -156,6 +156,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return true;
}
+ // this allows sliders to be drawn outside compose area (after starting from a point within the compose area).
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || PlacementActive == PlacementState.Active;
+
+ // ReceivePositionalInputAtSubTree generally always returns true when masking is disabled, but we don't want that,
+ // otherwise a slider path tooltip will be displayed anywhere in the editor (outside compose area).
+ protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => ReceivePositionalInputAt(screenSpacePos);
+
private void beginNewSegment(PathControlPoint lastPoint)
{
segmentStart = lastPoint;
diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
index b18452768c..477d3b4e57 100644
--- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
+++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
@@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
+using osu.Game.Configuration;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Osu.UI;
@@ -25,13 +26,17 @@ namespace osu.Game.Rulesets.Osu.Edit
private readonly OsuGridToolboxGroup gridToolbox;
- private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.GridCentre));
+ private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, EditorOrigin.GridCentre));
private SliderWithTextBoxInput angleInput = null!;
private EditorRadioButtonCollection rotationOrigin = null!;
+ private RadioButton gridCentreButton = null!;
+ private RadioButton playfieldCentreButton = null!;
private RadioButton selectionCentreButton = null!;
+ private Bindable configRotationOrigin = null!;
+
public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox)
{
this.rotationHandler = rotationHandler;
@@ -41,8 +46,10 @@ namespace osu.Game.Rulesets.Osu.Edit
}
[BackgroundDependencyLoader]
- private void load()
+ private void load(OsuConfigManager config)
{
+ configRotationOrigin = config.GetBindable(OsuSetting.EditorRotationOrigin);
+
Child = new FillFlowContainer
{
Width = 220,
@@ -66,14 +73,14 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X,
Items = new[]
{
- new RadioButton("Grid centre",
- () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.GridCentre },
+ gridCentreButton = new RadioButton("Grid centre",
+ () => rotationInfo.Value = rotationInfo.Value with { Origin = EditorOrigin.GridCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
- new RadioButton("Playfield centre",
- () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre },
+ playfieldCentreButton = new RadioButton("Playfield centre",
+ () => rotationInfo.Value = rotationInfo.Value with { Origin = EditorOrigin.PlayfieldCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
selectionCentreButton = new RadioButton("Selection centre",
- () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre },
+ () => rotationInfo.Value = rotationInfo.Value with { Origin = EditorOrigin.SelectionCentre },
() => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare })
}
}
@@ -95,13 +102,63 @@ namespace osu.Game.Rulesets.Osu.Edit
angleInput.SelectAll();
});
angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue });
- rotationOrigin.Items.First().Select();
rotationHandler.CanRotateAroundSelectionOrigin.BindValueChanged(e =>
{
selectionCentreButton.Selected.Disabled = !e.NewValue;
}, true);
+ bool didSelect = false;
+
+ configRotationOrigin.BindValueChanged(val =>
+ {
+ switch (configRotationOrigin.Value)
+ {
+ case EditorOrigin.GridCentre:
+ if (!gridCentreButton.Selected.Disabled)
+ {
+ gridCentreButton.Select();
+ didSelect = true;
+ }
+
+ break;
+
+ case EditorOrigin.PlayfieldCentre:
+ if (!playfieldCentreButton.Selected.Disabled)
+ {
+ playfieldCentreButton.Select();
+ didSelect = true;
+ }
+
+ break;
+
+ case EditorOrigin.SelectionCentre:
+ if (!selectionCentreButton.Selected.Disabled)
+ {
+ selectionCentreButton.Select();
+ didSelect = true;
+ }
+
+ break;
+ }
+ }, true);
+
+ if (!didSelect)
+ rotationOrigin.Items.First(b => !b.Selected.Disabled).Select();
+
+ gridCentreButton.Selected.BindValueChanged(b =>
+ {
+ if (b.NewValue) configRotationOrigin.Value = EditorOrigin.GridCentre;
+ });
+ playfieldCentreButton.Selected.BindValueChanged(b =>
+ {
+ if (b.NewValue) configRotationOrigin.Value = EditorOrigin.PlayfieldCentre;
+ });
+ selectionCentreButton.Selected.BindValueChanged(b =>
+ {
+ if (b.NewValue) configRotationOrigin.Value = EditorOrigin.SelectionCentre;
+ });
+
rotationInfo.BindValueChanged(rotation =>
{
rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue));
@@ -111,9 +168,9 @@ namespace osu.Game.Rulesets.Osu.Edit
private Vector2? getOriginPosition(PreciseRotationInfo rotation) =>
rotation.Origin switch
{
- RotationOrigin.GridCentre => gridToolbox.StartPosition.Value,
- RotationOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2,
- RotationOrigin.SelectionCentre => null,
+ EditorOrigin.GridCentre => gridToolbox.StartPosition.Value,
+ EditorOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2,
+ EditorOrigin.SelectionCentre => null,
_ => throw new ArgumentOutOfRangeException(nameof(rotation))
};
@@ -143,12 +200,5 @@ namespace osu.Game.Rulesets.Osu.Edit
}
}
- public enum RotationOrigin
- {
- GridCentre,
- PlayfieldCentre,
- SelectionCentre
- }
-
- public record PreciseRotationInfo(float Degrees, RotationOrigin Origin);
+ public record PreciseRotationInfo(float Degrees, EditorOrigin Origin);
}
diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs
index 68f5b268f8..e728290289 100644
--- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs
+++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs
@@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
+using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Input.Bindings;
@@ -18,6 +19,7 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons;
+using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
@@ -28,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private readonly OsuGridToolboxGroup gridToolbox;
- private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, ScaleOrigin.GridCentre, true, true));
+ private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, EditorOrigin.GridCentre, true, true));
private SliderWithTextBoxInput scaleInput = null!;
private BindableNumber scaleInputBindable = null!;
@@ -41,6 +43,8 @@ namespace osu.Game.Rulesets.Osu.Edit
private OsuCheckbox xCheckBox = null!;
private OsuCheckbox yCheckBox = null!;
+ private Bindable configScaleOrigin = null!;
+
private BindableList selectedItems { get; } = new BindableList();
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler, OsuGridToolboxGroup gridToolbox)
@@ -52,10 +56,12 @@ namespace osu.Game.Rulesets.Osu.Edit
}
[BackgroundDependencyLoader]
- private void load(EditorBeatmap editorBeatmap)
+ private void load(EditorBeatmap editorBeatmap, OsuConfigManager config)
{
selectedItems.BindTo(editorBeatmap.SelectedHitObjects);
+ configScaleOrigin = config.GetBindable(OsuSetting.EditorScaleOrigin);
+
Child = new FillFlowContainer
{
Width = 220,
@@ -67,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
Current = scaleInputBindable = new BindableNumber
{
- MinValue = 0.5f,
+ MinValue = 0.05f,
MaxValue = 2,
Precision = 0.001f,
Value = 1,
@@ -82,13 +88,13 @@ namespace osu.Game.Rulesets.Osu.Edit
Items = new[]
{
gridCentreButton = new RadioButton("Grid centre",
- () => setOrigin(ScaleOrigin.GridCentre),
+ () => setOrigin(EditorOrigin.GridCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
playfieldCentreButton = new RadioButton("Playfield centre",
- () => setOrigin(ScaleOrigin.PlayfieldCentre),
+ () => setOrigin(EditorOrigin.PlayfieldCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
selectionCentreButton = new RadioButton("Selection centre",
- () => setOrigin(ScaleOrigin.SelectionCentre),
+ () => setOrigin(EditorOrigin.SelectionCentre),
() => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare })
}
},
@@ -165,7 +171,56 @@ namespace osu.Game.Rulesets.Osu.Edit
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
gridCentreButton.Selected.Disabled = playfieldCentreButton.Selected.Disabled;
- scaleOrigin.Items.First(b => !b.Selected.Disabled).Select();
+ bool didSelect = false;
+
+ configScaleOrigin.BindValueChanged(val =>
+ {
+ switch (configScaleOrigin.Value)
+ {
+ case EditorOrigin.GridCentre:
+ if (!gridCentreButton.Selected.Disabled)
+ {
+ gridCentreButton.Select();
+ didSelect = true;
+ }
+
+ break;
+
+ case EditorOrigin.PlayfieldCentre:
+ if (!playfieldCentreButton.Selected.Disabled)
+ {
+ playfieldCentreButton.Select();
+ didSelect = true;
+ }
+
+ break;
+
+ case EditorOrigin.SelectionCentre:
+ if (!selectionCentreButton.Selected.Disabled)
+ {
+ selectionCentreButton.Select();
+ didSelect = true;
+ }
+
+ break;
+ }
+ }, true);
+
+ if (!didSelect)
+ scaleOrigin.Items.First(b => !b.Selected.Disabled).Select();
+
+ gridCentreButton.Selected.BindValueChanged(b =>
+ {
+ if (b.NewValue) configScaleOrigin.Value = EditorOrigin.GridCentre;
+ });
+ playfieldCentreButton.Selected.BindValueChanged(b =>
+ {
+ if (b.NewValue) configScaleOrigin.Value = EditorOrigin.PlayfieldCentre;
+ });
+ selectionCentreButton.Selected.BindValueChanged(b =>
+ {
+ if (b.NewValue) configScaleOrigin.Value = EditorOrigin.SelectionCentre;
+ });
scaleInfo.BindValueChanged(scale =>
{
@@ -182,7 +237,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private void updateAxisCheckBoxesEnabled()
{
- if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre)
+ if (scaleInfo.Value.Origin != EditorOrigin.SelectionCentre)
{
toggleAxisAvailable(xCheckBox.Current, true);
toggleAxisAvailable(yCheckBox.Current, true);
@@ -208,7 +263,7 @@ namespace osu.Game.Rulesets.Osu.Edit
if (!scaleHandler.OriginalSurroundingQuad.HasValue)
return;
- const float min_scale = 0.5f;
+ const float min_scale = 0.05f;
const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
@@ -230,7 +285,7 @@ namespace osu.Game.Rulesets.Osu.Edit
scaleInputBindable.MinValue = MathF.Min(1, MathF.Max(scale.X, scale.Y));
}
- private void setOrigin(ScaleOrigin origin)
+ private void setOrigin(EditorOrigin origin)
{
scaleInfo.Value = scaleInfo.Value with { Origin = origin };
updateMinMaxScale();
@@ -241,13 +296,13 @@ namespace osu.Game.Rulesets.Osu.Edit
{
switch (scale.Origin)
{
- case ScaleOrigin.GridCentre:
+ case EditorOrigin.GridCentre:
return gridToolbox.StartPosition.Value;
- case ScaleOrigin.PlayfieldCentre:
+ case EditorOrigin.PlayfieldCentre:
return OsuPlayfield.BASE_SIZE / 2;
- case ScaleOrigin.SelectionCentre:
+ case EditorOrigin.SelectionCentre:
if (selectedItems.Count == 1 && selectedItems.First() is Slider slider)
return slider.Position;
@@ -271,7 +326,7 @@ namespace osu.Game.Rulesets.Osu.Edit
return result;
}
- private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
+ private float getRotation(PreciseScaleInfo scale) => scale.Origin == EditorOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
protected override void PopIn()
{
@@ -299,12 +354,5 @@ namespace osu.Game.Rulesets.Osu.Edit
}
}
- public enum ScaleOrigin
- {
- GridCentre,
- PlayfieldCentre,
- SelectionCentre
- }
-
- public record PreciseScaleInfo(float Scale, ScaleOrigin Origin, bool XAxis, bool YAxis);
+ public record PreciseScaleInfo(float Scale, EditorOrigin Origin, bool XAxis, bool YAxis);
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
index 9f63e84867..25428c8b2f 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
@@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
@@ -11,26 +12,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
{
public class ColourEvaluator
{
- ///
- /// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2).
- ///
- /// The input value.
- /// The center of the sigmoid, where the largest gradient occurs and value is equal to middle.
- /// The radius of the sigmoid, outside of which values are near the minimum/maximum.
- /// The middle of the sigmoid output.
- /// The height of the sigmoid output. This will be equal to max value - min value.
- private static double sigmoid(double val, double center, double width, double middle, double height)
- {
- double sigmoid = Math.Tanh(Math.E * -(val - center) / width);
- return sigmoid * (height / 2) + middle;
- }
-
///
/// Evaluate the difficulty of the first note of a .
///
public static double EvaluateDifficultyOf(MonoStreak monoStreak)
{
- return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
+ return DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
}
///
@@ -38,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
///
public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern)
{
- return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
+ return DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
}
///
@@ -46,7 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
///
public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern)
{
- return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1));
+ return 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E));
}
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject)
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
index e528c70699..f6914039f0 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs
@@ -1,33 +1,55 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
{
///
/// Calculates the stamina coefficient of taiko difficulty.
///
- public class Stamina : StrainDecaySkill
+ public class Stamina : StrainSkill
{
- protected override double SkillMultiplier => 1.1;
- protected override double StrainDecayBase => 0.4;
+ private double skillMultiplier => 1.1;
+ private double strainDecayBase => 0.4;
+
+ private readonly bool singleColourStamina;
+
+ private double currentStrain;
///
/// Creates a skill.
///
/// Mods for use in skill calculations.
- public Stamina(Mod[] mods)
+ /// Reads when Stamina is from a single coloured pattern.
+ public Stamina(Mod[] mods, bool singleColourStamina)
: base(mods)
{
+ this.singleColourStamina = singleColourStamina;
}
- protected override double StrainValueOf(DifficultyHitObject current)
+ private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
+
+ protected override double StrainValueAt(DifficultyHitObject current)
{
- return StaminaEvaluator.EvaluateDifficultyOf(current);
+ currentStrain *= strainDecay(current.DeltaTime);
+ currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
+
+ // Safely prevents previous strains from shifting as new notes are added.
+ var currentObject = current as TaikoDifficultyHitObject;
+ int index = currentObject?.Colour.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0;
+
+ if (singleColourStamina)
+ return currentStrain / (1 + Math.Exp(-(index - 10) / 2.0));
+
+ return currentStrain;
}
+
+ protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => singleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime);
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
index 451aed183d..c8f0448767 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
@@ -16,6 +16,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("stamina_difficulty")]
public double StaminaDifficulty { get; set; }
+ ///
+ /// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty.
+ ///
+ [JsonProperty("mono_stamina_factor")]
+ public double MonoStaminaFactor { get; set; }
+
///
/// The difficulty corresponding to the rhythm skill.
///
@@ -60,6 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
+ yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo)
@@ -69,6 +76,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
+ MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR];
}
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
index 18223e74fa..7f2558c406 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
@@ -38,7 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
new Rhythm(mods),
new Colour(mods),
- new Stamina(mods)
+ new Stamina(mods, false),
+ new Stamina(mods, true)
};
}
@@ -79,14 +80,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Colour colour = (Colour)skills.First(x => x is Colour);
Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm);
Stamina stamina = (Stamina)skills.First(x => x is Stamina);
+ Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina);
double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
+ double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
+ double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5);
double combinedRating = combinedDifficultyValue(rhythm, colour, stamina);
double starRating = rescale(combinedRating * 1.4);
+ // TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system.
+ if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0)
+ {
+ starRating *= 0.925;
+ // For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused.
+ if (colourRating < 2 && staminaRating > 8)
+ starRating *= 0.80;
+ }
+
HitWindows hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
@@ -95,6 +108,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
StarRating = starRating,
Mods = mods,
StaminaDifficulty = staminaRating,
+ MonoStaminaFactor = monoStaminaFactor,
RhythmDifficulty = rhythmRating,
ColourDifficulty = colourRating,
PeakDifficulty = combinedRating,
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
index e42b015176..c672b7a1d9 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
@@ -42,18 +42,18 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (totalSuccessfulHits > 0)
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
- // TODO: The detection of rulesets is temporary until the leftover old skills have been reworked.
+ // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation.
bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1;
double multiplier = 1.13;
- if (score.Mods.Any(m => m is ModHidden))
+ if (score.Mods.Any(m => m is ModHidden) && !isConvert)
multiplier *= 1.075;
if (score.Mods.Any(m => m is ModEasy))
- multiplier *= 0.975;
+ multiplier *= 0.950;
- double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert);
+ double difficultyValue = computeDifficultyValue(score, taikoAttributes);
double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert);
double totalValue =
Math.Pow(
@@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
};
}
- private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
+ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
{
double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0;
@@ -81,21 +81,25 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
difficultyValue *= Math.Pow(0.986, effectiveMissCount);
if (score.Mods.Any(m => m is ModEasy))
- difficultyValue *= 0.985;
+ difficultyValue *= 0.90;
- if (score.Mods.Any(m => m is ModHidden) && !isConvert)
+ if (score.Mods.Any(m => m is ModHidden))
difficultyValue *= 1.025;
if (score.Mods.Any(m => m is ModHardRock))
difficultyValue *= 1.10;
if (score.Mods.Any(m => m is ModFlashlight))
- difficultyValue *= 1.050 * lengthBonus;
+ difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus);
if (estimatedUnstableRate == null)
return 0;
- return difficultyValue * Math.Pow(SpecialFunctions.Erf(400 / (Math.Sqrt(2) * estimatedUnstableRate.Value)), 2.0);
+ // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps.
+ double accScalingExponent = 2 + attributes.MonoStaminaFactor;
+ double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor;
+
+ return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent);
}
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
index b706e96bdb..be2a5ac144 100644
--- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
@@ -54,17 +54,17 @@ namespace osu.Game.Rulesets.Taiko.Edit
public void SetStrongState(bool state)
{
- if (SelectedItems.OfType().All(h => h.IsStrong == state))
+ if (SelectedItems.OfType().All(h => h.IsStrong == state))
return;
EditorBeatmap.PerformOnSelection(h =>
{
- if (!(h is Hit taikoHit)) return;
+ if (h is not TaikoStrongableHitObject strongable) return;
- if (taikoHit.IsStrong != state)
+ if (strongable.IsStrong != state)
{
- taikoHit.IsStrong = state;
- EditorBeatmap.Update(taikoHit);
+ strongable.IsStrong = state;
+ EditorBeatmap.Update(strongable);
}
});
}
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs
index 1af4719b02..547d0afe4a 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs
@@ -31,6 +31,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad;
+ // done strictly for editor purposes.
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => MainPiece.Drawable.ReceivePositionalInputAt(screenSpacePos);
+
///
/// Rolling number of tick hits. This increases for hits and decreases for misses.
///
@@ -78,6 +81,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
base.RecreatePieces();
updateColour();
+ Height = HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE;
}
protected override void OnFree()
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs
index 0333fd71a9..64d2020edc 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs
@@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -44,6 +45,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
IsFirstTick.Value = HitObject.FirstTick;
}
+ protected override void RecreatePieces()
+ {
+ base.RecreatePieces();
+ Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE);
+ }
+
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (!userTriggered)
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
index a5e63c373f..28831a6d2c 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs
@@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -63,6 +64,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
updateActionsFromType();
base.RecreatePieces();
+ Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE);
}
protected override void OnFree()
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
index f2fcd185dd..28617b35f6 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs
@@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -34,6 +35,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
///
private const double ring_appear_offset = 100;
+ private Vector2 baseSize;
+
private readonly Container ticks;
private readonly Container bodyContainer;
private readonly CircularContainer targetRing;
@@ -141,6 +144,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
Origin = Anchor.Centre,
});
+ protected override void RecreatePieces()
+ {
+ base.RecreatePieces();
+ Size = baseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE);
+ }
+
protected override void OnFree()
{
base.OnFree();
@@ -269,7 +278,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
base.Update();
- Size = BaseSize * Parent!.RelativeChildSize;
+ Size = baseSize * Parent!.RelativeChildSize;
// Make the swell stop at the hit target
X = Math.Max(0, X);
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
index 3f4694d71d..0cf9651965 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs
@@ -130,7 +130,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public new TObject HitObject => (TObject)base.HitObject;
- protected Vector2 BaseSize;
protected SkinnableDrawable MainPiece;
protected DrawableTaikoHitObject([CanBeNull] TObject hitObject)
@@ -152,8 +151,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
protected virtual void RecreatePieces()
{
- Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE);
-
if (MainPiece != null)
Content.Remove(MainPiece, true);
diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs
index 4d7cdf3243..7c3ff4f27e 100644
--- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs
@@ -8,7 +8,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
-using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@@ -44,13 +43,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
isStrong.UnbindEvents();
}
- protected override void RecreatePieces()
- {
- base.RecreatePieces();
- if (HitObject.IsStrong)
- Size = BaseSize = new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE);
- }
-
protected override void AddNestedHitObject(DrawableHitObject hitObject)
{
base.AddNestedHitObject(hitObject);
diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs
index 5543a31ec9..78be0ef643 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -9,6 +10,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Graphics;
using osu.Game.Skinning;
+using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
@@ -19,13 +21,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
{
get
{
- var headDrawQuad = headCircle.ScreenSpaceDrawQuad;
- var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad;
+ // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii.
+ // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box.
+ var headCentre = headCircle.ScreenSpaceDrawQuad.Centre;
+ var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2;
- return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight);
+ float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2;
+ float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2;
+ float radius = Math.Max(headRadius, tailRadius);
+
+ var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius);
+ return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight);
}
}
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos);
+
private LegacyCirclePiece headCircle = null!;
private Sprite body = null!;
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
index b931896898..c8a09786ec 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs
@@ -120,11 +120,11 @@ namespace osu.Game.Tests.Beatmaps.Formats
private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual)
{
// Check all control points that are still considered to be at a global level.
- Assert.That(expected.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.TimingPoints.Serialize()));
- Assert.That(expected.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.EffectPoints.Serialize()));
+ Assert.That(actual.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.TimingPoints.Serialize()));
+ Assert.That(actual.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.EffectPoints.Serialize()));
// Check all hitobjects.
- Assert.That(expected.beatmap.HitObjects.Serialize(), Is.EqualTo(actual.beatmap.HitObjects.Serialize()));
+ Assert.That(actual.beatmap.HitObjects.Serialize(), Is.EqualTo(expected.beatmap.HitObjects.Serialize()));
// Check skin.
Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration));
diff --git a/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs
new file mode 100644
index 0000000000..5f5a1760ea
--- /dev/null
+++ b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs
@@ -0,0 +1,161 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Screens.Edit.Timing;
+
+namespace osu.Game.Tests.Editing
+{
+ [TestFixture]
+ public class TimingSectionAdjustmentsTest
+ {
+ [Test]
+ public void TestOffsetAdjustment()
+ {
+ var controlPoints = new ControlPointInfo();
+
+ controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
+ controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
+ controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
+
+ var beatmap = new Beatmap
+ {
+ ControlPointInfo = controlPoints,
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0 },
+ new HitCircle { StartTime = 200 },
+ new HitCircle { StartTime = 49_900 },
+ new HitCircle { StartTime = 50_000 },
+ new HitCircle { StartTime = 50_200 },
+ new HitCircle { StartTime = 99_800 },
+ new HitCircle { StartTime = 100_000 },
+ new HitCircle { StartTime = 100_050 },
+ new HitCircle { StartTime = 100_550 },
+ }
+ };
+
+ moveTimingPoint(beatmap, 100, -50);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(-50));
+ Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(50_000));
+ });
+
+ moveTimingPoint(beatmap, 50_000, 1_000);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(51_000));
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(100_800));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(100_000));
+ });
+
+ moveTimingPoint(beatmap, 100_000, 10_000);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(110_800));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(110_000));
+ Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(110_050));
+ Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(110_550));
+ });
+ }
+
+ [Test]
+ public void TestBPMAdjustment()
+ {
+ var controlPoints = new ControlPointInfo();
+
+ controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
+ controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
+ controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
+
+ var beatmap = new Beatmap
+ {
+ ControlPointInfo = controlPoints,
+ HitObjects = new List
+ {
+ new HitCircle { StartTime = 0 },
+ new HitCircle { StartTime = 200 },
+ new Spinner { StartTime = 500, EndTime = 1000 },
+ new HitCircle { StartTime = 49_900 },
+ new HitCircle { StartTime = 50_000 },
+ new HitCircle { StartTime = 50_200 },
+ new HitCircle { StartTime = 99_800 },
+ new HitCircle { StartTime = 100_000 },
+ new HitCircle { StartTime = 100_050 },
+ new HitCircle { StartTime = 100_550 },
+ }
+ };
+
+ adjustBeatLength(beatmap, 100, 50);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(50));
+ Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
+ Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
+ });
+
+ adjustBeatLength(beatmap, 50_000, 400);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
+ Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
+ Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
+ Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(149_600));
+ Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
+ });
+
+ adjustBeatLength(beatmap, 100_000, 100);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
+ Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(199_200));
+ Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
+ Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(100_100));
+ Assert.That(beatmap.HitObjects[9].StartTime, Is.EqualTo(101_100));
+ });
+ }
+
+ private static void moveTimingPoint(IBeatmap beatmap, double originalTime, double adjustment)
+ {
+ var controlPoints = beatmap.ControlPointInfo;
+ var controlPointGroup = controlPoints.GroupAt(originalTime);
+ var timingPoint = controlPointGroup.ControlPoints.OfType().Single();
+ controlPoints.RemoveGroup(controlPointGroup);
+ TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, timingPoint, adjustment);
+ controlPoints.Add(originalTime - adjustment, timingPoint);
+ }
+
+ private static void adjustBeatLength(IBeatmap beatmap, double groupTime, double newBeatLength)
+ {
+ var controlPoints = beatmap.ControlPointInfo;
+ var controlPointGroup = controlPoints.GroupAt(groupTime);
+ var timingPoint = controlPointGroup.ControlPoints.OfType().Single();
+ double oldBeatLength = timingPoint.BeatLength;
+ timingPoint.BeatLength = newBeatLength;
+ TimingSectionAdjustments.SetHitObjectBPM(beatmap, timingPoint, oldBeatLength);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Resources/mania-0-01-sv.osu b/osu.Game.Tests/Resources/mania-0-01-sv.osu
new file mode 100644
index 0000000000..295a8a423a
--- /dev/null
+++ b/osu.Game.Tests/Resources/mania-0-01-sv.osu
@@ -0,0 +1,39 @@
+osu file format v14
+
+[General]
+SampleSet: Normal
+StackLeniency: 0.7
+Mode: 3
+
+[Difficulty]
+HPDrainRate:3
+CircleSize:5
+OverallDifficulty:8
+ApproachRate:8
+SliderMultiplier:3.59999990463257
+SliderTickRate:2
+
+[TimingPoints]
+24,352.941176470588,4,1,1,100,1,0
+6376,-10000,4,1,1,100,0,0
+
+[HitObjects]
+51,192,24,1,0,0:0:0:0:
+153,192,200,1,0,0:0:0:0:
+358,192,376,1,0,0:0:0:0:
+460,192,553,1,0,0:0:0:0:
+460,192,729,128,0,1435:0:0:0:0:
+358,192,906,128,0,1612:0:0:0:0:
+256,192,1082,128,0,1788:0:0:0:0:
+153,192,1259,128,0,1965:0:0:0:0:
+51,192,1435,128,0,2141:0:0:0:0:
+51,192,2318,1,12,0:0:0:0:
+153,192,2318,1,4,0:0:0:0:
+256,192,2318,1,6,0:0:0:0:
+358,192,2318,1,14,0:0:0:0:
+460,192,2318,1,0,0:0:0:0:
+51,192,2494,128,0,2582:0:0:0:0:
+153,192,2494,128,14,2582:0:0:0:0:
+256,192,2494,128,6,2582:0:0:0:0:
+358,192,2494,128,4,2582:0:0:0:0:
+460,192,2494,128,12,2582:0:0:0:0:
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
index f392841ac7..d7c92a64b1 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs
@@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons;
using osu.Game.Screens.Edit.Components.TernaryButtons;
@@ -82,6 +83,45 @@ namespace osu.Game.Tests.Visual.Editing
});
}
+ [Test]
+ public void TestPlacementOutsideComposeScreen()
+ {
+ AddStep("clear all control points and hitobjects", () =>
+ {
+ editorBeatmap.ControlPointInfo.Clear();
+ editorBeatmap.Clear();
+ });
+
+ AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
+ AddStep("select circle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").TriggerClick());
+ AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single()));
+ AddStep("click", () => InputManager.Click(MouseButton.Left));
+ AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1);
+
+ AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 20f)));
+ AddStep("click", () => InputManager.Click(MouseButton.Left));
+ AddAssert("no circle placed", () => editorBeatmap.HitObjects.Count == 1);
+ }
+
+ [Test]
+ public void TestDragSliderOutsideComposeScreen()
+ {
+ AddStep("clear all control points and hitobjects", () =>
+ {
+ editorBeatmap.ControlPointInfo.Clear();
+ editorBeatmap.Clear();
+ });
+
+ AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
+ AddStep("select slider", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "Slider").TriggerClick());
+
+ AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single()));
+ AddStep("hold", () => InputManager.PressButton(MouseButton.Left));
+ AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 80f)));
+ AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
+ AddAssert("slider placed", () => editorBeatmap.HitObjects.Count == 1);
+ }
+
[Test]
public void TestPlacementOnlyWorksWithTiming()
{
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
index 193e8b2571..1787230117 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs
@@ -155,7 +155,13 @@ namespace osu.Game.Tests.Visual.Gameplay
var api = (DummyAPIAccess)API;
api.Friends.Clear();
- api.Friends.Add(friend);
+ api.Friends.Add(new APIRelation
+ {
+ Mutual = true,
+ RelationType = RelationType.Friend,
+ TargetID = friend.OnlineID,
+ TargetUser = friend
+ });
});
int playerNumber = 1;
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
index 2d61c26a6b..3b10509895 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Linq;
using Moq;
@@ -36,15 +34,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
private readonly Bindable beatmapAvailability = new Bindable();
private readonly Bindable room = new Bindable();
- private MultiplayerRoom multiplayerRoom;
- private MultiplayerRoomUser localUser;
- private OngoingOperationTracker ongoingOperationTracker;
+ private MultiplayerRoom multiplayerRoom = null!;
+ private MultiplayerRoomUser localUser = null!;
+ private OngoingOperationTracker ongoingOperationTracker = null!;
- private PopoverContainer content;
- private MatchStartControl control;
+ private PopoverContainer content = null!;
+ private MatchStartControl control = null!;
private OsuButton readyButton => control.ChildrenOfType().Single();
+ [Cached(typeof(IBindable))]
+ private readonly Bindable currentItem = new Bindable();
+
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) { Model = { BindTarget = room } };
@@ -112,15 +113,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable();
- var playlistItem = new PlaylistItem(Beatmap.Value.BeatmapInfo)
+ currentItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
{
RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID
};
room.Value = new Room
{
- Playlist = { playlistItem },
- CurrentPlaylistItem = { Value = playlistItem }
+ Playlist = { currentItem.Value },
+ CurrentPlaylistItem = { BindTarget = currentItem }
};
localUser = new MultiplayerRoomUser(API.LocalUser.Value.Id) { User = API.LocalUser.Value };
@@ -129,7 +130,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
Playlist =
{
- TestMultiplayerClient.CreateMultiplayerPlaylistItem(playlistItem),
+ TestMultiplayerClient.CreateMultiplayerPlaylistItem(currentItem.Value),
},
Users = { localUser },
Host = localUser,
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs
index c2d3b17ccb..9d8ef76e75 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs
@@ -1,15 +1,21 @@
// 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.Containers;
using osu.Framework.Graphics.Cursor;
+using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
namespace osu.Game.Tests.Visual.Multiplayer
{
public partial class TestSceneMultiplayerMatchFooter : MultiplayerTestScene
{
+ [Cached(typeof(IBindable))]
+ private readonly Bindable currentItem = new Bindable();
+
public override void SetUpSteps()
{
base.SetUpSteps();
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs
index 88cc7eb9b3..bd635b1669 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs
@@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using JetBrains.Annotations;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@@ -15,6 +16,7 @@ using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
+using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Mods;
@@ -42,6 +44,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
private Live importedBeatmapSet;
+ [Resolved]
+ private OsuConfigManager configManager { get; set; }
+
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
@@ -57,10 +62,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
Add(detachedBeatmapStore);
}
- public override void SetUpSteps()
+ private void setUp()
{
- base.SetUpSteps();
-
AddStep("reset", () =>
{
Ruleset.Value = new OsuRuleset().RulesetInfo;
@@ -75,6 +78,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestSelectFreeMods()
{
+ setUp();
+
AddStep("set some freemods", () => songSelect.FreeMods.Value = new OsuRuleset().GetModsFor(ModType.Fun).ToArray());
AddStep("set all freemods", () => songSelect.FreeMods.Value = new OsuRuleset().CreateAllMods().ToArray());
AddStep("set no freemods", () => songSelect.FreeMods.Value = Array.Empty());
@@ -85,6 +90,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
BeatmapInfo selectedBeatmap = null;
+ setUp();
+
AddStep("change ruleset", () => Ruleset.Value = new TaikoRuleset().RulesetInfo);
AddStep("select beatmap",
() => songSelect.Carousel.SelectBeatmap(selectedBeatmap = beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == new TaikoRuleset().LegacyID)));
@@ -107,6 +114,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[TestCase(typeof(OsuModHidden), typeof(OsuModTraceable))] // Incompatible.
public void TestAllowedModDeselectedWhenRequired(Type allowedMod, Type requiredMod)
{
+ setUp();
+
AddStep("change ruleset", () => Ruleset.Value = new OsuRuleset().RulesetInfo);
AddStep($"select {allowedMod.ReadableName()} as allowed", () => songSelect.FreeMods.Value = new[] { (Mod)Activator.CreateInstance(allowedMod) });
AddStep($"select {requiredMod.ReadableName()} as required", () => songSelect.Mods.Value = new[] { (Mod)Activator.CreateInstance(requiredMod) });
@@ -120,6 +129,30 @@ namespace osu.Game.Tests.Visual.Multiplayer
assertFreeModNotShown(requiredMod);
}
+ [Test]
+ public void TestChangeRulesetImmediatelyAfterLoadComplete()
+ {
+ AddStep("reset", () =>
+ {
+ configManager.SetValue(OsuSetting.ShowConvertedBeatmaps, false);
+ Beatmap.SetDefault();
+ SelectedMods.SetDefault();
+ });
+
+ AddStep("create song select", () =>
+ {
+ SelectedRoom.Value.Playlist.Single().RulesetID = 2;
+ songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value, SelectedRoom.Value.Playlist.Single());
+ songSelect.OnLoadComplete += _ => Ruleset.Value = new TaikoRuleset().RulesetInfo;
+ LoadScreen(songSelect);
+ });
+ AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded);
+
+ AddStep("confirm selection", () => songSelect.FinaliseSelection());
+ AddAssert("beatmap is taiko", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1));
+ AddAssert("ruleset is taiko", () => Ruleset.Value.OnlineID, () => Is.EqualTo(1));
+ }
+
private void assertFreeModNotShown(Type type)
{
AddAssert($"{type.ReadableName()} not displayed in freemod overlay",
@@ -138,8 +171,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
public new BeatmapCarousel Carousel => base.Carousel;
- public TestMultiplayerMatchSongSelect(Room room)
- : base(room)
+ public TestMultiplayerMatchSongSelect(Room room, [CanBeNull] PlaylistItem itemToEdit = null)
+ : base(room, itemToEdit)
{
}
}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
index 2100f82886..3baabecd84 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs
@@ -1,13 +1,12 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
+using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Platform;
@@ -29,10 +28,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
public partial class TestSceneMultiplayerPlaylist : MultiplayerTestScene
{
- private MultiplayerPlaylist list;
- private BeatmapManager beatmaps;
- private BeatmapSetInfo importedSet;
- private BeatmapInfo importedBeatmap;
+ [Cached(typeof(IBindable))]
+ private readonly Bindable currentItem = new Bindable();
+
+ private MultiplayerPlaylist list = null!;
+ private BeatmapManager beatmaps = null!;
+ private BeatmapSetInfo importedSet = null!;
+ private BeatmapInfo importedBeatmap = null!;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
@@ -198,7 +200,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
addItemStep();
addItemStep();
- DrawableRoomPlaylistItem[] drawableItems = null;
+ DrawableRoomPlaylistItem[] drawableItems = null!;
AddStep("get drawable items", () => drawableItems = this.ChildrenOfType().ToArray());
// Add 1 item for another user.
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
index 816ba4ca32..5ae5d1e228 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
@@ -28,13 +26,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
public partial class TestSceneMultiplayerSpectateButton : MultiplayerTestScene
{
- private MultiplayerSpectateButton spectateButton;
- private MatchStartControl startControl;
+ [Cached(typeof(IBindable))]
+ private readonly Bindable currentItem = new Bindable();
- private readonly Bindable selectedItem = new Bindable();
+ private MultiplayerSpectateButton spectateButton = null!;
+ private MatchStartControl startControl = null!;
- private BeatmapSetInfo importedSet;
- private BeatmapManager beatmaps;
+ private BeatmapSetInfo importedSet = null!;
+ private BeatmapManager beatmaps = null!;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
@@ -52,14 +51,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("create button", () =>
{
- AvailabilityTracker.SelectedItem.BindTo(selectedItem);
+ AvailabilityTracker.SelectedItem.BindTo(currentItem);
importedSet = beatmaps.GetAllUsableBeatmapSets().First();
Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
- selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
- {
- RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID,
- };
+
+ currentItem.Value = SelectedRoom.Value.Playlist.First();
Child = new PopoverContainer
{
diff --git a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs
index b6a300322f..fb54e936bc 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs
@@ -30,14 +30,20 @@ namespace osu.Game.Tests.Visual.Online
if (supportLevel > 3)
supportLevel = 0;
- ((DummyAPIAccess)API).Friends.Add(new APIUser
+ ((DummyAPIAccess)API).Friends.Add(new APIRelation
{
- Username = @"peppy",
- Id = 2,
- Colour = "99EB47",
- CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
- IsSupporter = supportLevel > 0,
- SupportLevel = supportLevel
+ TargetID = 2,
+ RelationType = RelationType.Friend,
+ Mutual = true,
+ TargetUser = new APIUser
+ {
+ Username = @"peppy",
+ Id = 2,
+ Colour = "99EB47",
+ CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ IsSupporter = supportLevel > 0,
+ SupportLevel = supportLevel
+ }
});
}
}
diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs
index 91942c391a..ab3d5907ef 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs
@@ -55,21 +55,21 @@ namespace osu.Game.Tests.Visual.Online
{
Username = @"flyte",
Id = 3103765,
- CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg"
+ CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
}),
new UserBrickPanel(new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
- CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
}),
new UserGridPanel(new APIUser
{
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
- CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg",
+ CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
Status = { Value = UserStatus.Online }
}) { Width = 300 },
boundPanel1 = new UserGridPanel(new APIUser
@@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Online
Username = @"peppy",
Id = 2,
CountryCode = CountryCode.AU,
- CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
IsSupporter = true,
SupportLevel = 3,
}) { Width = 300 },
@@ -95,7 +95,7 @@ namespace osu.Game.Tests.Visual.Online
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
- CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg",
+ CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 }
}) { Width = 300 },
new UserRankPanel(new APIUser
@@ -104,7 +104,7 @@ namespace osu.Game.Tests.Visual.Online
Id = 2,
Colour = "99EB47",
CountryCode = CountryCode.AU,
- CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
Statistics = new UserStatistics { GlobalRank = null, CountryRank = null }
}) { Width = 300 }
}
@@ -168,6 +168,14 @@ namespace osu.Game.Tests.Visual.Online
CountryRank = RNG.Next(100000)
});
});
+ AddStep("set statistics to something big", () =>
+ {
+ API.UpdateStatistics(new UserStatistics
+ {
+ GlobalRank = RNG.Next(1_000_000, 100_000_000),
+ CountryRank = RNG.Next(1_000_000, 100_000_000)
+ });
+ });
AddStep("set statistics to empty", () =>
{
API.UpdateStatistics(new UserStatistics());
diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs
index c9e5a3315c..6167d1f760 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs
@@ -3,15 +3,20 @@
using System;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Profile;
+using osu.Game.Overlays.Profile.Header.Components;
using osu.Game.Rulesets.Osu;
using osu.Game.Users;
@@ -22,6 +27,10 @@ namespace osu.Game.Tests.Visual.Online
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
+ private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
+
+ private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim();
+
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
@@ -400,5 +409,97 @@ namespace osu.Game.Tests.Visual.Online
}
}, new OsuRuleset().RulesetInfo));
}
+
+ private APIUser nonFriend => new APIUser
+ {
+ Id = 727,
+ Username = "Whatever",
+ };
+
+ [Test]
+ public void TestAddFriend()
+ {
+ AddStep("Setup request", () =>
+ {
+ requestLock.Reset();
+
+ dummyAPI.HandleRequest = request =>
+ {
+ if (request is not AddFriendRequest req)
+ return false;
+
+ if (req.TargetId != nonFriend.OnlineID)
+ return false;
+
+ var apiRelation = new APIRelation
+ {
+ TargetID = nonFriend.OnlineID,
+ Mutual = true,
+ RelationType = RelationType.Friend,
+ TargetUser = nonFriend
+ };
+
+ Task.Run(() =>
+ {
+ requestLock.Wait(3000);
+ dummyAPI.Friends.Add(apiRelation);
+ req.TriggerSuccess(new AddFriendResponse
+ {
+ UserRelation = apiRelation
+ });
+ });
+
+ return true;
+ };
+ });
+ AddStep("clear friend list", () => dummyAPI.Friends.Clear());
+ AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo));
+ AddStep("Click followers button", () => this.ChildrenOfType().First().TriggerClick());
+ AddStep("Complete request", () => requestLock.Set());
+ AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID));
+ }
+
+ [Test]
+ public void TestAddFriendNonMutual()
+ {
+ AddStep("Setup request", () =>
+ {
+ requestLock.Reset();
+
+ dummyAPI.HandleRequest = request =>
+ {
+ if (request is not AddFriendRequest req)
+ return false;
+
+ if (req.TargetId != nonFriend.OnlineID)
+ return false;
+
+ var apiRelation = new APIRelation
+ {
+ TargetID = nonFriend.OnlineID,
+ Mutual = false,
+ RelationType = RelationType.Friend,
+ TargetUser = nonFriend
+ };
+
+ Task.Run(() =>
+ {
+ requestLock.Wait(3000);
+ dummyAPI.Friends.Add(apiRelation);
+ req.TriggerSuccess(new AddFriendResponse
+ {
+ UserRelation = apiRelation
+ });
+ });
+
+ return true;
+ };
+ });
+ AddStep("clear friend list", () => dummyAPI.Friends.Clear());
+ AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo));
+ AddStep("Click followers button", () => this.ChildrenOfType().First().TriggerClick());
+ AddStep("Complete request", () => requestLock.Set());
+ AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID));
+ }
}
}
diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs
index 86008a56a4..4cad283833 100644
--- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs
+++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs
@@ -414,11 +414,7 @@ namespace osu.Game.Tests.Visual.Settings
});
AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre));
scrollToAndStartBinding("Left (centre)");
- AddStep("clear binding", () =>
- {
- var row = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == "Left (centre)"));
- row.ChildrenOfType().Single().TriggerClick();
- });
+ clearBinding();
scrollToAndStartBinding("Left (rim)");
AddStep("bind M1", () => InputManager.Click(MouseButton.Left));
@@ -431,6 +427,45 @@ namespace osu.Game.Tests.Visual.Settings
AddUntilStep("conflict popover not shown", () => panel.ChildrenOfType().SingleOrDefault(), () => Is.Null);
}
+ [Test]
+ public void TestResettingRowCannotConflictWithItself()
+ {
+ AddStep("reset taiko section to default", () =>
+ {
+ var section = panel.ChildrenOfType().First(section => new TaikoRuleset().RulesetInfo.Equals(section.Ruleset));
+ section.ChildrenOfType().Single().TriggerClick();
+ });
+ AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre));
+
+ scrollToAndStartBinding("Left (centre)");
+ clearBinding();
+ scrollToAndStartBinding("Left (centre)", 1);
+ clearBinding();
+
+ scrollToAndStartBinding("Left (centre)");
+ AddStep("bind F", () => InputManager.Key(Key.F));
+ scrollToAndStartBinding("Left (centre)", 1);
+ AddStep("bind M1", () => InputManager.Click(MouseButton.Left));
+
+ AddStep("revert row to default", () =>
+ {
+ var row = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == "Left (centre)"));
+ InputManager.MoveMouseTo(row.ChildrenOfType>().Single());
+ InputManager.Click(MouseButton.Left);
+ });
+ AddWaitStep("wait a bit", 3);
+ AddUntilStep("conflict popover not shown", () => panel.ChildrenOfType().SingleOrDefault(), () => Is.Null);
+ }
+
+ private void clearBinding()
+ {
+ AddStep("clear binding", () =>
+ {
+ var row = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == "Left (centre)"));
+ row.ChildrenOfType().Single().TriggerClick();
+ });
+ }
+
private void checkBinding(string name, string keyName)
{
AddAssert($"Check {name} is bound to {keyName}", () =>
@@ -442,23 +477,23 @@ namespace osu.Game.Tests.Visual.Settings
}, () => Is.EqualTo(keyName));
}
- private void scrollToAndStartBinding(string name)
+ private void scrollToAndStartBinding(string name, int bindingIndex = 0)
{
- KeyBindingRow.KeyButton firstButton = null;
+ KeyBindingRow.KeyButton targetButton = null;
AddStep($"Scroll to {name}", () =>
{
var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == name));
- firstButton = firstRow.ChildrenOfType().First();
+ targetButton = firstRow.ChildrenOfType().ElementAt(bindingIndex);
- panel.ChildrenOfType().First().ScrollTo(firstButton);
+ panel.ChildrenOfType().First().ScrollTo(targetButton);
});
AddWaitStep("wait for scroll", 5);
AddStep("click to bind", () =>
{
- InputManager.MoveMouseTo(firstButton);
+ InputManager.MoveMouseTo(targetButton);
InputManager.Click(MouseButton.Left);
});
}
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs
index 16c8bc1a6b..66862e1b78 100644
--- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs
@@ -10,9 +10,12 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
+using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
+using osu.Game.Database;
using osu.Game.Extensions;
+using osu.Game.Online.API;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
@@ -191,8 +194,39 @@ namespace osu.Game.Tests.Visual.SongSelect
{
AddStep("present beatmap", () => Game.PresentBeatmap(getImport()));
- AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect);
+ AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded);
AddUntilStep("recommended beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.MatchesOnlineID(getImport().Beatmaps[expectedDiff - 1]));
}
+
+ protected override TestOsuGame CreateTestGame() => new NoBeatmapUpdateGame(LocalStorage, API);
+
+ private partial class NoBeatmapUpdateGame : TestOsuGame
+ {
+ public NoBeatmapUpdateGame(Storage storage, IAPIProvider api, string[] args = null)
+ : base(storage, api, args)
+ {
+ }
+
+ protected override IBeatmapUpdater CreateBeatmapUpdater() => new TestBeatmapUpdater();
+
+ private class TestBeatmapUpdater : IBeatmapUpdater
+ {
+ public void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
+ {
+ }
+
+ public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
+ {
+ }
+
+ public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+ }
}
}
diff --git a/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs
index b160043820..965f3be0aa 100644
--- a/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs
+++ b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs
@@ -13,11 +13,11 @@ namespace osu.Game.Beatmaps
///
public partial class BeatmapOnlineChangeIngest : Component
{
- private readonly BeatmapUpdater beatmapUpdater;
+ private readonly IBeatmapUpdater beatmapUpdater;
private readonly RealmAccess realm;
private readonly MetadataClient metadataClient;
- public BeatmapOnlineChangeIngest(BeatmapUpdater beatmapUpdater, RealmAccess realm, MetadataClient metadataClient)
+ public BeatmapOnlineChangeIngest(IBeatmapUpdater beatmapUpdater, RealmAccess realm, MetadataClient metadataClient)
{
this.beatmapUpdater = beatmapUpdater;
this.realm = realm;
diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs
index e897d28916..efb432b84e 100644
--- a/osu.Game/Beatmaps/BeatmapUpdater.cs
+++ b/osu.Game/Beatmaps/BeatmapUpdater.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 System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
@@ -15,10 +14,7 @@ using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Beatmaps
{
- ///
- /// Handles all processing required to ensure a local beatmap is in a consistent state with any changes.
- ///
- public class BeatmapUpdater : IDisposable
+ public class BeatmapUpdater : IBeatmapUpdater
{
private readonly IWorkingBeatmapCache workingBeatmapCache;
@@ -38,11 +34,6 @@ namespace osu.Game.Beatmaps
metadataLookup = new BeatmapUpdaterMetadataLookup(api, storage);
}
- ///
- /// Queue a beatmap for background processing.
- ///
- /// The managed beatmap set to update. A transaction will be opened to apply changes.
- /// The preferred scope to use for metadata lookup.
public void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
Logger.Log($"Queueing change for local beatmap {beatmapSet}");
@@ -50,55 +41,56 @@ namespace osu.Game.Beatmaps
updateScheduler);
}
- ///
- /// Run all processing on a beatmap immediately.
- ///
- /// The managed beatmap set to update. A transaction will be opened to apply changes.
- /// The preferred scope to use for metadata lookup.
- public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm!.Write(_ =>
+ public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
- // Before we use below, we want to invalidate.
- workingBeatmapCache.Invalidate(beatmapSet);
-
- if (lookupScope != MetadataLookupScope.None)
- metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst);
-
- foreach (var beatmap in beatmapSet.Beatmaps)
+ beatmapSet.Realm!.Write(_ =>
{
- difficultyCache.Invalidate(beatmap);
+ // Before we use below, we want to invalidate.
+ workingBeatmapCache.Invalidate(beatmapSet);
- var working = workingBeatmapCache.GetWorkingBeatmap(beatmap);
- var ruleset = working.BeatmapInfo.Ruleset.CreateInstance();
+ if (lookupScope != MetadataLookupScope.None)
+ metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst);
- Debug.Assert(ruleset != null);
+ foreach (var beatmap in beatmapSet.Beatmaps)
+ {
+ difficultyCache.Invalidate(beatmap);
- var calculator = ruleset.CreateDifficultyCalculator(working);
+ var working = workingBeatmapCache.GetWorkingBeatmap(beatmap);
+ var ruleset = working.BeatmapInfo.Ruleset.CreateInstance();
- beatmap.StarRating = calculator.Calculate().StarRating;
- beatmap.Length = working.Beatmap.CalculatePlayableLength();
- beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength();
- beatmap.EndTimeObjectCount = working.Beatmap.HitObjects.Count(h => h is IHasDuration);
- beatmap.TotalObjectCount = working.Beatmap.HitObjects.Count;
- }
+ Debug.Assert(ruleset != null);
- // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required.
- workingBeatmapCache.Invalidate(beatmapSet);
- });
+ var calculator = ruleset.CreateDifficultyCalculator(working);
- public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapInfo.Realm!.Write(_ =>
+ beatmap.StarRating = calculator.Calculate().StarRating;
+ beatmap.Length = working.Beatmap.CalculatePlayableLength();
+ beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength();
+ beatmap.EndTimeObjectCount = working.Beatmap.HitObjects.Count(h => h is IHasDuration);
+ beatmap.TotalObjectCount = working.Beatmap.HitObjects.Count;
+ }
+
+ // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required.
+ workingBeatmapCache.Invalidate(beatmapSet);
+ });
+ }
+
+ public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
- // Before we use below, we want to invalidate.
- workingBeatmapCache.Invalidate(beatmapInfo);
+ beatmapInfo.Realm!.Write(_ =>
+ {
+ // Before we use below, we want to invalidate.
+ workingBeatmapCache.Invalidate(beatmapInfo);
- var working = workingBeatmapCache.GetWorkingBeatmap(beatmapInfo);
- var beatmap = working.Beatmap;
+ var working = workingBeatmapCache.GetWorkingBeatmap(beatmapInfo);
+ var beatmap = working.Beatmap;
- beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration);
- beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count;
+ beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration);
+ beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count;
- // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required.
- workingBeatmapCache.Invalidate(beatmapInfo);
- });
+ // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required.
+ workingBeatmapCache.Invalidate(beatmapInfo);
+ });
+ }
#region Implementation of IDisposable
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
index c14648caf6..956d004602 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
@@ -183,7 +183,17 @@ namespace osu.Game.Beatmaps.Formats
if (scrollSpeedEncodedAsSliderVelocity)
{
foreach (var point in legacyControlPoints.EffectPoints)
- legacyControlPoints.Add(point.Time, new DifficultyControlPoint { SliderVelocity = point.ScrollSpeed });
+ {
+ legacyControlPoints.Add(point.Time, new DifficultyControlPoint
+ {
+ SliderVelocityBindable =
+ {
+ MinValue = point.ScrollSpeedBindable.MinValue,
+ MaxValue = point.ScrollSpeedBindable.MaxValue,
+ Value = point.ScrollSpeedBindable.Value,
+ }
+ });
+ }
}
LegacyControlPointProperties lastControlPointProperties = new LegacyControlPointProperties();
diff --git a/osu.Game/Beatmaps/IBeatmapUpdater.cs b/osu.Game/Beatmaps/IBeatmapUpdater.cs
new file mode 100644
index 0000000000..062984adf0
--- /dev/null
+++ b/osu.Game/Beatmaps/IBeatmapUpdater.cs
@@ -0,0 +1,35 @@
+// 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.Database;
+
+namespace osu.Game.Beatmaps
+{
+ ///
+ /// Handles all processing required to ensure a local beatmap is in a consistent state with any changes.
+ ///
+ public interface IBeatmapUpdater : IDisposable
+ {
+ ///
+ /// Queue a beatmap for background processing.
+ ///
+ /// The managed beatmap set to update. A transaction will be opened to apply changes.
+ /// The preferred scope to use for metadata lookup.
+ void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst);
+
+ ///
+ /// Run all processing on a beatmap immediately.
+ ///
+ /// The managed beatmap set to update. A transaction will be opened to apply changes.
+ /// The preferred scope to use for metadata lookup.
+ void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst);
+
+ ///
+ /// Runs a subset of processing focused on updating any cached beatmap object counts.
+ ///
+ /// The managed beatmap to update. A transaction will be opened to apply changes.
+ /// The preferred scope to use for metadata lookup.
+ void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst);
+ }
+}
diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs
index a16abe5c55..af6fd61a3d 100644
--- a/osu.Game/Configuration/OsuConfigManager.cs
+++ b/osu.Game/Configuration/OsuConfigManager.cs
@@ -17,6 +17,7 @@ using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods.Input;
using osu.Game.Rulesets.Scoring;
+using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
@@ -193,6 +194,9 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.EditorAutoSeekOnPlacement, true);
SetDefault(OsuSetting.EditorLimitedDistanceSnap, false);
SetDefault(OsuSetting.EditorShowSpeedChanges, false);
+ SetDefault(OsuSetting.EditorScaleOrigin, EditorOrigin.GridCentre);
+ SetDefault(OsuSetting.EditorRotationOrigin, EditorOrigin.GridCentre);
+ SetDefault(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges, true);
SetDefault(OsuSetting.HideCountryFlags, false);
@@ -204,6 +208,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.UserOnlineStatus, null);
SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true);
+ SetDefault(OsuSetting.EditorTimelineShowBreaks, true);
SetDefault(OsuSetting.EditorTimelineShowTicks, true);
SetDefault(OsuSetting.EditorContractSidebars, false);
@@ -434,6 +439,10 @@ namespace osu.Game.Configuration
EditorTimelineShowTimingChanges,
EditorTimelineShowTicks,
AlwaysShowHoldForMenuButton,
- EditorContractSidebars
+ EditorContractSidebars,
+ EditorScaleOrigin,
+ EditorRotationOrigin,
+ EditorTimelineShowBreaks,
+ EditorAdjustExistingObjectsOnTimingChanges,
}
}
diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs
index 3efd4da3aa..1512b6be93 100644
--- a/osu.Game/Database/BackgroundDataStoreProcessor.cs
+++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs
@@ -46,7 +46,7 @@ namespace osu.Game.Database
private RealmAccess realmAccess { get; set; } = null!;
[Resolved]
- private BeatmapUpdater beatmapUpdater { get; set; } = null!;
+ private IBeatmapUpdater beatmapUpdater { get; set; } = null!;
[Resolved]
private IBindable gameBeatmap { get; set; } = null!;
diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs
index 806b7a10b8..b3ffd15816 100644
--- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs
+++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs
@@ -10,6 +10,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
+using osu.Game.Localisation;
using osuTK;
using osuTK.Graphics;
@@ -77,7 +78,7 @@ namespace osu.Game.Graphics.UserInterface
if (Link != null)
{
items.Add(new OsuMenuItem("Open", MenuItemType.Highlighted, () => game?.OpenUrlExternally(Link)));
- items.Add(new OsuMenuItem("Copy link", MenuItemType.Standard, copyUrl));
+ items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, copyUrl));
}
return items.ToArray();
diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs
index 2c377a81d9..243a100029 100644
--- a/osu.Game/Localisation/CommonStrings.cs
+++ b/osu.Game/Localisation/CommonStrings.cs
@@ -174,6 +174,11 @@ namespace osu.Game.Localisation
///
public static LocalisableString General => new TranslatableString(getKey(@"general"), @"General");
+ ///
+ /// "Copy link"
+ ///
+ public static LocalisableString CopyLink => new TranslatableString(getKey(@"copy_link"), @"Copy link");
+
private static string getKey(string key) => $@"{prefix}:{key}";
}
-}
+}
\ No newline at end of file
diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs
index f6cce554e9..127bdd8355 100644
--- a/osu.Game/Localisation/EditorStrings.cs
+++ b/osu.Game/Localisation/EditorStrings.cs
@@ -39,6 +39,11 @@ namespace osu.Game.Localisation
///
public static LocalisableString SetPreviewPointToCurrent => new TranslatableString(getKey(@"set_preview_point_to_current"), @"Set preview point to current time");
+ ///
+ /// "Move already placed objects when changing timing"
+ ///
+ public static LocalisableString AdjustExistingObjectsOnTimingChanges => new TranslatableString(getKey(@"adjust_existing_objects_on_timing_changes"), @"Move already placed objects when changing timing");
+
///
/// "For editing (.olz)"
///
@@ -139,6 +144,11 @@ namespace osu.Game.Localisation
///
public static LocalisableString TimelineShowTimingChanges => new TranslatableString(getKey(@"timeline_show_timing_changes"), @"Show timing changes");
+ ///
+ /// "Show breaks"
+ ///
+ public static LocalisableString TimelineShowBreaks => new TranslatableString(getKey(@"timeline_show_breaks"), @"Show breaks");
+
///
/// "Show ticks"
///
diff --git a/osu.Game/Localisation/PlayerLoaderStrings.cs b/osu.Game/Localisation/PlayerLoaderStrings.cs
index eba98c7aa7..f9d6f80676 100644
--- a/osu.Game/Localisation/PlayerLoaderStrings.cs
+++ b/osu.Game/Localisation/PlayerLoaderStrings.cs
@@ -26,10 +26,10 @@ namespace osu.Game.Localisation
///
/// "No performance points will be awarded.
- /// Leaderboards may be reset by the beatmap creator."
+ /// Leaderboards may be reset."
///
public static LocalisableString LovedBeatmapDisclaimerContent => new TranslatableString(getKey(@"loved_beatmap_disclaimer_content"), @"No performance points will be awarded.
-Leaderboards may be reset by the beatmap creator.");
+Leaderboards may be reset.");
///
/// "This beatmap is qualified"
diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs
index a9ccbf9b18..c8992c108e 100644
--- a/osu.Game/Online/API/APIAccess.cs
+++ b/osu.Game/Online/API/APIAccess.cs
@@ -57,7 +57,7 @@ namespace osu.Game.Online.API
private string password;
public IBindable LocalUser => localUser;
- public IBindableList Friends => friends;
+ public IBindableList Friends => friends;
public IBindable Activity => activity;
public IBindable Statistics => statistics;
@@ -67,7 +67,7 @@ namespace osu.Game.Online.API
private Bindable localUser { get; } = new Bindable(createGuestUser());
- private BindableList friends { get; } = new BindableList();
+ private BindableList friends { get; } = new BindableList();
private Bindable activity { get; } = new Bindable();
@@ -360,19 +360,7 @@ namespace osu.Game.Online.API
}
}
- var friendsReq = new GetFriendsRequest();
- friendsReq.Failure += _ => state.Value = APIState.Failing;
- friendsReq.Success += res =>
- {
- friends.Clear();
- friends.AddRange(res);
- };
-
- if (!handleRequest(friendsReq))
- {
- state.Value = APIState.Failing;
- return;
- }
+ UpdateLocalFriends();
// The Success callback event is fired on the main thread, so we should wait for that to run before proceeding.
// Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests
@@ -624,6 +612,22 @@ namespace osu.Game.Online.API
localUser.Value.Statistics = newStatistics;
}
+ public void UpdateLocalFriends()
+ {
+ if (!IsLoggedIn)
+ return;
+
+ var friendsReq = new GetFriendsRequest();
+ friendsReq.Failure += _ => state.Value = APIState.Failing;
+ friendsReq.Success += res =>
+ {
+ friends.Clear();
+ friends.AddRange(res);
+ };
+
+ Queue(friendsReq);
+ }
+
private static APIUser createGuestUser() => new GuestUser();
private void setLocalUser(APIUser user) => Scheduler.Add(() =>
diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs
index 7ac5c45fad..f0da0c25da 100644
--- a/osu.Game/Online/API/DummyAPIAccess.cs
+++ b/osu.Game/Online/API/DummyAPIAccess.cs
@@ -26,7 +26,7 @@ namespace osu.Game.Online.API
Id = DUMMY_USER_ID,
});
- public BindableList Friends { get; } = new BindableList();
+ public BindableList Friends { get; } = new BindableList();
public Bindable Activity { get; } = new Bindable();
@@ -201,6 +201,10 @@ namespace osu.Game.Online.API
LocalUser.Value.Statistics = newStatistics;
}
+ public void UpdateLocalFriends()
+ {
+ }
+
public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null;
public IChatClient GetChatClient() => new TestChatClientConnector(this);
@@ -214,7 +218,7 @@ namespace osu.Game.Online.API
public void SetState(APIState newState) => state.Value = newState;
IBindable IAPIProvider.LocalUser => LocalUser;
- IBindableList IAPIProvider.Friends => Friends;
+ IBindableList IAPIProvider.Friends => Friends;
IBindable IAPIProvider.Activity => Activity;
IBindable IAPIProvider.Statistics => Statistics;
diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs
index eccfb36546..4b1aed236d 100644
--- a/osu.Game/Online/API/IAPIProvider.cs
+++ b/osu.Game/Online/API/IAPIProvider.cs
@@ -22,7 +22,7 @@ namespace osu.Game.Online.API
///
/// The user's friends.
///
- IBindableList Friends { get; }
+ IBindableList Friends { get; }
///
/// The current user's activity.
@@ -134,6 +134,11 @@ namespace osu.Game.Online.API
///
void UpdateStatistics(UserStatistics newStatistics);
+ ///
+ /// Update the friends status of the current user.
+ ///
+ void UpdateLocalFriends();
+
///
/// Schedule a callback to run on the update thread.
///
diff --git a/osu.Game/Online/API/Requests/AddFriendRequest.cs b/osu.Game/Online/API/Requests/AddFriendRequest.cs
new file mode 100644
index 0000000000..11045cedbe
--- /dev/null
+++ b/osu.Game/Online/API/Requests/AddFriendRequest.cs
@@ -0,0 +1,30 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Net.Http;
+using osu.Framework.IO.Network;
+
+namespace osu.Game.Online.API.Requests
+{
+ public class AddFriendRequest : APIRequest
+ {
+ public readonly int TargetId;
+
+ public AddFriendRequest(int targetId)
+ {
+ TargetId = targetId;
+ }
+
+ protected override WebRequest CreateWebRequest()
+ {
+ var req = base.CreateWebRequest();
+
+ req.Method = HttpMethod.Post;
+ req.AddParameter("target", TargetId.ToString(), RequestParameterType.Query);
+
+ return req;
+ }
+
+ protected override string Target => @"friends";
+ }
+}
diff --git a/osu.Game/Online/API/Requests/AddFriendResponse.cs b/osu.Game/Online/API/Requests/AddFriendResponse.cs
new file mode 100644
index 0000000000..af9d037e47
--- /dev/null
+++ b/osu.Game/Online/API/Requests/AddFriendResponse.cs
@@ -0,0 +1,14 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using Newtonsoft.Json;
+using osu.Game.Online.API.Requests.Responses;
+
+namespace osu.Game.Online.API.Requests
+{
+ public class AddFriendResponse
+ {
+ [JsonProperty("user_relation")]
+ public APIRelation UserRelation = null!;
+ }
+}
diff --git a/osu.Game/Online/API/Requests/DeleteFriendRequest.cs b/osu.Game/Online/API/Requests/DeleteFriendRequest.cs
new file mode 100644
index 0000000000..42ceb2c55a
--- /dev/null
+++ b/osu.Game/Online/API/Requests/DeleteFriendRequest.cs
@@ -0,0 +1,27 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Net.Http;
+using osu.Framework.IO.Network;
+
+namespace osu.Game.Online.API.Requests
+{
+ public class DeleteFriendRequest : APIRequest
+ {
+ public readonly int TargetId;
+
+ public DeleteFriendRequest(int targetId)
+ {
+ TargetId = targetId;
+ }
+
+ protected override WebRequest CreateWebRequest()
+ {
+ var req = base.CreateWebRequest();
+ req.Method = HttpMethod.Delete;
+ return req;
+ }
+
+ protected override string Target => $@"friends/{TargetId}";
+ }
+}
diff --git a/osu.Game/Online/API/Requests/GetFriendsRequest.cs b/osu.Game/Online/API/Requests/GetFriendsRequest.cs
index 63a221d91a..77b37e87d0 100644
--- a/osu.Game/Online/API/Requests/GetFriendsRequest.cs
+++ b/osu.Game/Online/API/Requests/GetFriendsRequest.cs
@@ -6,7 +6,7 @@ using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
{
- public class GetFriendsRequest : APIRequest>
+ public class GetFriendsRequest : APIRequest>
{
protected override string Target => @"friends";
}
diff --git a/osu.Game/Online/API/Requests/Responses/APIRelation.cs b/osu.Game/Online/API/Requests/Responses/APIRelation.cs
new file mode 100644
index 0000000000..c7315db8b9
--- /dev/null
+++ b/osu.Game/Online/API/Requests/Responses/APIRelation.cs
@@ -0,0 +1,30 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace osu.Game.Online.API.Requests.Responses
+{
+ public class APIRelation
+ {
+ [JsonProperty("target_id")]
+ public int TargetID { get; set; }
+
+ [JsonProperty("relation_type")]
+ public RelationType RelationType { get; set; }
+
+ [JsonProperty("mutual")]
+ public bool Mutual { get; set; }
+
+ [JsonProperty("target")]
+ public APIUser? TargetUser { get; set; }
+ }
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum RelationType
+ {
+ Friend,
+ Block,
+ }
+}
diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs
index 1c48a4fe6d..75b161d57b 100644
--- a/osu.Game/Online/Chat/ExternalLinkOpener.cs
+++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs
@@ -11,7 +11,7 @@ using osu.Game.Configuration;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
-using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
+using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Online.Chat
{
@@ -60,12 +60,12 @@ namespace osu.Game.Online.Chat
},
new PopupDialogCancelButton
{
- Text = @"Copy link",
+ Text = CommonStrings.CopyLink,
Action = copyExternalLinkAction
},
new PopupDialogCancelButton
{
- Text = CommonStrings.ButtonsCancel,
+ Text = WebCommonStrings.ButtonsCancel,
},
};
}
diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs
index 964f065813..5651f01645 100644
--- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs
+++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs
@@ -17,6 +17,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
+using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
@@ -33,6 +34,8 @@ using osu.Game.Online.API;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
+using CommonStrings = osu.Game.Localisation.CommonStrings;
+using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Online.Leaderboards
{
@@ -71,6 +74,12 @@ namespace osu.Game.Online.Leaderboards
[Resolved(canBeNull: true)]
private SongSelect songSelect { get; set; }
+ [Resolved(canBeNull: true)]
+ private Clipboard clipboard { get; set; }
+
+ [Resolved]
+ private IAPIProvider api { get; set; }
+
public ITooltip GetCustomTooltip() => new LeaderboardScoreTooltip();
public virtual ScoreInfo TooltipContent => Score;
@@ -423,10 +432,13 @@ namespace osu.Game.Online.Leaderboards
if (Score.Mods.Length > 0 && songSelect != null)
items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods));
+ if (Score.OnlineID > 0)
+ items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{Score.OnlineID}")));
+
if (Score.Files.Count > 0)
{
- items.Add(new OsuMenuItem(Localisation.CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(Score)));
- items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score))));
+ items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(Score)));
+ items.Add(new OsuMenuItem(WebCommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score))));
}
return items.ToArray();
diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
index 5fd8b8b337..ff147aba10 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
@@ -202,7 +202,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(joinedRoom.Playlist.Count > 0);
APIRoom.Playlist.Clear();
- APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem));
+ APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(item => new PlaylistItem(item)));
APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId);
// The server will null out the end date upon the host joining the room, but the null value is never communicated to the client.
@@ -734,7 +734,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(APIRoom != null);
Room.Playlist.Add(item);
- APIRoom.Playlist.Add(createPlaylistItem(item));
+ APIRoom.Playlist.Add(new PlaylistItem(item));
ItemAdded?.Invoke(item);
RoomUpdated?.Invoke();
@@ -780,7 +780,7 @@ namespace osu.Game.Online.Multiplayer
int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID));
APIRoom.Playlist.RemoveAt(existingIndex);
- APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item));
+ APIRoom.Playlist.Insert(existingIndex, new PlaylistItem(item));
}
catch (Exception ex)
{
@@ -853,18 +853,6 @@ namespace osu.Game.Online.Multiplayer
RoomUpdated?.Invoke();
}
- private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating })
- {
- ID = item.ID,
- OwnerID = item.OwnerID,
- RulesetID = item.RulesetID,
- Expired = item.Expired,
- PlaylistOrder = item.PlaylistOrder,
- PlayedAt = item.PlayedAt,
- RequiredMods = item.RequiredMods.ToArray(),
- AllowedMods = item.AllowedMods.ToArray()
- };
-
///
/// For the provided user ID, update whether the user is included in .
///
diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomExtensions.cs
new file mode 100644
index 0000000000..0aeb85d2d8
--- /dev/null
+++ b/osu.Game/Online/Multiplayer/MultiplayerRoomExtensions.cs
@@ -0,0 +1,39 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using osu.Game.Online.Rooms;
+
+namespace osu.Game.Online.Multiplayer
+{
+ public static class MultiplayerRoomExtensions
+ {
+ ///
+ /// Returns all historical/expired items from the , in the order in which they were played.
+ ///
+ public static IEnumerable GetHistoricalItems(this MultiplayerRoom room)
+ => room.Playlist.Where(item => item.Expired).OrderBy(item => item.PlayedAt);
+
+ ///
+ /// Returns all non-expired items from the , in the order in which they are to be played.
+ ///
+ public static IEnumerable GetUpcomingItems(this MultiplayerRoom room)
+ => room.Playlist.Where(item => !item.Expired).OrderBy(item => item.PlaylistOrder);
+
+ ///
+ /// Returns the first non-expired in playlist order from the supplied ,
+ /// or the last-played if all items are expired,
+ /// or if was empty.
+ ///
+ public static MultiplayerPlaylistItem? GetCurrentItem(this MultiplayerRoom room)
+ {
+ if (room.Playlist.Count == 0)
+ return null;
+
+ return room.Playlist.All(item => item.Expired)
+ ? GetHistoricalItems(room).Last()
+ : GetUpcomingItems(room).First();
+ }
+ }
+}
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index d4704d1c72..dc13924b4f 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -198,7 +198,7 @@ namespace osu.Game
public readonly Bindable>> AvailableMods = new Bindable>>(new Dictionary>());
private BeatmapDifficultyCache difficultyCache;
- private BeatmapUpdater beatmapUpdater;
+ private IBeatmapUpdater beatmapUpdater;
private UserLookupCache userCache;
private BeatmapLookupCache beatmapCache;
@@ -324,7 +324,7 @@ namespace osu.Game
base.Content.Add(difficultyCache);
// TODO: OsuGame or OsuGameBase?
- dependencies.CacheAs(beatmapUpdater = new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage));
+ dependencies.CacheAs(beatmapUpdater = CreateBeatmapUpdater());
dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints));
dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints));
dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints));
@@ -563,6 +563,8 @@ namespace osu.Game
}
}
+ protected virtual IBeatmapUpdater CreateBeatmapUpdater() => new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage);
+
protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager();
protected virtual BatteryInfo CreateBatteryInfo() => null;
diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs
index e3accfd2ad..3e393ced01 100644
--- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs
+++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs
@@ -6,14 +6,17 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using osuTK;
@@ -35,7 +38,8 @@ namespace osu.Game.Overlays.Dashboard.Friends
private CancellationTokenSource cancellationToken;
- private Drawable currentContent;
+ [CanBeNull]
+ private SearchContainer currentContent;
private FriendOnlineStreamControl onlineStreamControl;
private Box background;
@@ -43,8 +47,9 @@ namespace osu.Game.Overlays.Dashboard.Friends
private UserListToolbar userListToolbar;
private Container itemsPlaceholder;
private LoadingLayer loading;
+ private BasicSearchTextBox searchTextBox;
- private readonly IBindableList apiFriends = new BindableList();
+ private readonly IBindableList apiFriends = new BindableList();
public FriendDisplay()
{
@@ -104,7 +109,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
Margin = new MarginPadding { Bottom = 20 },
Children = new Drawable[]
{
- new Container
+ new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
@@ -113,11 +118,38 @@ namespace osu.Game.Overlays.Dashboard.Friends
Horizontal = 40,
Vertical = 20
},
- Child = userListToolbar = new UserListToolbar
+ ColumnDimensions = new[]
{
- Anchor = Anchor.CentreRight,
- Origin = Anchor.CentreRight,
- }
+ new Dimension(),
+ new Dimension(GridSizeMode.Absolute, 50),
+ new Dimension(GridSizeMode.AutoSize),
+ },
+ RowDimensions = new[]
+ {
+ new Dimension(GridSizeMode.AutoSize),
+ },
+ Content = new[]
+ {
+ new[]
+ {
+ searchTextBox = new BasicSearchTextBox
+ {
+ RelativeSizeAxes = Axes.X,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Height = 40,
+ ReleaseFocusOnCommit = false,
+ HoldFocus = true,
+ PlaceholderText = HomeStrings.SearchPlaceholder,
+ },
+ Empty(),
+ userListToolbar = new UserListToolbar
+ {
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.CentreRight,
+ },
+ },
+ },
},
new Container
{
@@ -145,7 +177,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
controlBackground.Colour = colourProvider.Background5;
apiFriends.BindTo(api.Friends);
- apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.ToList()), true);
+ apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.Select(f => f.TargetUser).ToList()), true);
}
protected override void LoadComplete()
@@ -155,6 +187,11 @@ namespace osu.Game.Overlays.Dashboard.Friends
onlineStreamControl.Current.BindValueChanged(_ => recreatePanels());
userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels());
userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels());
+ searchTextBox.Current.BindValueChanged(_ =>
+ {
+ if (currentContent.IsNotNull())
+ currentContent.SearchTerm = searchTextBox.Current.Value;
+ });
}
private void recreatePanels()
@@ -188,7 +225,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
}
}
- private void addContentToPlaceholder(Drawable content)
+ private void addContentToPlaceholder(SearchContainer content)
{
loading.Hide();
@@ -204,16 +241,17 @@ namespace osu.Game.Overlays.Dashboard.Friends
currentContent.FadeIn(200, Easing.OutQuint);
}
- private FillFlowContainer createTable(List users)
+ private SearchContainer createTable(List users)
{
var style = userListToolbar.DisplayStyle.Value;
- return new FillFlowContainer
+ return new SearchContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2),
- Children = users.Select(u => createUserPanel(u, style)).ToList()
+ Children = users.Select(u => createUserPanel(u, style)).ToList(),
+ SearchTerm = searchTextBox.Current.Value,
};
}
diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
index 844efa5cf0..af78d62789 100644
--- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
@@ -1,11 +1,21 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Events;
using osu.Framework.Localisation;
+using osu.Game.Graphics;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Overlays.Notifications;
using osu.Game.Resources.Localisation.Web;
+using SharpCompress;
namespace osu.Game.Overlays.Profile.Header.Components
{
@@ -13,15 +23,201 @@ namespace osu.Game.Overlays.Profile.Header.Components
{
public readonly Bindable User = new Bindable();
- public override LocalisableString TooltipText => FriendsStrings.ButtonsDisabled;
+ // Because it is impossible to update the number of friends after the operation,
+ // the number of friends obtained is stored and modified locally.
+ private int followerCount;
+
+ public override LocalisableString TooltipText
+ {
+ get
+ {
+ switch (status.Value)
+ {
+ case FriendStatus.Self:
+ return FriendsStrings.ButtonsDisabled;
+
+ case FriendStatus.None:
+ return FriendsStrings.ButtonsAdd;
+
+ case FriendStatus.NotMutual:
+ case FriendStatus.Mutual:
+ return FriendsStrings.ButtonsRemove;
+ }
+
+ return FriendsStrings.TitleCompact;
+ }
+ }
protected override IconUsage Icon => FontAwesome.Solid.User;
+ private readonly IBindableList apiFriends = new BindableList();
+ private readonly IBindable localUser = new Bindable();
+
+ private readonly Bindable status = new Bindable();
+
+ [Resolved]
+ private OsuColour colour { get; set; } = null!;
+
+ [Resolved]
+ private OverlayColourProvider colourProvider { get; set; } = null!;
+
[BackgroundDependencyLoader]
- private void load()
+ private void load(IAPIProvider api, INotificationOverlay? notifications)
{
- // todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly.
- User.BindValueChanged(user => SetValue(user.NewValue?.User.FollowerCount ?? 0), true);
+ localUser.BindTo(api.LocalUser);
+
+ status.BindValueChanged(_ =>
+ {
+ updateIcon();
+ updateColor();
+ });
+
+ User.BindValueChanged(u =>
+ {
+ followerCount = u.NewValue?.User.FollowerCount ?? 0;
+ updateStatus();
+ }, true);
+
+ apiFriends.BindTo(api.Friends);
+ apiFriends.BindCollectionChanged((_, _) => Schedule(updateStatus));
+
+ Action += () =>
+ {
+ if (User.Value == null)
+ return;
+
+ if (status.Value == FriendStatus.Self)
+ return;
+
+ ShowLoadingLayer();
+
+ APIRequest req = status.Value == FriendStatus.None ? new AddFriendRequest(User.Value.User.OnlineID) : new DeleteFriendRequest(User.Value.User.OnlineID);
+
+ req.Success += () =>
+ {
+ if (req is AddFriendRequest addedRequest)
+ {
+ SetValue(++followerCount);
+ status.Value = addedRequest.Response?.UserRelation.Mutual == true ? FriendStatus.Mutual : FriendStatus.NotMutual;
+ }
+ else
+ {
+ SetValue(--followerCount);
+ status.Value = FriendStatus.None;
+ }
+
+ api.UpdateLocalFriends();
+ HideLoadingLayer();
+ };
+
+ req.Failure += e =>
+ {
+ notifications?.Post(new SimpleNotification
+ {
+ Text = e.Message,
+ Icon = FontAwesome.Solid.Times,
+ });
+
+ HideLoadingLayer();
+ };
+
+ api.Queue(req);
+ };
+ }
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ if (status.Value > FriendStatus.None)
+ {
+ SetIcon(FontAwesome.Solid.UserTimes);
+ }
+
+ return base.OnHover(e);
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ base.OnHoverLost(e);
+
+ updateIcon();
+ }
+
+ private void updateStatus()
+ {
+ SetValue(followerCount);
+
+ if (localUser.Value.OnlineID == User.Value?.User.OnlineID)
+ {
+ status.Value = FriendStatus.Self;
+ return;
+ }
+
+ var friend = apiFriends.FirstOrDefault(u => User.Value?.User.OnlineID == u.TargetID);
+
+ if (friend != null)
+ {
+ status.Value = friend.Mutual ? FriendStatus.Mutual : FriendStatus.NotMutual;
+ }
+ else
+ {
+ status.Value = FriendStatus.None;
+ }
+ }
+
+ private void updateIcon()
+ {
+ switch (status.Value)
+ {
+ case FriendStatus.Self:
+ SetIcon(FontAwesome.Solid.User);
+ break;
+
+ case FriendStatus.None:
+ SetIcon(FontAwesome.Solid.UserPlus);
+ break;
+
+ case FriendStatus.NotMutual:
+ SetIcon(FontAwesome.Solid.User);
+ break;
+
+ case FriendStatus.Mutual:
+ SetIcon(FontAwesome.Solid.UserFriends);
+ break;
+ }
+ }
+
+ private void updateColor()
+ {
+ // https://github.com/ppy/osu-web/blob/0a5367a4a68a6cdf450eb483251b3cf03b3ac7d2/resources/css/bem/user-action-button.less
+
+ switch (status.Value)
+ {
+ case FriendStatus.Self:
+ case FriendStatus.None:
+ IdleColour = colourProvider.Background6;
+ HoverColour = colourProvider.Background5;
+ break;
+
+ case FriendStatus.NotMutual:
+ IdleColour = colour.Green.Opacity(0.7f);
+ HoverColour = IdleColour.Lighten(0.1f);
+ break;
+
+ case FriendStatus.Mutual:
+ IdleColour = colour.Pink.Opacity(0.7f);
+ HoverColour = IdleColour.Lighten(0.1f);
+ break;
+ }
+
+ EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint));
+ }
+
+ private enum FriendStatus
+ {
+ Self,
+ None,
+ NotMutual,
+ Mutual,
}
}
}
diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs
index 414ca4d077..4fa72de5cc 100644
--- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs
@@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Profile.Header.Components
{
@@ -14,6 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{
private readonly Box background;
private readonly Container content;
+ private readonly LoadingLayer loading;
protected override Container Content => content;
@@ -40,11 +42,22 @@ namespace osu.Game.Overlays.Profile.Header.Components
AutoSizeAxes = Axes.X,
RelativeSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10 },
- }
+ },
+ loading = new LoadingLayer(true, false)
}
});
}
+ protected void ShowLoadingLayer()
+ {
+ loading.Show();
+ }
+
+ protected void HideLoadingLayer()
+ {
+ loading.Hide();
+ }
+
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
index 32c5ebee2c..3c2e603da8 100644
--- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
@@ -14,6 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
public abstract partial class ProfileHeaderStatisticsButton : ProfileHeaderButton
{
private readonly OsuSpriteText drawableText;
+ private readonly Container iconContainer;
protected ProfileHeaderStatisticsButton()
{
@@ -26,13 +27,11 @@ namespace osu.Game.Overlays.Profile.Header.Components
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
- new SpriteIcon
+ iconContainer = new Container
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
- Icon = Icon,
- FillMode = FillMode.Fit,
- Size = new Vector2(50, 14)
+ AutoSizeAxes = Axes.Both,
},
drawableText = new OsuSpriteText
{
@@ -43,10 +42,24 @@ namespace osu.Game.Overlays.Profile.Header.Components
}
}
};
+
+ SetIcon(Icon);
}
protected abstract IconUsage Icon { get; }
+ protected void SetIcon(IconUsage icon)
+ {
+ iconContainer.Child = new SpriteIcon
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Icon = icon,
+ FillMode = FillMode.Fit,
+ Size = new Vector2(50, 14)
+ };
+ }
+
protected void SetValue(int value) => drawableText.Text = value.ToLocalisableString("#,##0");
}
}
diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs
index ddf831c23e..083c678176 100644
--- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs
+++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs
@@ -222,7 +222,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input
var button = buttons[i++];
button.UpdateKeyCombination(d);
- tryPersistKeyBinding(button.KeyBinding.Value, advanceToNextBinding: false);
+ tryPersistKeyBinding(button.KeyBinding.Value, advanceToNextBinding: false, restoringDefaults: true);
}
isDefault.Value = true;
@@ -489,12 +489,25 @@ namespace osu.Game.Overlays.Settings.Sections.Input
base.OnFocusLost(e);
}
- private void tryPersistKeyBinding(RealmKeyBinding keyBinding, bool advanceToNextBinding)
+ private bool isConflictingBinding(RealmKeyBinding first, RealmKeyBinding second, bool restoringDefaults)
+ {
+ if (first.ID == second.ID)
+ return false;
+
+ // ignore conflicts with same action bindings during revert. the assumption is that the other binding will be reverted subsequently in the same higher-level operation.
+ // this happens if the bindings for an action are rebound to the same keys, but the ordering of the bindings itself is different.
+ if (restoringDefaults && first.ActionInt == second.ActionInt)
+ return false;
+
+ return first.KeyCombination.Equals(second.KeyCombination);
+ }
+
+ private void tryPersistKeyBinding(RealmKeyBinding keyBinding, bool advanceToNextBinding, bool restoringDefaults = false)
{
List bindings = GetAllSectionBindings();
RealmKeyBinding? existingBinding = keyBinding.KeyCombination.Equals(new KeyCombination(InputKey.None))
? null
- : bindings.FirstOrDefault(other => other.ID != keyBinding.ID && other.KeyCombination.Equals(keyBinding.KeyCombination));
+ : bindings.FirstOrDefault(other => isConflictingBinding(keyBinding, other, restoringDefaults));
if (existingBinding == null)
{
diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs
index 05ab505417..a979575a0b 100644
--- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs
+++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs
@@ -122,7 +122,10 @@ namespace osu.Game.Overlays.Toolbar
rulesetSelectionChannel[r.NewValue] = channel;
channel.Play();
- musicController?.DuckMomentarily(500, new DuckParameters { DuckDuration = 0 });
+
+ // Longer unduck delay for Mania sample
+ int unduckDelay = r.NewValue.OnlineID == 3 ? 750 : 500;
+ musicController?.DuckMomentarily(unduckDelay, new DuckParameters { DuckDuration = 0 });
}
public override bool HandleNonPositionalInput => !Current.Disabled && base.HandleNonPositionalInput;
diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
index ae4239c148..7b6bc37a61 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
@@ -29,6 +29,7 @@ namespace osu.Game.Rulesets.Difficulty
protected const int ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT = 23;
protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25;
protected const int ATTRIB_ID_OK_HIT_WINDOW = 27;
+ protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29;
///
/// The mods which were applied to the beatmap.
diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs
index b07e8399c0..1cb6b69f91 100644
--- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs
+++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs
@@ -26,10 +26,10 @@ namespace osu.Game.Rulesets.Difficulty.Skills
protected virtual int SectionLength => 400;
private double currentSectionPeak; // We also keep track of the peak strain level in the current section.
-
private double currentSectionEnd;
private readonly List strainPeaks = new List();
+ protected readonly List ObjectStrains = new List(); // Store individual strains
protected StrainSkill(Mod[] mods)
: base(mods)
@@ -57,7 +57,25 @@ namespace osu.Game.Rulesets.Difficulty.Skills
currentSectionEnd += SectionLength;
}
- currentSectionPeak = Math.Max(StrainValueAt(current), currentSectionPeak);
+ double strain = StrainValueAt(current);
+ currentSectionPeak = Math.Max(strain, currentSectionPeak);
+
+ // Store the strain value for the object
+ ObjectStrains.Add(strain);
+ }
+
+ ///
+ /// Calculates the number of strains weighted against the top strain.
+ /// The result is scaled by clock rate as it affects the total number of strains.
+ ///
+ public virtual double CountTopWeightedStrains()
+ {
+ if (ObjectStrains.Count == 0)
+ return 0.0;
+
+ double consistentTopStrain = DifficultyValue() / 10; // What would the top strain be if all strain values were identical
+ // Use a weighted sum of all strains. Constants are arbitrary and give nice values
+ return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88))));
}
///
diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
new file mode 100644
index 0000000000..b9efcd683d
--- /dev/null
+++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
@@ -0,0 +1,50 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+
+namespace osu.Game.Rulesets.Difficulty.Utils
+{
+ public static class DifficultyCalculationUtils
+ {
+ ///
+ /// Converts BPM value into milliseconds
+ ///
+ /// Beats per minute
+ /// Which rhythm delimiter to use, default is 1/4
+ /// BPM conveted to milliseconds
+ public static double BPMToMilliseconds(double bpm, int delimiter = 4)
+ {
+ return 60000.0 / delimiter / bpm;
+ }
+
+ ///
+ /// Converts milliseconds value into a BPM value
+ ///
+ /// Milliseconds
+ /// Which rhythm delimiter to use, default is 1/4
+ /// Milliseconds conveted to beats per minute
+ public static double MillisecondsToBPM(double ms, int delimiter = 4)
+ {
+ return 60000.0 / (ms * delimiter);
+ }
+
+ ///
+ /// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function)
+ ///
+ /// Value to calculate the function for
+ /// Maximum value returnable by the function
+ /// Growth rate of the function
+ /// How much the function midpoint is offset from zero
+ /// The output of logistic function of
+ public static double Logistic(double x, double midpointOffset, double multiplier, double maxValue = 1) => maxValue / (1 + Math.Exp(multiplier * (midpointOffset - x)));
+
+ ///
+ /// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function)
+ ///
+ /// Maximum value returnable by the function
+ /// Exponent
+ /// The output of logistic function
+ public static double Logistic(double exponent, double maxValue = 1) => maxValue / (1 + Math.Exp(exponent));
+ }
+}
diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
index 8b36ef189a..8aaab18e36 100644
--- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs
+++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
@@ -68,6 +68,9 @@ namespace osu.Game.Rulesets.Edit
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
+ [Resolved(canBeNull: true)]
+ private NewBeatmapEditorChangeHandler changeHandler { get; set; }
+
public override ComposeBlueprintContainer BlueprintContainer => blueprintContainer;
private ComposeBlueprintContainer blueprintContainer;
@@ -273,7 +276,8 @@ namespace osu.Game.Rulesets.Edit
TernaryStates = CreateTernaryButtons().ToArray();
togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b)));
- sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Zip(BlueprintContainer.SampleAdditionBankTernaryStates).Select(b => new SampleBankTernaryButton(b.First, b.Second)));
+ sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Zip(BlueprintContainer.SampleAdditionBankTernaryStates)
+ .Select(b => new SampleBankTernaryButton(b.First, b.Second)));
SetSelectTool();
@@ -538,25 +542,23 @@ namespace osu.Game.Rulesets.Edit
#region IPlacementHandler
- public void BeginPlacement(HitObject hitObject)
+ public void ShowPlacement(HitObject hitObject)
{
EditorBeatmap.PlacementObject.Value = hitObject;
}
- [Resolved(canBeNull: true)]
- private NewBeatmapEditorChangeHandler changeHandler { get; set; }
-
- public void EndPlacement(HitObject hitObject, bool commit)
+ public void HidePlacement()
{
EditorBeatmap.PlacementObject.Value = null;
+ }
- if (commit)
- {
- new AddHitObjectChange(EditorBeatmap, hitObject).Apply(changeHandler);
+ public void CommitPlacement(HitObject hitObject)
+ {
+ EditorBeatmap.PlacementObject.Value = null;
+ new AddHitObjectChange(EditorBeatmap, hitObject).Apply(changeHandler);
- if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime)
- EditorClock.SeekSmoothlyTo(hitObject.StartTime);
- }
+ if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime)
+ EditorClock.SeekSmoothlyTo(hitObject.StartTime);
}
public void Delete(HitObject hitObject) => new RemoveHitObjectChange(EditorBeatmap, hitObject).Apply(changeHandler);
diff --git a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs
index 760c7b0936..4df2a52743 100644
--- a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs
+++ b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Graphics.Containers;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
@@ -63,18 +64,26 @@ namespace osu.Game.Rulesets.Edit
startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true);
}
+ private bool placementBegun;
+
protected override void BeginPlacement(bool commitStart = false)
{
base.BeginPlacement(commitStart);
- placementHandler.BeginPlacement(HitObject);
+ if (State.Value == Visibility.Visible)
+ placementHandler.ShowPlacement(HitObject);
+
+ placementBegun = true;
}
public override void EndPlacement(bool commit)
{
base.EndPlacement(commit);
- placementHandler.EndPlacement(HitObject, IsValidForPlacement && commit);
+ if (IsValidForPlacement && commit)
+ placementHandler.CommitPlacement(HitObject);
+ else
+ placementHandler.HidePlacement();
}
///
@@ -127,5 +136,19 @@ namespace osu.Game.Rulesets.Edit
/// refreshing and parameters for the .
///
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty);
+
+ protected override void PopIn()
+ {
+ base.PopIn();
+
+ if (placementBegun)
+ placementHandler.ShowPlacement(HitObject);
+ }
+
+ protected override void PopOut()
+ {
+ base.PopOut();
+ placementHandler.HidePlacement();
+ }
}
}
diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
index a36de02433..52b8a5c796 100644
--- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
+++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
@@ -7,7 +7,6 @@ using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Objects;
-using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Edit
@@ -15,7 +14,7 @@ namespace osu.Game.Rulesets.Edit
///
/// A blueprint which governs the placement of something.
///
- public abstract partial class PlacementBlueprint : CompositeDrawable, IKeyBindingHandler
+ public abstract partial class PlacementBlueprint : VisibilityContainer, IKeyBindingHandler
{
///
/// Whether the is currently mid-placement, but has not necessarily finished being placed.
@@ -31,12 +30,17 @@ namespace osu.Game.Rulesets.Edit
///
protected virtual bool IsValidForPlacement => true;
+ // the blueprint should still be considered for input even if it is hidden,
+ // especially when such input is the reason for making the blueprint become visible.
+ public override bool PropagatePositionalInputSubTree => true;
+ public override bool PropagateNonPositionalInputSubTree => true;
+
protected PlacementBlueprint()
{
RelativeSizeAxes = Axes.Both;
- // This is required to allow the blueprint's position to be updated via OnMouseMove/Handle
- // on the same frame it is made visible via a PlacementState change.
+ // the blueprint should still be considered for input even if it is hidden,
+ // especially when such input is the reason for making the blueprint become visible.
AlwaysPresent = true;
}
@@ -104,8 +108,6 @@ namespace osu.Game.Rulesets.Edit
{
}
- public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
-
protected override bool Handle(UIEvent e)
{
base.Handle(e);
@@ -127,6 +129,9 @@ namespace osu.Game.Rulesets.Edit
}
}
+ protected override void PopIn() => this.FadeIn();
+ protected override void PopOut() => this.FadeOut();
+
public enum PlacementState
{
Waiting,
diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs
index 69e676667d..0ffd1072cd 100644
--- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs
@@ -316,9 +316,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
///
/// Refreshes the current placement tool.
///
- private void refreshTool()
+ private void refreshPlacement()
{
- removePlacement();
+ CurrentPlacement?.EndPlacement(false);
+ CurrentPlacement?.Expire();
+ CurrentPlacement = null;
+
ensurePlacementCreated();
}
@@ -344,21 +347,24 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
case PlacementBlueprint.PlacementState.Waiting:
if (!Composer.CursorInPlacementArea)
- removePlacement();
+ CurrentPlacement.Hide();
+ else
+ CurrentPlacement.Show();
+
+ break;
+
+ case PlacementBlueprint.PlacementState.Active:
+ CurrentPlacement.Show();
break;
case PlacementBlueprint.PlacementState.Finished:
- removePlacement();
+ refreshPlacement();
break;
}
- }
- if (Composer.CursorInPlacementArea)
- ensurePlacementCreated();
-
- // updates the placement with the latest editor clock time.
- if (CurrentPlacement != null)
+ // updates the placement with the latest editor clock time.
updatePlacementTimeAndPosition();
+ }
}
protected override bool OnMouseMove(MouseMoveEvent e)
@@ -386,7 +392,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
private void hitObjectAdded(HitObject obj)
{
// refresh the tool to handle the case of placement completing.
- refreshTool();
+ refreshPlacement();
// on successful placement, the new combo button should be reset as this is the most common user interaction.
if (Beatmap.SelectedHitObjects.Count == 0)
@@ -415,14 +421,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
public void CommitIfPlacementActive()
{
CurrentPlacement?.EndPlacement(CurrentPlacement.PlacementActive == PlacementBlueprint.PlacementState.Active);
- removePlacement();
- }
-
- private void removePlacement()
- {
- CurrentPlacement?.EndPlacement(false);
- CurrentPlacement?.Expire();
- CurrentPlacement = null;
+ refreshPlacement();
}
private CompositionTool currentTool;
diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorOrigin.cs b/osu.Game/Screens/Edit/Compose/Components/EditorOrigin.cs
new file mode 100644
index 0000000000..4aa7bf68d7
--- /dev/null
+++ b/osu.Game/Screens/Edit/Compose/Components/EditorOrigin.cs
@@ -0,0 +1,12 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Screens.Edit.Compose.Components
+{
+ public enum EditorOrigin
+ {
+ GridCentre,
+ PlayfieldCentre,
+ SelectionCentre
+ }
+}
diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreakDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreakDisplay.cs
index eca44672f6..381816c546 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreakDisplay.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreakDisplay.cs
@@ -6,6 +6,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Game.Beatmaps.Timing;
+using osu.Game.Configuration;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
@@ -27,6 +28,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private readonly BindableList breaks = new BindableList();
+ private readonly BindableBool showBreaks = new BindableBool(true);
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager configManager)
+ {
+ configManager.BindWith(OsuSetting.EditorTimelineShowBreaks, showBreaks);
+ showBreaks.BindValueChanged(_ => breakCache.Invalidate());
+ }
+
protected override void LoadBeatmap(EditorBeatmap beatmap)
{
base.LoadBeatmap(beatmap);
@@ -67,6 +77,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
Clear();
+ if (!showBreaks.Value)
+ return;
+
for (int i = 0; i < breaks.Count; i++)
{
var breakPeriod = breaks[i];
diff --git a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs
index 57960a76a1..e2046cd532 100644
--- a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs
@@ -10,17 +10,21 @@ namespace osu.Game.Screens.Edit.Compose
public interface IPlacementHandler
{
///
- /// Notifies that a placement has begun.
+ /// Notifies that a placement blueprint became visible on the screen.
///
- /// The being placed.
- void BeginPlacement(HitObject hitObject);
+ /// The representing the placement.
+ void ShowPlacement(HitObject hitObject);
///
- /// Notifies that a placement has finished.
+ /// Notifies that a visible placement blueprint has been hidden.
+ ///
+ void HidePlacement();
+
+ ///
+ /// Notifies that a placement has been committed.
///
/// The that has been placed.
- /// Whether the object should be committed.
- void EndPlacement(HitObject hitObject, bool commit);
+ void CommitPlacement(HitObject hitObject);
///
/// Deletes a .
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index 65846121a8..273bf61b9b 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -216,6 +216,7 @@ namespace osu.Game.Screens.Edit
private Bindable editorAutoSeekOnPlacement;
private Bindable editorLimitedDistanceSnap;
private Bindable editorTimelineShowTimingChanges;
+ private Bindable editorTimelineShowBreaks;
private Bindable editorTimelineShowTicks;
private Bindable editorContractSidebars;
@@ -328,6 +329,7 @@ namespace osu.Game.Screens.Edit
editorAutoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement);
editorLimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap);
editorTimelineShowTimingChanges = config.GetBindable(OsuSetting.EditorTimelineShowTimingChanges);
+ editorTimelineShowBreaks = config.GetBindable(OsuSetting.EditorTimelineShowBreaks);
editorTimelineShowTicks = config.GetBindable(OsuSetting.EditorTimelineShowTicks);
editorContractSidebars = config.GetBindable(OsuSetting.EditorContractSidebars);
@@ -395,6 +397,10 @@ namespace osu.Game.Screens.Edit
{
State = { BindTarget = editorTimelineShowTicks }
},
+ new ToggleMenuItem(EditorStrings.TimelineShowBreaks)
+ {
+ State = { BindTarget = editorTimelineShowBreaks }
+ },
]
},
new BackgroundDimMenuItem(editorBackgroundDim),
@@ -420,7 +426,7 @@ namespace osu.Game.Screens.Edit
{
Items = new MenuItem[]
{
- new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime)
+ new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime),
}
}
}
diff --git a/osu.Game/Screens/Edit/Timing/GroupSection.cs b/osu.Game/Screens/Edit/Timing/GroupSection.cs
index d715c3ebc9..13e802a8e4 100644
--- a/osu.Game/Screens/Edit/Timing/GroupSection.cs
+++ b/osu.Game/Screens/Edit/Timing/GroupSection.cs
@@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osuTK;
@@ -25,6 +26,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved]
protected EditorBeatmap Beatmap { get; private set; } = null!;
+ [Resolved]
+ private OsuConfigManager configManager { get; set; } = null!;
+
[Resolved]
private EditorClock clock { get; set; } = null!;
@@ -110,7 +114,16 @@ namespace osu.Game.Screens.Edit.Timing
Beatmap.ControlPointInfo.RemoveGroup(SelectedGroup.Value);
foreach (var cp in currentGroupItems)
+ {
+ // Only adjust hit object offsets if the group contains a timing control point
+ if (cp is TimingControlPoint tp && configManager.Get(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges))
+ {
+ TimingSectionAdjustments.AdjustHitObjectOffset(Beatmap, tp, time - SelectedGroup.Value.Time);
+ Beatmap.UpdateAllHitObjects();
+ }
+
Beatmap.ControlPointInfo.Add(time, cp);
+ }
// the control point might not necessarily exist yet, if currentGroupItems was empty.
SelectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(time, true);
diff --git a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs
index 8cdbd97ecb..f105c00726 100644
--- a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs
+++ b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs
@@ -10,6 +10,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
@@ -26,6 +27,9 @@ namespace osu.Game.Screens.Edit.Timing
[Resolved]
private EditorBeatmap beatmap { get; set; } = null!;
+ [Resolved]
+ private OsuConfigManager configManager { get; set; } = null!;
+
[Resolved]
private Bindable selectedGroup { get; set; } = null!;
@@ -202,15 +206,25 @@ namespace osu.Game.Screens.Edit.Timing
// VERY TEMPORARY
var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray();
+ beatmap.BeginChange();
beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value);
double newOffset = selectedGroup.Value.Time + adjust;
foreach (var cp in currentGroupItems)
+ {
+ if (cp is TimingControlPoint tp)
+ {
+ TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, tp, adjust);
+ beatmap.UpdateAllHitObjects();
+ }
+
beatmap.ControlPointInfo.Add(newOffset, cp);
+ }
// the control point might not necessarily exist yet, if currentGroupItems was empty.
selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true);
+ beatmap.EndChange();
if (!editorClock.IsRunning && wasAtStart)
editorClock.Seek(newOffset);
@@ -223,7 +237,16 @@ namespace osu.Game.Screens.Edit.Timing
if (timing == null)
return;
+ double oldBeatLength = timing.BeatLength;
timing.BeatLength = 60000 / (timing.BPM + adjust);
+
+ if (configManager.Get(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges))
+ {
+ beatmap.BeginChange();
+ TimingSectionAdjustments.SetHitObjectBPM(beatmap, timing, oldBeatLength);
+ beatmap.UpdateAllHitObjects();
+ beatmap.EndChange();
+ }
}
private partial class InlineButton : OsuButton
diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs
index 838eb1f9fd..ae1ac02dd6 100644
--- a/osu.Game/Screens/Edit/Timing/TimingSection.cs
+++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs
@@ -1,11 +1,14 @@
// 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.Beatmaps.ControlPoints;
+using osu.Game.Configuration;
using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Localisation;
namespace osu.Game.Screens.Edit.Timing
{
@@ -15,11 +18,20 @@ namespace osu.Game.Screens.Edit.Timing
private LabelledSwitchButton omitBarLine = null!;
private BPMTextBox bpmTextEntry = null!;
+ [Resolved]
+ private OsuConfigManager configManager { get; set; } = null!;
+
[BackgroundDependencyLoader]
private void load()
{
Flow.AddRange(new Drawable[]
{
+ new LabelledSwitchButton
+ {
+ Label = EditorStrings.AdjustExistingObjectsOnTimingChanges,
+ FixedLabelWidth = 220,
+ Current = configManager.GetBindable(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges),
+ },
new TapTimingControl(),
bpmTextEntry = new BPMTextBox(),
timeSignature = new LabelledTimeSignature
@@ -42,6 +54,17 @@ namespace osu.Game.Screens.Edit.Timing
{
if (!isRebinding) ChangeHandler?.SaveState();
}
+
+ bpmTextEntry.OnCommit = (oldBeatLength, _) =>
+ {
+ if (!configManager.Get(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges) || ControlPoint.Value == null)
+ return;
+
+ Beatmap.BeginChange();
+ TimingSectionAdjustments.SetHitObjectBPM(Beatmap, ControlPoint.Value, oldBeatLength);
+ Beatmap.UpdateAllHitObjects();
+ Beatmap.EndChange();
+ };
}
private bool isRebinding;
@@ -74,6 +97,8 @@ namespace osu.Game.Screens.Edit.Timing
private partial class BPMTextBox : LabelledTextBox
{
+ public new Action? OnCommit { get; set; }
+
private readonly BindableNumber beatLengthBindable = new TimingControlPoint().BeatLengthBindable;
public BPMTextBox()
@@ -81,10 +106,12 @@ namespace osu.Game.Screens.Edit.Timing
Label = "BPM";
SelectAllOnFocus = true;
- OnCommit += (_, isNew) =>
+ base.OnCommit += (_, isNew) =>
{
if (!isNew) return;
+ double oldBeatLength = beatLengthBindable.Value;
+
try
{
if (double.TryParse(Current.Value, out double doubleVal) && doubleVal > 0)
@@ -98,6 +125,7 @@ namespace osu.Game.Screens.Edit.Timing
// This is run regardless of parsing success as the parsed number may not actually trigger a change
// due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state.
beatLengthBindable.TriggerChange();
+ OnCommit?.Invoke(oldBeatLength, beatLengthBindable.Value);
};
beatLengthBindable.BindValueChanged(val =>
diff --git a/osu.Game/Screens/Edit/Timing/TimingSectionAdjustments.cs b/osu.Game/Screens/Edit/Timing/TimingSectionAdjustments.cs
new file mode 100644
index 0000000000..65edc47ff5
--- /dev/null
+++ b/osu.Game/Screens/Edit/Timing/TimingSectionAdjustments.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.Collections.Generic;
+using System.Linq;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+
+namespace osu.Game.Screens.Edit.Timing
+{
+ public static class TimingSectionAdjustments
+ {
+ ///
+ /// Returns all objects from which are affected by the supplied .
+ ///
+ public static List HitObjectsInTimingRange(IBeatmap beatmap, TimingControlPoint timingControlPoint)
+ {
+ // If the first group, we grab all hitobjects prior to the next, if the last group, we grab all remaining hitobjects
+ double startTime = beatmap.ControlPointInfo.TimingPoints.Any(x => x.Time < timingControlPoint.Time) ? timingControlPoint.Time : double.MinValue;
+ double endTime = beatmap.ControlPointInfo.TimingPoints.FirstOrDefault(x => x.Time > timingControlPoint.Time)?.Time ?? double.MaxValue;
+
+ return beatmap.HitObjects.Where(x => Precision.AlmostBigger(x.StartTime, startTime) && Precision.DefinitelyBigger(endTime, x.StartTime)).ToList();
+ }
+
+ ///
+ /// Moves all relevant objects after 's offset has been changed by .
+ ///
+ public static void AdjustHitObjectOffset(IBeatmap beatmap, TimingControlPoint timingControlPoint, double adjustment)
+ {
+ foreach (HitObject hitObject in HitObjectsInTimingRange(beatmap, timingControlPoint))
+ {
+ hitObject.StartTime += adjustment;
+ }
+ }
+
+ ///
+ /// Ensures all relevant objects are still snapped to the same beats after 's beat length / BPM has been changed.
+ ///
+ public static void SetHitObjectBPM(IBeatmap beatmap, TimingControlPoint timingControlPoint, double oldBeatLength)
+ {
+ foreach (HitObject hitObject in HitObjectsInTimingRange(beatmap, timingControlPoint))
+ {
+ double beat = (hitObject.StartTime - timingControlPoint.Time) / oldBeatLength;
+
+ hitObject.StartTime = (beat * timingControlPoint.BeatLength) + timingControlPoint.Time;
+
+ if (hitObject is not IHasRepeats && hitObject is IHasDuration hitObjectWithDuration)
+ hitObjectWithDuration.Duration *= timingControlPoint.BeatLength / oldBeatLength;
+ }
+ }
+ }
+}
diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs
index b48046d190..677a3b0278 100644
--- a/osu.Game/Screens/Menu/StorageErrorDialog.cs
+++ b/osu.Game/Screens/Menu/StorageErrorDialog.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
+using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Localisation;
using osu.Game.Overlays;
@@ -16,6 +17,9 @@ namespace osu.Game.Screens.Menu
[Resolved]
private IDialogOverlay dialogOverlay { get; set; } = null!;
+ [Resolved]
+ private RealmAccess realmAccess { get; set; } = null!;
+
public StorageErrorDialog(OsuStorage storage, OsuStorageError error)
{
HeaderText = StorageErrorDialogStrings.StorageError;
@@ -35,7 +39,15 @@ namespace osu.Game.Screens.Menu
Text = StorageErrorDialogStrings.TryAgain,
Action = () =>
{
- if (!storage.TryChangeToCustomStorage(out var nextError))
+ bool success;
+ OsuStorageError nextError;
+
+ // blocking all operations has a side effect of closing & reopening the realm db,
+ // which is desirable here since the restoration of the old storage - if it succeeds - means the realm db has moved.
+ using (realmAccess.BlockAllOperations(@"restoration of previously unavailable storage"))
+ success = storage.TryChangeToCustomStorage(out nextError);
+
+ if (!success)
dialogOverlay.Push(new StorageErrorDialog(storage, nextError));
}
},
diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs
index adfc44fbd4..09aafa415a 100644
--- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs
+++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs
@@ -1,23 +1,25 @@
// 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.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
-using osu.Game.Screens.OnlinePlay.Multiplayer;
+using osu.Game.Online.Multiplayer;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
- public partial class RankRangePill : MultiplayerRoomComposite
+ public partial class RankRangePill : CompositeDrawable
{
- private OsuTextFlowContainer rankFlow;
+ private OsuTextFlowContainer rankFlow = null!;
+
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
public RankRangePill()
{
@@ -55,20 +57,28 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
};
}
- protected override void OnRoomUpdated()
+ protected override void LoadComplete()
{
- base.OnRoomUpdated();
+ base.LoadComplete();
+ client.RoomUpdated += onRoomUpdated;
+ updateState();
+ }
+
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
+
+ private void updateState()
+ {
rankFlow.Clear();
- if (Room == null || Room.Users.All(u => u.User == null))
+ if (client.Room == null || client.Room.Users.All(u => u.User == null))
{
rankFlow.AddText("-");
return;
}
- int minRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Min();
- int maxRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Max();
+ int minRank = client.Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Min();
+ int maxRank = client.Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Max();
rankFlow.AddText("#");
rankFlow.AddText(minRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold));
@@ -78,5 +88,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
rankFlow.AddText("#");
rankFlow.AddText(maxRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold));
}
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ client.RoomUpdated -= onRoomUpdated;
+ }
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs
index ba3508b24f..a82fa6e4bc 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs
@@ -1,47 +1,50 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Diagnostics;
using System.Linq;
-using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Threading;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.Countdown;
+using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
- public partial class MatchStartControl : MultiplayerRoomComposite
+ public partial class MatchStartControl : CompositeDrawable
{
[Resolved]
- private OngoingOperationTracker ongoingOperationTracker { get; set; }
-
- [CanBeNull]
- private IDisposable clickOperation;
+ private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!;
[Resolved(canBeNull: true)]
- private IDialogOverlay dialogOverlay { get; set; }
+ private IDialogOverlay? dialogOverlay { get; set; }
- private Sample sampleReady;
- private Sample sampleReadyAll;
- private Sample sampleUnready;
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
+
+ [Resolved]
+ private IBindable currentItem { get; set; } = null!;
private readonly MultiplayerReadyButton readyButton;
private readonly MultiplayerCountdownButton countdownButton;
+
+ private IBindable operationInProgress = null!;
+ private ScheduledDelegate? readySampleDelegate;
+ private IDisposable? clickOperation;
+ private Sample? sampleReady;
+ private Sample? sampleReadyAll;
+ private Sample? sampleUnready;
private int countReady;
- private ScheduledDelegate readySampleDelegate;
- private IBindable operationInProgress;
public MatchStartControl()
{
@@ -91,34 +94,29 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
base.LoadComplete();
- CurrentPlaylistItem.BindValueChanged(_ => updateState());
- }
-
- protected override void OnRoomUpdated()
- {
- base.OnRoomUpdated();
+ currentItem.BindValueChanged(_ => updateState());
+ client.RoomUpdated += onRoomUpdated;
+ client.LoadRequested += onLoadRequested;
updateState();
}
- protected override void OnRoomLoadRequested()
- {
- base.OnRoomLoadRequested();
- endOperation();
- }
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
+
+ private void onLoadRequested() => Scheduler.AddOnce(endOperation);
private void onReadyButtonClick()
{
- if (Room == null)
+ if (client.Room == null)
return;
Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation();
- if (Client.IsHost)
+ if (client.IsHost)
{
- if (Room.State == MultiplayerRoomState.Open)
+ if (client.Room.State == MultiplayerRoomState.Open)
{
- if (isReady() && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown))
+ if (isReady() && !client.Room.ActiveCountdowns.Any(c => c is MatchStartCountdown))
startMatch();
else
toggleReady();
@@ -131,16 +129,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
dialogOverlay.Push(new ConfirmAbortDialog(abortMatch, endOperation));
}
}
- else if (Room.State != MultiplayerRoomState.Closed)
+ else if (client.Room.State != MultiplayerRoomState.Closed)
toggleReady();
- bool isReady() => Client.LocalUser?.State == MultiplayerUserState.Ready || Client.LocalUser?.State == MultiplayerUserState.Spectating;
+ bool isReady() => client.LocalUser?.State == MultiplayerUserState.Ready || client.LocalUser?.State == MultiplayerUserState.Spectating;
- void toggleReady() => Client.ToggleReady().FireAndForget(
+ void toggleReady() => client.ToggleReady().FireAndForget(
onSuccess: endOperation,
onError: _ => endOperation());
- void startMatch() => Client.StartMatch().FireAndForget(onSuccess: () =>
+ void startMatch() => client.StartMatch().FireAndForget(onSuccess: () =>
{
// gameplay is starting, the button will be unblocked on load requested.
}, onError: _ =>
@@ -149,7 +147,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
endOperation();
});
- void abortMatch() => Client.AbortMatch().FireAndForget(endOperation, _ => endOperation());
+ void abortMatch() => client.AbortMatch().FireAndForget(endOperation, _ => endOperation());
}
private void startCountdown(TimeSpan duration)
@@ -157,19 +155,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation();
- Client.SendMatchRequest(new StartMatchCountdownRequest { Duration = duration }).ContinueWith(_ => endOperation());
+ client.SendMatchRequest(new StartMatchCountdownRequest { Duration = duration }).ContinueWith(_ => endOperation());
}
private void cancelCountdown()
{
- if (Client.Room == null)
+ if (client.Room == null)
return;
Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation();
- MultiplayerCountdown countdown = Client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown);
- Client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation());
+ MultiplayerCountdown countdown = client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown);
+ client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation());
}
private void endOperation()
@@ -180,19 +178,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void updateState()
{
- if (Room == null)
+ if (client.Room == null)
{
readyButton.Enabled.Value = false;
countdownButton.Enabled.Value = false;
return;
}
- var localUser = Client.LocalUser;
+ var localUser = client.LocalUser;
- int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready);
- int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating);
+ int newCountReady = client.Room.Users.Count(u => u.State == MultiplayerUserState.Ready);
+ int newCountTotal = client.Room.Users.Count(u => u.State != MultiplayerUserState.Spectating);
- if (!Client.IsHost || Room.Settings.AutoStartEnabled)
+ if (!client.IsHost || client.Room.Settings.AutoStartEnabled)
countdownButton.Hide();
else
{
@@ -211,21 +209,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
readyButton.Enabled.Value = countdownButton.Enabled.Value =
- Room.State != MultiplayerRoomState.Closed
- && CurrentPlaylistItem.Value?.ID == Room.Settings.PlaylistItemId
- && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired
+ client.Room.State != MultiplayerRoomState.Closed
+ && currentItem.Value?.ID == client.Room.Settings.PlaylistItemId
+ && !client.Room.Playlist.Single(i => i.ID == client.Room.Settings.PlaylistItemId).Expired
&& !operationInProgress.Value;
// When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready.
if (localUser?.State == MultiplayerUserState.Spectating)
- readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown);
+ readyButton.Enabled.Value &= client.IsHost && newCountReady > 0 && !client.Room.ActiveCountdowns.Any(c => c is MatchStartCountdown);
// When the local user is not the host, the button should only be enabled when no match is in progress.
- if (!Client.IsHost)
- readyButton.Enabled.Value &= Room.State == MultiplayerRoomState.Open;
+ if (!client.IsHost)
+ readyButton.Enabled.Value &= client.Room.State == MultiplayerRoomState.Open;
// At all times, the countdown button should only be enabled when no match is in progress.
- countdownButton.Enabled.Value &= Room.State == MultiplayerRoomState.Open;
+ countdownButton.Enabled.Value &= client.Room.State == MultiplayerRoomState.Open;
if (newCountReady == countReady)
return;
@@ -249,6 +247,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
});
}
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ {
+ client.RoomUpdated -= onRoomUpdated;
+ client.LoadRequested -= onLoadRequested;
+ }
+ }
+
public partial class ConfirmAbortDialog : DangerousActionDialog
{
public ConfirmAbortDialog(Action abortMatch, Action cancel)
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
index ea7ab2dce3..92edc9b979 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
@@ -5,7 +5,9 @@ using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
@@ -17,7 +19,7 @@ using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
- public partial class MultiplayerSpectateButton : MultiplayerRoomComposite
+ public partial class MultiplayerSpectateButton : CompositeDrawable
{
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!;
@@ -25,6 +27,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
[Resolved]
private OsuColour colours { get; set; } = null!;
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
+
+ [Resolved]
+ private IBindable currentItem { get; set; } = null!;
+
private IBindable operationInProgress = null!;
private readonly RoundedButton button;
@@ -44,7 +52,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
var clickOperation = ongoingOperationTracker.BeginOperation();
- Client.ToggleSpectate().ContinueWith(_ => endOperation());
+ client.ToggleSpectate().ContinueWith(_ => endOperation());
void endOperation() => clickOperation?.Dispose();
}
@@ -63,19 +71,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
base.LoadComplete();
- CurrentPlaylistItem.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload), true);
- }
-
- protected override void OnRoomUpdated()
- {
- base.OnRoomUpdated();
-
+ currentItem.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload), true);
+ client.RoomUpdated += onRoomUpdated;
updateState();
}
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
+
private void updateState()
{
- switch (Client.LocalUser?.State)
+ switch (client.LocalUser?.State)
{
default:
button.Text = "Spectate";
@@ -88,8 +93,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
break;
}
- button.Enabled.Value = Client.Room != null
- && Client.Room.State != MultiplayerRoomState.Closed
+ button.Enabled.Value = client.Room != null
+ && client.Room.State != MultiplayerRoomState.Closed
&& !operationInProgress.Value;
Scheduler.AddOnce(checkForAutomaticDownload);
@@ -112,11 +117,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void checkForAutomaticDownload()
{
- PlaylistItem? currentItem = CurrentPlaylistItem.Value;
+ PlaylistItem? item = currentItem.Value;
downloadCheckCancellation?.Cancel();
- if (currentItem == null)
+ if (item == null)
return;
if (!automaticallyDownload.Value)
@@ -128,13 +133,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
//
// Rather than over-complicating this flow, let's only auto-download when spectating for the time being.
// A potential path forward would be to have a local auto-download checkbox above the playlist item list area.
- if (Client.LocalUser?.State != MultiplayerUserState.Spectating)
+ if (client.LocalUser?.State != MultiplayerUserState.Spectating)
return;
// In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes.
// ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised.
beatmapLookupCache
- .GetBeatmapAsync(currentItem.Beatmap.OnlineID, (downloadCheckCancellation = new CancellationTokenSource()).Token)
+ .GetBeatmapAsync(item.Beatmap.OnlineID, (downloadCheckCancellation = new CancellationTokenSource()).Token)
.ContinueWith(resolved => Schedule(() =>
{
var beatmapSet = resolved.GetResultSafely()?.BeatmapSet;
@@ -150,5 +155,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}
#endregion
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ client.RoomUpdated -= onRoomUpdated;
+ }
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
index 2d08d8ecf6..8ba85019d5 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Linq;
using osu.Framework.Allocation;
@@ -17,18 +15,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
///
/// The multiplayer playlist, containing lists to show the items from a in both gameplay-order and historical-order.
///
- public partial class MultiplayerPlaylist : MultiplayerRoomComposite
+ public partial class MultiplayerPlaylist : CompositeDrawable
{
public readonly Bindable DisplayMode = new Bindable();
///
/// Invoked when an item requests to be edited.
///
- public Action RequestEdit;
+ public Action? RequestEdit;
- private MultiplayerPlaylistTabControl playlistTabControl;
- private MultiplayerQueueList queueList;
- private MultiplayerHistoryList historyList;
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
+
+ [Resolved]
+ private IBindable currentItem { get; set; } = null!;
+
+ private MultiplayerPlaylistTabControl playlistTabControl = null!;
+ private MultiplayerQueueList queueList = null!;
+ private MultiplayerHistoryList historyList = null!;
private bool firstPopulation = true;
[BackgroundDependencyLoader]
@@ -54,14 +58,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
queueList = new MultiplayerQueueList
{
RelativeSizeAxes = Axes.Both,
- SelectedItem = { BindTarget = CurrentPlaylistItem },
+ SelectedItem = { BindTarget = currentItem },
RequestEdit = item => RequestEdit?.Invoke(item)
},
historyList = new MultiplayerHistoryList
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
- SelectedItem = { BindTarget = CurrentPlaylistItem }
+ SelectedItem = { BindTarget = currentItem }
}
}
}
@@ -73,7 +77,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
protected override void LoadComplete()
{
base.LoadComplete();
+
DisplayMode.BindValueChanged(onDisplayModeChanged, true);
+ client.ItemAdded += playlistItemAdded;
+ client.ItemRemoved += playlistItemRemoved;
+ client.ItemChanged += playlistItemChanged;
+ client.RoomUpdated += onRoomUpdated;
+ updateState();
}
private void onDisplayModeChanged(ValueChangedEvent mode)
@@ -82,11 +92,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
queueList.FadeTo(mode.NewValue == MultiplayerPlaylistDisplayMode.Queue ? 1 : 0, 100);
}
- protected override void OnRoomUpdated()
- {
- base.OnRoomUpdated();
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
- if (Room == null)
+ private void updateState()
+ {
+ if (client.Room == null)
{
historyList.Items.Clear();
queueList.Items.Clear();
@@ -96,34 +106,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
if (firstPopulation)
{
- foreach (var item in Room.Playlist)
+ foreach (var item in client.Room.Playlist)
addItemToLists(item);
firstPopulation = false;
}
}
- protected override void PlaylistItemAdded(MultiplayerPlaylistItem item)
- {
- base.PlaylistItemAdded(item);
- addItemToLists(item);
- }
+ private void playlistItemAdded(MultiplayerPlaylistItem item) => Schedule(() => addItemToLists(item));
- protected override void PlaylistItemRemoved(long item)
- {
- base.PlaylistItemRemoved(item);
- removeItemFromLists(item);
- }
+ private void playlistItemRemoved(long item) => Schedule(() => removeItemFromLists(item));
- protected override void PlaylistItemChanged(MultiplayerPlaylistItem item)
+ private void playlistItemChanged(MultiplayerPlaylistItem item) => Schedule(() =>
{
- base.PlaylistItemChanged(item);
+ if (client.Room == null)
+ return;
- var newApiItem = Playlist.SingleOrDefault(i => i.ID == item.ID);
+ var newApiItem = new PlaylistItem(item);
var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID);
// Test if the only change between the two playlist items is the order.
- if (newApiItem != null && existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem))
+ if (existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem))
{
// Set the new playlist order directly without refreshing the DrawablePlaylistItem.
existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder;
@@ -137,20 +140,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
removeItemFromLists(item.ID);
addItemToLists(item);
}
- }
+ });
private void addItemToLists(MultiplayerPlaylistItem item)
{
- var apiItem = Playlist.SingleOrDefault(i => i.ID == item.ID);
+ var apiItem = client.Room?.Playlist.SingleOrDefault(i => i.ID == item.ID);
// Item could have been removed from the playlist while the local player was in gameplay.
if (apiItem == null)
return;
if (item.Expired)
- historyList.Items.Add(apiItem);
+ historyList.Items.Add(new PlaylistItem(apiItem));
else
- queueList.Items.Add(apiItem);
+ queueList.Items.Add(new PlaylistItem(apiItem));
}
private void removeItemFromLists(long item)
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
deleted file mode 100644
index ee5c84bf40..0000000000
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
+++ /dev/null
@@ -1,125 +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 JetBrains.Annotations;
-using osu.Framework.Allocation;
-using osu.Game.Online.Multiplayer;
-using osu.Game.Online.Rooms;
-
-namespace osu.Game.Screens.OnlinePlay.Multiplayer
-{
- public abstract partial class MultiplayerRoomComposite : OnlinePlayComposite
- {
- [CanBeNull]
- protected MultiplayerRoom Room => Client.Room;
-
- [Resolved]
- protected MultiplayerClient Client { get; private set; }
-
- protected override void LoadComplete()
- {
- base.LoadComplete();
-
- Client.RoomUpdated += invokeOnRoomUpdated;
- Client.LoadRequested += invokeOnRoomLoadRequested;
- Client.UserLeft += invokeUserLeft;
- Client.UserKicked += invokeUserKicked;
- Client.UserJoined += invokeUserJoined;
- Client.ItemAdded += invokeItemAdded;
- Client.ItemRemoved += invokeItemRemoved;
- Client.ItemChanged += invokeItemChanged;
-
- OnRoomUpdated();
- }
-
- private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated);
- private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => UserJoined(user));
- private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.Add(() => UserKicked(user));
- private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => UserLeft(user));
- private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item));
- private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item));
- private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item));
- private void invokeOnRoomLoadRequested() => Scheduler.AddOnce(OnRoomLoadRequested);
-
- ///
- /// Invoked when a user has joined the room.
- ///
- /// The user.
- protected virtual void UserJoined(MultiplayerRoomUser user)
- {
- }
-
- ///
- /// Invoked when a user has been kicked from the room (including the local user).
- ///
- /// The user.
- protected virtual void UserKicked(MultiplayerRoomUser user)
- {
- }
-
- ///
- /// Invoked when a user has left the room.
- ///
- /// The user.
- protected virtual void UserLeft(MultiplayerRoomUser user)
- {
- }
-
- ///
- /// Invoked when a playlist item is added to the room.
- ///
- /// The added playlist item.
- protected virtual void PlaylistItemAdded(MultiplayerPlaylistItem item)
- {
- }
-
- ///
- /// Invoked when a playlist item is removed from the room.
- ///
- /// The ID of the removed playlist item.
- protected virtual void PlaylistItemRemoved(long item)
- {
- }
-
- ///
- /// Invoked when a playlist item is changed in the room.
- ///
- /// The new playlist item, with an existing item's ID.
- protected virtual void PlaylistItemChanged(MultiplayerPlaylistItem item)
- {
- }
-
- ///
- /// Invoked when any change occurs to the multiplayer room.
- ///
- protected virtual void OnRoomUpdated()
- {
- }
-
- ///
- /// Invoked when the room requests the local user to load into gameplay.
- ///
- protected virtual void OnRoomLoadRequested()
- {
- }
-
- protected override void Dispose(bool isDisposing)
- {
- if (Client != null)
- {
- Client.RoomUpdated -= invokeOnRoomUpdated;
- Client.LoadRequested -= invokeOnRoomLoadRequested;
- Client.UserLeft -= invokeUserLeft;
- Client.UserKicked -= invokeUserKicked;
- Client.UserJoined -= invokeUserJoined;
- Client.ItemAdded -= invokeItemAdded;
- Client.ItemRemoved -= invokeItemRemoved;
- Client.ItemChanged -= invokeItemChanged;
- }
-
- base.Dispose(isDisposing);
- }
- }
-}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs
index 90595bc33b..d53e485c86 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs
@@ -1,23 +1,26 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
-using osu.Framework.Bindables;
-using osu.Game.Online.API.Requests.Responses;
+using osu.Framework.Extensions.ObjectExtensions;
+using osu.Framework.Graphics.Containers;
using osu.Game.Online.Multiplayer;
namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
- public partial class MultiplayerRoomSounds : MultiplayerRoomComposite
+ public partial class MultiplayerRoomSounds : CompositeDrawable
{
- private Sample hostChangedSample;
- private Sample userJoinedSample;
- private Sample userLeftSample;
- private Sample userKickedSample;
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
+
+ private Sample? hostChangedSample;
+ private Sample? userJoinedSample;
+ private Sample? userLeftSample;
+ private Sample? userKickedSample;
+ private MultiplayerRoomUser? host;
[BackgroundDependencyLoader]
private void load(AudioManager audio)
@@ -32,36 +35,47 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
base.LoadComplete();
- Host.BindValueChanged(hostChanged);
+ client.RoomUpdated += onRoomUpdated;
+ client.UserJoined += onUserJoined;
+ client.UserLeft += onUserLeft;
+ client.UserKicked += onUserKicked;
+ updateState();
}
- protected override void UserJoined(MultiplayerRoomUser user)
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
+
+ private void updateState()
{
- base.UserJoined(user);
+ if (EqualityComparer.Default.Equals(host, client.Room?.Host))
+ return;
- Scheduler.AddOnce(() => userJoinedSample?.Play());
- }
-
- protected override void UserLeft(MultiplayerRoomUser user)
- {
- base.UserLeft(user);
-
- Scheduler.AddOnce(() => userLeftSample?.Play());
- }
-
- protected override void UserKicked(MultiplayerRoomUser user)
- {
- base.UserKicked(user);
-
- Scheduler.AddOnce(() => userKickedSample?.Play());
- }
-
- private void hostChanged(ValueChangedEvent value)
- {
// only play sound when the host changes from an already-existing host.
- if (value.OldValue == null) return;
+ if (host != null)
+ Scheduler.AddOnce(() => hostChangedSample?.Play());
- Scheduler.AddOnce(() => hostChangedSample?.Play());
+ host = client.Room?.Host;
+ }
+
+ private void onUserJoined(MultiplayerRoomUser user)
+ => Scheduler.AddOnce(() => userJoinedSample?.Play());
+
+ private void onUserLeft(MultiplayerRoomUser user)
+ => Scheduler.AddOnce(() => userLeftSample?.Play());
+
+ private void onUserKicked(MultiplayerRoomUser user)
+ => Scheduler.AddOnce(() => userKickedSample?.Play());
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ {
+ client.RoomUpdated -= onRoomUpdated;
+ client.UserJoined -= onUserJoined;
+ client.UserLeft -= onUserLeft;
+ client.UserKicked -= onUserKicked;
+ }
}
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs
index c79c210e30..7e42b18240 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
@@ -30,7 +31,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{
- public partial class ParticipantPanel : MultiplayerRoomComposite, IHasContextMenu
+ public partial class ParticipantPanel : CompositeDrawable, IHasContextMenu
{
public readonly MultiplayerRoomUser User;
@@ -40,6 +41,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
[Resolved]
private IRulesetStore rulesets { get; set; } = null!;
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
+
private SpriteIcon crown = null!;
private OsuSpriteText userRankText = null!;
@@ -171,23 +175,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Origin = Anchor.Centre,
Alpha = 0,
Margin = new MarginPadding(4),
- Action = () => Client.KickUser(User.UserID).FireAndForget(),
+ Action = () => client.KickUser(User.UserID).FireAndForget(),
},
},
}
};
}
- protected override void OnRoomUpdated()
+ protected override void LoadComplete()
{
- base.OnRoomUpdated();
+ base.LoadComplete();
- if (Room == null || Client.LocalUser == null)
+ client.RoomUpdated += onRoomUpdated;
+ updateState();
+ }
+
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
+
+ private void updateState()
+ {
+ if (client.Room == null || client.LocalUser == null)
return;
const double fade_time = 50;
- var currentItem = Playlist.GetCurrentItem();
+ MultiplayerPlaylistItem? currentItem = client.Room.GetCurrentItem();
Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null;
int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null;
@@ -200,8 +212,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
else
userModsDisplay.FadeOut(fade_time);
- kickButton.Alpha = Client.IsHost && !User.Equals(Client.LocalUser) ? 1 : 0;
- crown.Alpha = Room.Host?.Equals(User) == true ? 1 : 0;
+ kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0;
+ crown.Alpha = client.Room.Host?.Equals(User) == true ? 1 : 0;
// If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187
// This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix.
@@ -215,7 +227,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{
get
{
- if (Room == null)
+ if (client.Room == null)
return null;
// If the local user is targetted.
@@ -223,7 +235,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
return null;
// If the local user is not the host of the room.
- if (Room.Host?.UserID != api.LocalUser.Value.Id)
+ if (client.Room.Host?.UserID != api.LocalUser.Value.Id)
return null;
int targetUser = User.UserID;
@@ -233,23 +245,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
new OsuMenuItem("Give host", MenuItemType.Standard, () =>
{
// Ensure the local user is still host.
- if (!Client.IsHost)
+ if (!client.IsHost)
return;
- Client.TransferHost(targetUser).FireAndForget();
+ client.TransferHost(targetUser).FireAndForget();
}),
new OsuMenuItem("Kick", MenuItemType.Destructive, () =>
{
// Ensure the local user is still host.
- if (!Client.IsHost)
+ if (!client.IsHost)
return;
- Client.KickUser(targetUser).FireAndForget();
+ client.KickUser(targetUser).FireAndForget();
})
};
}
}
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ client.RoomUpdated -= onRoomUpdated;
+ }
+
public partial class KickButton : IconButton
{
public KickButton()
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs
index 6a7a3758c3..a9d7f4ab52 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs
@@ -1,24 +1,24 @@
// 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.Linq;
-using JetBrains.Annotations;
using osu.Framework.Allocation;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
+using osu.Game.Online.Multiplayer;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{
- public partial class ParticipantsList : MultiplayerRoomComposite
+ public partial class ParticipantsList : CompositeDrawable
{
- private FillFlowContainer panels;
+ private FillFlowContainer panels = null!;
+ private ParticipantPanel? currentHostPanel;
- [CanBeNull]
- private ParticipantPanel currentHostPanel;
+ [Resolved]
+ private MultiplayerClient client { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
@@ -37,11 +37,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
};
}
- protected override void OnRoomUpdated()
+ protected override void LoadComplete()
{
- base.OnRoomUpdated();
+ base.LoadComplete();
- if (Room == null)
+ client.RoomUpdated += onRoomUpdated;
+ updateState();
+ }
+
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
+
+ private void updateState()
+ {
+ if (client.Room == null)
panels.Clear();
else
{
@@ -49,15 +57,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
foreach (var p in panels)
{
// Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run.
- if (Room.Users.All(u => !ReferenceEquals(p.User, u)))
+ if (client.Room.Users.All(u => !ReferenceEquals(p.User, u)))
p.Expire();
}
// Add panels for all users new to the room.
- foreach (var user in Room.Users.Except(panels.Select(p => p.User)))
+ foreach (var user in client.Room.Users.Except(panels.Select(p => p.User)))
panels.Add(new ParticipantPanel(user));
- if (currentHostPanel == null || !currentHostPanel.User.Equals(Room.Host))
+ if (currentHostPanel == null || !currentHostPanel.User.Equals(client.Room.Host))
{
// Reset position of previous host back to normal, if one existing.
if (currentHostPanel != null && panels.Contains(currentHostPanel))
@@ -66,9 +74,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
currentHostPanel = null;
// Change position of new host to display above all participants.
- if (Room.Host != null)
+ if (client.Room.Host != null)
{
- currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(Room.Host));
+ currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(client.Room.Host));
if (currentHostPanel != null)
panels.SetLayoutPosition(currentHostPanel, -1);
@@ -76,5 +84,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
}
}
}
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ client.RoomUpdated -= onRoomUpdated;
+ }
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs
index fe57ad26a5..bd9511d50d 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs
@@ -1,12 +1,11 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
@@ -20,27 +19,26 @@ using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{
- internal partial class TeamDisplay : MultiplayerRoomComposite
+ internal partial class TeamDisplay : CompositeDrawable
{
private readonly MultiplayerRoomUser user;
- private Drawable box;
-
- private Sample sampleTeamSwap;
+ [Resolved]
+ private OsuColour colours { get; set; } = null!;
[Resolved]
- private OsuColour colours { get; set; }
+ private MultiplayerClient client { get; set; } = null!;
- private OsuClickableContainer clickableContent;
+ private OsuClickableContainer clickableContent = null!;
+ private Drawable box = null!;
+ private Sample? sampleTeamSwap;
public TeamDisplay(MultiplayerRoomUser user)
{
this.user = user;
RelativeSizeAxes = Axes.Y;
-
AutoSizeAxes = Axes.X;
-
Margin = new MarginPadding { Horizontal = 3 };
}
@@ -71,7 +69,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
}
};
- if (Client.LocalUser?.Equals(user) == true)
+ if (client.LocalUser?.Equals(user) == true)
{
clickableContent.Action = changeTeam;
clickableContent.TooltipText = "Change team";
@@ -80,23 +78,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
sampleTeamSwap = audio.Samples.Get(@"Multiplayer/team-swap");
}
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ client.RoomUpdated += onRoomUpdated;
+ updateState();
+ }
+
private void changeTeam()
{
- Client.SendMatchRequest(new ChangeTeamRequest
+ client.SendMatchRequest(new ChangeTeamRequest
{
- TeamID = ((Client.LocalUser?.MatchState as TeamVersusUserState)?.TeamID + 1) % 2 ?? 0,
+ TeamID = ((client.LocalUser?.MatchState as TeamVersusUserState)?.TeamID + 1) % 2 ?? 0,
}).FireAndForget();
}
public int? DisplayedTeam { get; private set; }
- protected override void OnRoomUpdated()
- {
- base.OnRoomUpdated();
+ private void onRoomUpdated() => Scheduler.AddOnce(updateState);
+ private void updateState()
+ {
// we don't have a way of knowing when an individual user's state has updated, so just handle on RoomUpdated for now.
- var userRoomState = Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState;
+ var userRoomState = client.Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState;
const double duration = 400;
@@ -138,5 +144,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
return colours.Blue;
}
}
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (client.IsNotNull())
+ client.RoomUpdated -= onRoomUpdated;
+ }
}
}
diff --git a/osu.Game/Screens/Play/Break/BreakInfoLine.cs b/osu.Game/Screens/Play/Break/BreakInfoLine.cs
index df71767f82..79b417732a 100644
--- a/osu.Game/Screens/Play/Break/BreakInfoLine.cs
+++ b/osu.Game/Screens/Play/Break/BreakInfoLine.cs
@@ -63,7 +63,7 @@ namespace osu.Game.Screens.Play.Break
protected virtual LocalisableString Format(T count)
{
if (count is Enum countEnum)
- return countEnum.GetDescription();
+ return countEnum.GetLocalisableDescription();
return count.ToString() ?? string.Empty;
}
diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs
index 7471955493..3d46517a68 100644
--- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs
+++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs
@@ -316,7 +316,7 @@ namespace osu.Game.Screens.Play.HUD
HasQuit.BindValueChanged(_ => updateState());
- isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.Id);
+ isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.TargetID);
}
protected override void LoadComplete()
diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs
index 292f554483..a6c2405eb6 100644
--- a/osu.Game/Screens/Play/HUDOverlay.cs
+++ b/osu.Game/Screens/Play/HUDOverlay.cs
@@ -300,7 +300,9 @@ namespace osu.Game.Screens.Play
if (element is LegacyHealthDisplay)
return;
- float bottom = drawable.ScreenSpaceDrawQuad.BottomRight.Y;
+ // AABB is used here because the drawable can be flipped/rotated arbitrarily,
+ // so the "bottom right" corner of the raw SSDQ might not necessarily be where one expects it to be.
+ float bottom = drawable.ScreenSpaceDrawQuad.AABBFloat.BottomRight.Y;
bool isRelativeX = drawable.RelativeSizeAxes == Axes.X;
@@ -319,7 +321,7 @@ namespace osu.Game.Screens.Play
// and align bottom-right components with the top-edge of the highest bottom-anchored hud element.
else if (drawable.Anchor.HasFlag(Anchor.BottomRight) || (drawable.Anchor.HasFlag(Anchor.y2) && drawable.RelativeSizeAxes == Axes.X))
{
- var topLeft = element.ScreenSpaceDrawQuad.TopLeft;
+ var topLeft = element.ScreenSpaceDrawQuad.AABBFloat.TopLeft;
if (highestBottomScreenSpace == null || topLeft.Y < highestBottomScreenSpace.Value.Y)
highestBottomScreenSpace = topLeft;
}
diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs
index 44f91b4df1..95268c35da 100644
--- a/osu.Game/Screens/Select/BeatmapCarousel.cs
+++ b/osu.Game/Screens/Select/BeatmapCarousel.cs
@@ -216,18 +216,12 @@ namespace osu.Game.Screens.Select
private int visibleSetsCount;
- public BeatmapCarousel(FilterCriteria initialCriterial)
+ public BeatmapCarousel(FilterCriteria initialCriteria)
{
root = new CarouselRoot(this);
InternalChild = new Container
{
RelativeSizeAxes = Axes.Both,
- Padding = new MarginPadding
- {
- // Avoid clash between scrollbar and osu! logo.
- Top = 10,
- Bottom = 100,
- },
Children = new Drawable[]
{
setPool,
@@ -239,7 +233,7 @@ namespace osu.Game.Screens.Select
}
};
- activeCriteria = initialCriterial;
+ activeCriteria = initialCriteria;
}
[BackgroundDependencyLoader]
@@ -1116,11 +1110,6 @@ namespace osu.Game.Screens.Select
// adjusting the item's overall X position can cause it to become masked away when
// child items (difficulties) are still visible.
item.Header.X = offsetX(dist, visibleHalfHeight) - (parent?.X ?? 0);
-
- // We are applying a multiplicative alpha (which is internally done by nesting an
- // additional container and setting that container's alpha) such that we can
- // layer alpha transformations on top.
- item.SetMultiplicativeAlpha(Math.Clamp(1.75f - 1.5f * dist, 0, 1));
}
private enum PendingScrollOperation
@@ -1271,6 +1260,38 @@ namespace osu.Game.Screens.Select
return base.OnDragStart(e);
}
+
+ protected override ScrollbarContainer CreateScrollbar(Direction direction)
+ {
+ return new PaddedScrollbar();
+ }
+
+ protected partial class PaddedScrollbar : OsuScrollbar
+ {
+ public PaddedScrollbar()
+ : base(Direction.Vertical)
+ {
+ }
+ }
+
+ private const float top_padding = 10;
+ private const float bottom_padding = 70;
+
+ protected override float ToScrollbarPosition(float scrollPosition)
+ {
+ if (Precision.AlmostEquals(0, ScrollableExtent))
+ return 0;
+
+ return top_padding + (ScrollbarMovementExtent - (top_padding + bottom_padding)) * (scrollPosition / ScrollableExtent);
+ }
+
+ protected override float FromScrollbarPosition(float scrollbarPosition)
+ {
+ if (Precision.AlmostEquals(0, ScrollbarMovementExtent))
+ return 0;
+
+ return ScrollableExtent * ((scrollbarPosition - top_padding) / (ScrollbarMovementExtent - (top_padding + bottom_padding)));
+ }
}
protected override void Dispose(bool isDisposing)
diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs
index 359e0f6c78..75c13c1be6 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs
@@ -32,6 +32,8 @@ using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osuTK;
using osuTK.Graphics;
+using CommonStrings = osu.Game.Localisation.CommonStrings;
+using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Screens.Select.Carousel
{
@@ -296,10 +298,10 @@ namespace osu.Game.Screens.Select.Carousel
items.Add(new OsuMenuItem("Collections") { Items = collectionItems });
if (beatmapInfo.GetOnlineURL(api, ruleset.Value) is string url)
- items.Add(new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyUrlToClipboard(url)));
+ items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyUrlToClipboard(url)));
if (hideRequested != null)
- items.Add(new OsuMenuItem(CommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo)));
+ items.Add(new OsuMenuItem(WebCommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo)));
return items.ToArray();
}
diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
index eba40994e2..996d9ea0ab 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
@@ -20,6 +20,7 @@ using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
+using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Overlays;
using osu.Game.Rulesets;
@@ -300,7 +301,7 @@ namespace osu.Game.Screens.Select.Carousel
items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet)));
if (beatmapSet.GetOnlineURL(api, ruleset.Value) is string url)
- items.Add(new OsuMenuItem("Copy link", MenuItemType.Standard, () => game?.CopyUrlToClipboard(url)));
+ items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => game?.CopyUrlToClipboard(url)));
if (dialogOverlay != null)
items.Add(new OsuMenuItem("Delete...", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet))));
diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs
index 755008d370..10921c331e 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs
@@ -86,8 +86,6 @@ namespace osu.Game.Screens.Select.Carousel
};
}
- public void SetMultiplicativeAlpha(float alpha) => Header.BorderContainer.Alpha = alpha;
-
protected override void LoadComplete()
{
base.LoadComplete();
diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs
index 877db75317..b221296ba8 100644
--- a/osu.Game/Screens/Select/FilterControl.cs
+++ b/osu.Game/Screens/Select/FilterControl.cs
@@ -28,7 +28,6 @@ using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select.Filter;
using osuTK;
-using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Screens.Select
@@ -97,8 +96,8 @@ namespace osu.Game.Screens.Select
{
new Box
{
- Colour = Color4.Black,
- Alpha = 0.8f,
+ Colour = OsuColour.Gray(0.05f),
+ Alpha = 0.96f,
Width = 2,
RelativeSizeAxes = Axes.Both,
},
diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs
index 933df2464a..1d05f644b7 100644
--- a/osu.Game/Screens/Select/Footer.cs
+++ b/osu.Game/Screens/Select/Footer.cs
@@ -6,12 +6,11 @@
using System.Collections.Generic;
using System.Linq;
using osuTK;
-using osuTK.Graphics;
-using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
+using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Screens.Select
@@ -82,14 +81,15 @@ namespace osu.Game.Screens.Select
{
RelativeSizeAxes = Axes.Both,
Size = Vector2.One,
- Colour = Color4.Black.Opacity(0.5f),
+ Colour = OsuColour.Gray(0.1f),
+ Alpha = 0.96f,
},
modeLight = new Box
{
RelativeSizeAxes = Axes.X,
Height = 3,
Position = new Vector2(0, -3),
- Colour = Color4.Black,
+ Colour = OsuColour.Gray(0.1f),
},
new FillFlowContainer
{
diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs
index ea5048ca49..9f7a2c02ff 100644
--- a/osu.Game/Screens/Select/SongSelect.cs
+++ b/osu.Game/Screens/Select/SongSelect.cs
@@ -711,12 +711,6 @@ namespace osu.Game.Screens.Select
Carousel.AllowSelection = true;
- if (pendingFilterApplication)
- {
- Carousel.Filter(FilterControl.CreateCriteria());
- pendingFilterApplication = false;
- }
-
BeatmapDetails.Refresh();
beginLooping();
@@ -749,6 +743,17 @@ namespace osu.Game.Screens.Select
FilterControl.Activate();
}
+ protected override void Update()
+ {
+ base.Update();
+
+ if (Carousel.AllowSelection && pendingFilterApplication)
+ {
+ Carousel.Filter(FilterControl.CreateCriteria());
+ pendingFilterApplication = false;
+ }
+ }
+
public override void OnSuspending(ScreenTransitionEvent e)
{
// Handle the case where FinaliseSelection is never called (ie. when a screen is pushed externally).
diff --git a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs
index b6508e177a..732fb2cd8c 100644
--- a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs
+++ b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs
@@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
+using osu.Framework.Platform;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Graphics;
@@ -23,6 +24,7 @@ using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
@@ -82,6 +84,12 @@ namespace osu.Game.Screens.SelectV2.Leaderboards
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
+ [Resolved]
+ private Clipboard? clipboard { get; set; }
+
+ [Resolved]
+ private IAPIProvider api { get; set; } = null!;
+
private Container content = null!;
private Box background = null!;
private Box foreground = null!;
@@ -769,6 +777,9 @@ namespace osu.Game.Screens.SelectV2.Leaderboards
if (score.Mods.Length > 0)
items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = score.Mods.Where(m => IsValidMod.Invoke(m)).ToArray()));
+ if (score.OnlineID > 0)
+ items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{score.OnlineID}")));
+
if (score.Files.Count <= 0) return items.ToArray();
items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(score)));
diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs
index a52325dea2..aa8aff3adc 100644
--- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs
+++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs
@@ -59,20 +59,20 @@ namespace osu.Game.Tests.Visual
protected override void LoadComplete()
{
base.LoadComplete();
-
ResetPlacement();
}
- public void BeginPlacement(HitObject hitObject)
+ public void ShowPlacement(HitObject hitObject)
{
}
- public void EndPlacement(HitObject hitObject, bool commit)
+ public void HidePlacement()
{
- if (commit)
- AddHitObject(CreateHitObject(hitObject));
+ }
- ResetPlacement();
+ public void CommitPlacement(HitObject hitObject)
+ {
+ AddHitObject(CreateHitObject(hitObject));
}
protected void ResetPlacement()
@@ -89,6 +89,10 @@ namespace osu.Game.Tests.Visual
protected override void Update()
{
base.Update();
+
+ if (CurrentBlueprint.PlacementActive == PlacementBlueprint.PlacementState.Finished)
+ ResetPlacement();
+
updatePlacementTimeAndPosition();
}
diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs
index b88619c8b7..0d3ea52611 100644
--- a/osu.Game/Users/UserPanel.cs
+++ b/osu.Game/Users/UserPanel.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
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.Graphics.Sprites;
@@ -13,6 +14,7 @@ using osu.Game.Overlays;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.UserInterface;
using osu.Framework.Graphics.Cursor;
+using osu.Framework.Localisation;
using osu.Framework.Screens;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
@@ -28,7 +30,7 @@ using osuTK;
namespace osu.Game.Users
{
- public abstract partial class UserPanel : OsuClickableContainer, IHasContextMenu
+ public abstract partial class UserPanel : OsuClickableContainer, IHasContextMenu, IFilterable
{
public readonly APIUser User;
@@ -162,5 +164,20 @@ namespace osu.Game.Users
return items.ToArray();
}
}
+
+ public IEnumerable FilterTerms => [User.Username];
+
+ public bool MatchingFilter
+ {
+ set
+ {
+ if (value)
+ Show();
+ else
+ Hide();
+ }
+ }
+
+ public bool FilteringActive { get; set; }
}
}
diff --git a/osu.Game/Users/UserRankPanel.cs b/osu.Game/Users/UserRankPanel.cs
index 0d57b7bb7d..6d8d7dd143 100644
--- a/osu.Game/Users/UserRankPanel.cs
+++ b/osu.Game/Users/UserRankPanel.cs
@@ -152,7 +152,7 @@ namespace osu.Game.Users
Margin = new MarginPadding { Top = main_content_height },
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
- Padding = new MarginPadding { Left = 80, Vertical = padding },
+ Padding = new MarginPadding(padding),
ColumnDimensions = new[]
{
new Dimension(),
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 73668536e0..855ba3374b 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -20,7 +20,7 @@
-
+
@@ -35,9 +35,9 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+